From 5c19fcaca9670d10caade5d5779a5c2eafdc3ad2 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 09:36:00 +0200 Subject: [PATCH 1/6] fix(forecast): credit the live in-flight load to its model, not recovery runReplan now tells the forecast archive which published loads Core replaced. The archive used to infer it by comparing the published loads with the selected forecast, which did not yet carry the live meter's in-flight load, so slot 0 was tagged core_recovery on every replan with a valid meter. Co-Authored-By: Claude Opus 5.5 --- go/cmd/ftw/forecast_archive_retry_test.go | 6 +-- go/cmd/ftw/forecast_horizon_native_test.go | 2 +- go/cmd/ftw/forecast_live_archive_test.go | 63 ++++++++++++++++++++++ go/cmd/ftw/forecast_load_risk_test.go | 4 +- go/cmd/ftw/forecast_occupancy_test.go | 2 +- go/cmd/ftw/forecast_primary_test.go | 27 ++++++---- go/cmd/ftw/forecast_source_choice_test.go | 10 ++-- go/cmd/ftw/forecast_tracking.go | 21 +++----- go/cmd/ftw/forecast_tracking_test.go | 26 ++++----- go/internal/mpc/core_recovery_test.go | 2 +- go/internal/mpc/forecast_primary_test.go | 2 +- go/internal/mpc/forecast_recovery_test.go | 43 ++++++++++++++- go/internal/mpc/forecast_snapshot.go | 4 +- go/internal/mpc/recovery_inputs_test.go | 2 +- go/internal/mpc/service.go | 15 +++++- 15 files changed, 173 insertions(+), 56 deletions(-) create mode 100644 go/cmd/ftw/forecast_live_archive_test.go diff --git a/go/cmd/ftw/forecast_archive_retry_test.go b/go/cmd/ftw/forecast_archive_retry_test.go index d9c48759f..ed5986ad1 100644 --- a/go/cmd/ftw/forecast_archive_retry_test.go +++ b/go/cmd/ftw/forecast_archive_retry_test.go @@ -50,7 +50,7 @@ func TestForecastArchiveSurvivesWriterContention(t *testing.T) { f := trackerFixture(at) f.store = st slots := trackerSlots(at.Add(time.Minute), 193) - f.Snapshot(at, trackerWeather(at.Add(time.Minute), at)).Record(slots, slots, "contended-decision", at.UnixMilli()) + f.Snapshot(at, trackerWeather(at.Add(time.Minute), at)).Record(slots, slots, nil, "contended-decision", at.UnixMilli()) frozen := <-f.queue f.queue <- frozen workerCtx, stop := context.WithCancel(ctx) @@ -73,7 +73,7 @@ func TestForecastArchiveSurvivesWriterContention(t *testing.T) { if _, err := conn.ExecContext(ctx, "ROLLBACK"); err != nil { t.Fatal(err) } - f.Snapshot(at.Add(time.Second), trackerWeather(at.Add(time.Minute), at)).Record(slots, slots, "next-decision", at.Add(time.Second).UnixMilli()) + f.Snapshot(at.Add(time.Second), trackerWeather(at.Add(time.Minute), at)).Record(slots, slots, nil, "next-decision", at.Add(time.Second).UnixMilli()) deadline := time.NewTimer(5 * time.Second) defer deadline.Stop() @@ -128,7 +128,7 @@ func TestForecastArchiveRetryPreservesFrozenIssue(t *testing.T) { at := time.Now().UTC().Truncate(time.Minute) f := trackerFixture(at) slots := trackerSlots(at.Add(time.Minute), 193) - f.Snapshot(at, trackerWeather(at.Add(time.Minute), at)).Record(slots, slots, "frozen-decision", at.UnixMilli()) + f.Snapshot(at, trackerWeather(at.Add(time.Minute), at)).Record(slots, slots, nil, "frozen-decision", at.UnixMilli()) frozen := (<-f.queue).issue calls := 0 err = saveForecastIssueWithRetry(ctx, frozen, func(writeCtx context.Context, issue forecasting.Issue) error { diff --git a/go/cmd/ftw/forecast_horizon_native_test.go b/go/cmd/ftw/forecast_horizon_native_test.go index 86531a5cb..347717aa0 100644 --- a/go/cmd/ftw/forecast_horizon_native_test.go +++ b/go/cmd/ftw/forecast_horizon_native_test.go @@ -68,7 +68,7 @@ func TestForecastPrimaryNativeFullHorizonArchivesEveryInterval(t *testing.T) { t.Fatalf("cold load replaced site prior at interval %d of %d", i, tc.count) } } - inputs.Record(resolved, resolved, "full-horizon-"+tc.name, origin.Add(time.Second).UnixMilli()) + inputs.Record(resolved, resolved, nil, "full-horizon-"+tc.name, origin.Add(time.Second).UnixMilli()) issue := (<-tracker.queue).issue if err := issue.Validate(); err != nil { t.Fatalf("composed forecast rejected: %v", err) diff --git a/go/cmd/ftw/forecast_live_archive_test.go b/go/cmd/ftw/forecast_live_archive_test.go new file mode 100644 index 000000000..d71ce6eb8 --- /dev/null +++ b/go/cmd/ftw/forecast_live_archive_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// The live meter replaces the in-flight slot's load on every replan. That is +// a measurement, not Core's recovery, so the archive keeps the model's source +// for it and for every later slot whose forecast passed unchanged. +func TestForecastArchiveKeepsModelSourceUnderLiveLoad(t *testing.T) { + at := time.Now().UTC().Truncate(15 * time.Minute) + f := primaryFixture(at, primaryReply) + st, err := state.Open(filepath.Join(t.TempDir(), "plan.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + var prices []state.PricePoint + for i := 0; i < 8; i++ { + prices = append(prices, state.PricePoint{Zone: "SE3", SlotTsMs: at.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(), + SlotLenMin: 15, SpotOreKwh: 50, TotalOreKwh: 100 + float64(i)*10, Source: "test", FetchedAtMs: at.UnixMilli()}) + } + if err := st.SavePrices(prices); err != nil { + t.Fatal(err) + } + svc := mpc.New(st, nil, "SE3", mpc.Params{Mode: mpc.ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5, + CapacityWh: 10000, InitialSoC: .5, SoCMin: .1, SoCMax: .9, MaxChargeW: 3000, MaxDischargeW: 3000, + ChargeEfficiency: .95, DischargeEfficiency: .95}) + svc.BaseLoad = 500 + svc.ForecastSnapshot = f.Snapshot + svc.HouseholdMeasurement = func() telemetry.ForecastReading { + return telemetry.ForecastReading{HouseholdW: 2345, Valid: true} + } + if plan := svc.Replan(context.Background()); plan == nil { + t.Fatalf("no plan: %+v", svc.PlanSnapshot()) + } + issue := (<-f.queue).issue + points := primarySeries(t, issue, "champion").Points + if len(points) < 4 { + t.Fatalf("short archive: %d points", len(points)) + } + if points[0].LoadW != 2345 { + t.Fatalf("slot 0 load %g, want the live 2345 W", points[0].LoadW) + } + for i, p := range points[:4] { + if p.LoadSource != "energyplan" || p.ModelLoad == nil || p.LoadQuality == "fallback" { + t.Fatalf("slot %d attributed to recovery: source=%q quality=%q model=%v", i, p.LoadSource, p.LoadQuality, p.ModelLoad) + } + } + if points[1].LoadW == points[2].LoadW { + t.Fatalf("fixture loads do not vary: %+v", points[:3]) + } + if err := issue.Validate(); err != nil { + t.Fatal(err) + } +} diff --git a/go/cmd/ftw/forecast_load_risk_test.go b/go/cmd/ftw/forecast_load_risk_test.go index 0dbd67eb1..93dec2a3e 100644 --- a/go/cmd/ftw/forecast_load_risk_test.go +++ b/go/cmd/ftw/forecast_load_risk_test.go @@ -34,7 +34,7 @@ func TestForecastPrimaryLoadQualityKeepsSitePriorUntilLearning(t *testing.T) { if got[0].LoadW != want || got[0].PVW != -100 { t.Fatalf("load selection changed the site prior or independent PV: %+v", got) } - in.Record(got, got, "load-quality", at.UnixMilli()) + in.Record(got, got, nil, "load-quality", at.UnixMilli()) issue := (<-f.queue).issue point := primarySeries(t, issue, "champion").Points[0] if point.LoadW != want || point.LoadSource != source { @@ -135,7 +135,7 @@ func TestForecastLoadRiskBeforeNetCalibration(t *testing.T) { if !reflect.DeepEqual(base, unchanged) { t.Fatal("risk changed the issued forecast") } - in.Record(base, planning, "load-risk", at.UnixMilli()) + in.Record(base, planning, nil, "load-risk", at.UnixMilli()) issue := (<-f.queue).issue if primarySeries(t, issue, "champion").Points[0].LoadW != 1000 || primarySeries(t, issue, "planning").Points[0].LoadW != tc.wantLoad { t.Fatal("archive lost the difference between forecast and planning margin") diff --git a/go/cmd/ftw/forecast_occupancy_test.go b/go/cmd/ftw/forecast_occupancy_test.go index d02eac075..671e9676e 100644 --- a/go/cmd/ftw/forecast_occupancy_test.go +++ b/go/cmd/ftw/forecast_occupancy_test.go @@ -21,7 +21,7 @@ func TestForecastOccupancySnapshotSurvivesIntentChange(t *testing.T) { inputs := tracker.Snapshot(at, trackerWeather(at, at)) away = false slots := trackerSlots(at, 4) - inputs.Record(slots, slots, "occupancy-frozen", at.UnixMilli()) + inputs.Record(slots, slots, nil, "occupancy-frozen", at.UnixMilli()) job := <-tracker.queue if len(job.issue.Occupancy) != 193 { t.Fatalf("captured quarter count=%d", len(job.issue.Occupancy)) diff --git a/go/cmd/ftw/forecast_primary_test.go b/go/cmd/ftw/forecast_primary_test.go index 8b78de3bf..5a16ded64 100644 --- a/go/cmd/ftw/forecast_primary_test.go +++ b/go/cmd/ftw/forecast_primary_test.go @@ -70,7 +70,7 @@ func TestForecastPrimaryRustControlsSlotsAndArchivesFrozenLegacy(t *testing.T) { *weather[0].SolarWm2 = 999 f.pv.SetRated(100000) f.load.SetHeatingCoef(900) - in.Record(base, planning, "decision", at.Add(time.Second).UnixMilli()) + in.Record(base, planning, nil, "decision", at.Add(time.Second).UnixMilli()) issue := (<-f.queue).issue if err := issue.Validate(); err != nil { t.Fatal(err) @@ -107,7 +107,7 @@ func TestForecastPrimarySelectsEachSignalAndRealZero(t *testing.T) { if got[0].PVW != legacy[0].PVW || got[0].LoadW != 1100 || got[1].PVW != 0 || got[1].LoadW != legacy[1].LoadW { t.Fatalf("signal fallback or real zero wrong: %+v", got) } - in.Record(got, got, "decision", at.UnixMilli()) + in.Record(got, got, nil, "decision", at.UnixMilli()) points := primarySeries(t, (<-f.queue).issue, "champion").Points if points[0].PVSource != "legacy" || points[0].ModelPV != nil || points[0].PVKnown || points[1].LoadSource != "legacy" || points[1].ModelLoad != nil || !points[1].PVKnown || points[1].PVQuality != "cold_start" { t.Fatalf("fallback fabricated knowledge or retained wrong model range: %+v", points) @@ -144,7 +144,7 @@ func TestForecastPrimaryUnavailableRetainsLegacy(t *testing.T) { if !reflect.DeepEqual(got, legacy) { t.Fatalf("unavailable worker changed legacy fallback: %+v", got) } - in.Record(got, got, "decision", at.UnixMilli()) + in.Record(got, got, nil, "decision", at.UnixMilli()) issue := (<-f.queue).issue if primarySeries(t, issue, "champion").Points[0].LoadSource != "legacy" { t.Fatal("fallback not explicit") @@ -163,7 +163,7 @@ func TestForecastPrimaryPartialCurrentIntervalUsesRustRemainder(t *testing.T) { if got[0].PVW != -100 || got[0].LoadW != 1100 || got[1].PVW != -200 || got[2].LoadW != 1300 { t.Fatalf("current interval did not use Rust remainder: %+v", got) } - in.Record(got, got, "partial-decision", at.UnixMilli()) + in.Record(got, got, nil, "partial-decision", at.UnixMilli()) issue := (<-f.queue).issue if err := issue.Validate(); err != nil { t.Fatal(err) @@ -202,7 +202,7 @@ func TestForecastPrimaryNativeColdLoadKeepsHeatingPriorUntilLearning(t *testing. if got[0].LoadW != legacy[0].LoadW { t.Fatalf("cold model replaced heating prior: %v -> %v", legacy[0].LoadW, got[0].LoadW) } - in.Record(got, got, "native-decision", at.Add(time.Second).UnixMilli()) + in.Record(got, got, nil, "native-decision", at.Add(time.Second).UnixMilli()) issue := (<-f.queue).issue point := primarySeries(t, issue, "champion").Points[0] if point.LoadSource != "legacy" || !point.LoadKnown || point.ModelLoad != nil { @@ -217,7 +217,7 @@ func TestForecastPrimaryNativeColdLoadKeepsHeatingPriorUntilLearning(t *testing. if next[0].LoadW >= 1000 { t.Fatalf("measured low load did not replace the prior: %+v", next[0]) } - learned.Record(next, next, "learned-decision", at.UnixMilli()) + learned.Record(next, next, nil, "learned-decision", at.UnixMilli()) if p := primarySeries(t, (<-f.queue).issue, "champion").Points[0]; p.LoadSource != "energyplan" || p.LoadQuality != "learning" { t.Fatalf("learned load did not take over: %+v", p) } @@ -250,7 +250,7 @@ func TestForecastPrimaryResolveUsesCapturedStateAndFeatures(t *testing.T) { if got[0].PVW != -100 || string(request.State) != `{"epoch":1}` || request.Horizon[0].Home == nil || !*request.Horizon[0].Home || *request.Horizon[0].GHIWm2 != 500 { t.Fatalf("live state or features leaked into primary request: %+v", request) } - in.Record(got, got, "decision", at.Add(time.Second).UnixMilli()) + in.Record(got, got, nil, "decision", at.Add(time.Second).UnixMilli()) issue := (<-f.queue).issue for _, model := range issue.Models { if model.Name == "energyplan" && string(model.State) != `{"epoch":1}` { @@ -281,7 +281,7 @@ func TestForecastPrimaryNativeCurrentIntervalReplaysAfterNewObservation(t *testi if got[0].LoadW == legacy[0].LoadW { t.Fatal("first current control interval still used legacy load") } - in.Record(got, got, "native-partial", origin.Add(time.Second).UnixMilli()) + in.Record(got, got, nil, "native-partial", origin.Add(time.Second).UnixMilli()) issue := (<-f.queue).issue primary := primarySeries(t, issue, "champion").Points[0] if primary.PredictionStartMS != origin.UnixMilli() || primary.LoadSource != "energyplan" { @@ -323,15 +323,20 @@ func TestForecastRecoveryArchiveDoesNotClaimRejectedModel(t *testing.T) { in := f.Snapshot(at, trackerWeather(at, at)) base := in.Resolve(context.Background(), trackerSlots(at, 4)) rawLoad := base[0].LoadW - for i := range base { + recovered := []bool{true, true, true, false} + for i := range base[:3] { base[i].LoadW = 800 } - in.Record(base, base, "recovered-decision", at.UnixMilli()) + in.Record(base, base, recovered, "recovered-decision", at.UnixMilli()) issue := (<-f.queue).issue - champion := primarySeries(t, issue, "champion").Points[0] + points := primarySeries(t, issue, "champion").Points + champion := points[0] if champion.LoadW != 800 || champion.LoadSource != "core_recovery" || champion.ModelLoad != nil || champion.LoadQuality != "fallback" { t.Fatalf("recovery was attributed to rejected model: %+v", champion) } + if kept := points[3]; kept.LoadSource != "energyplan" || kept.ModelLoad == nil { + t.Fatalf("unrecovered slot lost its model: %+v", kept) + } if raw := primarySeries(t, issue, "energyplan").Points[0]; raw.LoadW != rawLoad || raw.LoadSource == "core_recovery" { t.Fatal("raw model prediction was overwritten") } diff --git a/go/cmd/ftw/forecast_source_choice_test.go b/go/cmd/ftw/forecast_source_choice_test.go index 349d32554..32477522e 100644 --- a/go/cmd/ftw/forecast_source_choice_test.go +++ b/go/cmd/ftw/forecast_source_choice_test.go @@ -146,7 +146,7 @@ func TestForecastSourceChoiceSteersResolve(t *testing.T) { if got[4].PVW != -100 { t.Fatalf("legacy PV without weather replaced Energyplan PV: %+v", got[4]) } - in.Record(got, got, "decision", at.UnixMilli()) + in.Record(got, got, nil, "decision", at.UnixMilli()) points := primarySeries(t, (<-f.queue).issue, "champion").Points if p := points[0]; p.PVSource != "legacy" || p.LoadSource != "energyplan" || p.LoadQuality != "cold_start" || p.ModelLoad == nil { t.Fatalf("chosen sources not recorded: %+v", p) @@ -184,7 +184,7 @@ func TestForecastPVHorizonChoiceReachesPlannerAndArchive(t *testing.T) { if resolved[0].PVW != -2000 || resolved[1].PVW != -100 { t.Fatalf("per-horizon PV did not reach MPC: %+v", resolved) } - in.Record(resolved, resolved, "horizon-choice", at.UnixMilli()) + in.Record(resolved, resolved, nil, "horizon-choice", at.UnixMilli()) issue := (<-f.queue).issue champion := primarySeries(t, issue, "champion").Points if champion[0].PVSource != "legacy" || champion[0].ModelPV != nil || champion[1].PVSource != "energyplan" || champion[1].ModelPV == nil { @@ -212,7 +212,7 @@ func TestForecastLoadHorizonChoiceReachesPlannerAndArchive(t *testing.T) { if resolved[0].LoadW != legacy[0].LoadW || resolved[1].LoadW != 1100 { t.Fatalf("per-horizon load did not reach MPC: %+v", resolved) } - in.Record(resolved, resolved, "load-horizon-choice", at.UnixMilli()) + in.Record(resolved, resolved, nil, "load-horizon-choice", at.UnixMilli()) issue := (<-f.queue).issue champion := primarySeries(t, issue, "champion").Points if champion[0].LoadSource != "legacy" || champion[0].ModelLoad != nil || champion[1].LoadSource != "energyplan" || champion[1].ModelLoad == nil { @@ -240,7 +240,7 @@ func TestForecastColdLoadCannotBorrowAnotherHorizonsEvidence(t *testing.T) { if got[0].LoadW != 6000 || got[1].LoadW != 1100 { t.Fatalf("cold load borrowed another horizon's evidence: %+v", got) } - in.Record(got, got, "cold-load-horizon", at.UnixMilli()) + in.Record(got, got, nil, "cold-load-horizon", at.UnixMilli()) points := primarySeries(t, (<-f.queue).issue, "champion").Points if points[0].LoadSource != "legacy" || points[0].ModelLoad != nil || points[1].LoadSource != "energyplan" { t.Fatalf("cold load fallback provenance lost: %+v", points) @@ -331,7 +331,7 @@ func TestForecastDaytimeRiskAndArchivedBandsExcludeNightErrors(t *testing.T) { if planning[0].PVW != 0 || planning[0].LoadW != 2000 { t.Fatalf("night errors narrowed the daytime margin: base %+v planning %+v", base[0], planning[0]) } - in.Record(base, planning, "daytime-risk", at.UnixMilli()) + in.Record(base, planning, nil, "daytime-risk", at.UnixMilli()) issue := (<-f.queue).issue point := primarySeries(t, issue, "energyplan").Points[0] if point.PVBand.Samples != daylightHours || point.NetBand.Samples != daylightHours || point.PVBand.Method != forecasting.BandMethodEmpirical || point.NetBand.HighW != 2000 { diff --git a/go/cmd/ftw/forecast_tracking.go b/go/cmd/ftw/forecast_tracking.go index 5309dd28f..f41c6be52 100644 --- a/go/cmd/ftw/forecast_tracking.go +++ b/go/cmd/ftw/forecast_tracking.go @@ -675,7 +675,7 @@ func (f *forecastTracker) Snapshot(_ time.Time, weather []state.ForecastPoint) m } } } - in.Record = func(base, planning []mpc.Slot, decisionID string, issued int64) { + in.Record = func(base, planning []mpc.Slot, recoveredLoad []bool, decisionID string, issued int64) { issue := makeIssue(base, issued) issue.DecisionID = decisionID if prediction != nil { @@ -686,7 +686,7 @@ func (f *forecastTracker) Snapshot(_ time.Time, weather []state.ForecastPoint) m issue.Series[0].ModelVersion = version // Core may recover with a prior after the selected model fails. Keep // its accepted inputs separate from the raw worker and legacy series. - selected = recoveredForecastSources(base, selected) + selected = recoveredForecastSources(selected, recoveredLoad) issue.Series[0].Points = primaryForecastPoints(base, issue.Series[0].Points, selected) issue.Series = append(issue.Series, forecasting.Series{Name: "planning", ModelVersion: version, Points: primaryForecastPoints(planning, forecastPoints(planning, frozen, site, origin, load), selected)}) @@ -724,22 +724,15 @@ func usablePrimaryForecast(known bool, quality string, watts float64) bool { return known && (quality == "cold_start" || quality == "learning" || quality == "ready") && watts >= 0 && !math.IsNaN(watts) && !math.IsInf(watts, 0) } -// A changed point forecast belongs to Core's recovery, not the model whose -// raw prediction was rejected. Risk adjustments keep the recovered source. -func recoveredForecastSources(base []mpc.Slot, selected []forecasting.Point) []forecasting.Point { +// A load Core recovered belongs to that recovery, not the model whose raw +// prediction was rejected. Risk adjustments keep the recovered source. +func recoveredForecastSources(selected []forecasting.Point, recoveredLoad []bool) []forecasting.Point { out := append([]forecasting.Point(nil), selected...) - for i, slot := range base { - if i >= len(out) { - break - } - if slot.LoadW != out[i].LoadW { + for i, recovered := range recoveredLoad { + if recovered && i < len(out) { out[i].LoadSource, out[i].LoadQuality = "core_recovery", "fallback" out[i].ModelLoad = nil } - if -slot.PVW != out[i].PVW { - out[i].PVSource, out[i].PVQuality = "core_recovery", "fallback" - out[i].ModelPV = nil - } } return out } diff --git a/go/cmd/ftw/forecast_tracking_test.go b/go/cmd/ftw/forecast_tracking_test.go index 54f81193f..09d0e9ff5 100644 --- a/go/cmd/ftw/forecast_tracking_test.go +++ b/go/cmd/ftw/forecast_tracking_test.go @@ -110,7 +110,7 @@ func TestForecastSnapshotFreezesHorizonAndRecord(t *testing.T) { t.Fatal("live changes altered captured horizon") } slots := trackerSlots(target, 1) - in.Record(slots, slots, "decision", origin.UnixMilli()) + in.Record(slots, slots, nil, "decision", origin.UnixMilli()) job := <-f.queue if err := job.issue.Validate(); err != nil { t.Fatal(err) @@ -125,7 +125,7 @@ func TestForecastSnapshotFreezesHorizonAndRecord(t *testing.T) { if job.issue.Series[0].Points[0].LoadQuality != "cold_start" { t.Fatal("cold load called trained") } - in.Record(trackerSlots(target, 1), trackerSlots(target, 1), "decision-2", origin.UnixMilli()) + in.Record(trackerSlots(target, 1), trackerSlots(target, 1), nil, "decision-2", origin.UnixMilli()) job2 := <-f.queue if job.issue.Series[0].ModelVersion != job2.issue.Series[0].ModelVersion { t.Fatal("one capture has multiple model revisions") @@ -146,7 +146,7 @@ func TestForecastSnapshotRejectsWeatherFromPreviousLocation(t *testing.T) { t.Fatal("weather captured before a location change survived the snapshot") } slots := trackerSlots(target, 1) - old.Record(slots, slots, "old-weather", origin.UnixMilli()) + old.Record(slots, slots, nil, "old-weather", origin.UnixMilli()) job := <-f.queue if err := job.issue.Validate(); err != nil { t.Fatal(err) @@ -162,7 +162,7 @@ func TestForecastSnapshotRejectsWeatherFromPreviousLocation(t *testing.T) { if len(fresh.Weather) != 1 { t.Fatal("new location weather was rejected") } - fresh.Record(slots, slots, "new-weather", origin.UnixMilli()) + fresh.Record(slots, slots, nil, "new-weather", origin.UnixMilli()) job = <-f.queue if len(job.issue.Weather) != 1 || !job.issue.Series[0].Points[0].PVKnown { t.Fatal("new location weather did not restore PV evidence") @@ -196,7 +196,7 @@ func TestForecastOriginFollowsCandidateCapture(t *testing.T) { f.candidate = c in := f.Snapshot(at, nil) slots := trackerSlots(at.Add(time.Minute), 1) - in.Record(slots, slots, "decision", clock.UnixMilli()) + in.Record(slots, slots, nil, "decision", clock.UnixMilli()) job := <-f.queue if job.issue.OriginMS != clock.UnixMilli() { t.Fatal("origin preceded candidate snapshot") @@ -248,7 +248,7 @@ func TestForecastArchiveRejectsCurrentPartialAsEvidence(t *testing.T) { f := trackerFixture(origin) in := f.Snapshot(origin, trackerWeather(origin.Truncate(time.Hour), origin)) slots := trackerSlots(origin.Truncate(15*time.Minute), 1) - in.Record(slots, slots, "decision", origin.UnixMilli()) + in.Record(slots, slots, nil, "decision", origin.UnixMilli()) job := <-f.queue end := slots[0].StartMs + 15*time.Minute.Milliseconds() obs := []forecasting.Observation{{StartMS: slots[0].StartMs, EndMS: end, AvailableAtMS: end, PVW: 2000, LoadW: 1000, PVKnown: true, LoadKnown: true, Quality: "complete_balance_v1", ConfigVersion: trackerSite().Revision}} @@ -267,7 +267,7 @@ func TestForecastColdPriorRiskPreservesPowerSigns(t *testing.T) { if planning[0].PVW <= base[0].PVW || planning[0].PVW > 0 || planning[1].PVW != 0 { t.Fatal("cold prior no hedge or invented solar") } - in.Record(base, planning, "decision", at.UnixMilli()) + in.Record(base, planning, nil, "decision", at.UnixMilli()) job := <-f.queue if job.issue.Series[0].Points[0].PVBand.Method != forecasting.BandMethodColdStart { t.Fatal("cold prior called empirical") @@ -319,7 +319,7 @@ func TestForecastStopDrainsIssuedChampion(t *testing.T) { } in := f.Snapshot(at, trackerWeather(at.Add(time.Minute), at)) slots := trackerSlots(at.Add(time.Minute), 1) - in.Record(slots, slots, "shutdown-decision", at.UnixMilli()) + in.Record(slots, slots, nil, "shutdown-decision", at.UnixMilli()) ctx, cancel := context.WithCancel(context.Background()) f.cancel = cancel f.wg = sync.WaitGroup{} @@ -348,7 +348,7 @@ func TestForecastFullHorizonRecordFitsArchive(t *testing.T) { slots[i].LoadW = 1234.567 + float64(i)*0.03 slots[i].PVW = -2345.678 } - in.Record(slots, slots, "full-horizon", at.UnixMilli()) + in.Record(slots, slots, nil, "full-horizon", at.UnixMilli()) job := <-f.queue if err := job.issue.Validate(); err != nil { t.Fatal(err) @@ -395,7 +395,7 @@ func TestForecastMalformedWeatherDoesNotPoisonArchive(t *testing.T) { rows[0].CloudCoverPct = nil in := f.Snapshot(at, rows) slots := trackerSlots(at, 1) - in.Record(slots, slots, "bad-weather", at.UnixMilli()) + in.Record(slots, slots, nil, "bad-weather", at.UnixMilli()) job := <-f.queue if err := job.issue.Validate(); err != nil { t.Fatal(err) @@ -411,9 +411,9 @@ func TestForecastDirectPVAndStableModelStateArchive(t *testing.T) { rows := trackerWeather(at, at) rows[0].Source = "forecast_solar" slots := trackerSlots(at, 1) - f.Snapshot(at, rows).Record(slots, slots, "first", at.UnixMilli()) + f.Snapshot(at, rows).Record(slots, slots, nil, "first", at.UnixMilli()) first := <-f.queue - f.Snapshot(at, rows).Record(slots, slots, "second", at.UnixMilli()) + f.Snapshot(at, rows).Record(slots, slots, nil, "second", at.UnixMilli()) second := <-f.queue if first.issue.Weather[0].DirectPVW == nil || *first.issue.Weather[0].DirectPVW != 4000 { t.Fatal("direct forecast power missing from input archive") @@ -439,7 +439,7 @@ func BenchmarkForecastTrackingFullHorizon(b *testing.B) { _ = in.Load(time.UnixMilli(s.StartMs)) } in.Risk(slots, append([]mpc.Slot(nil), slots...), 1) - in.Record(slots, slots, "benchmark", at.UnixMilli()) + in.Record(slots, slots, nil, "benchmark", at.UnixMilli()) <-f.queue } } diff --git a/go/internal/mpc/core_recovery_test.go b/go/internal/mpc/core_recovery_test.go index e4b13ed31..f38624530 100644 --- a/go/internal/mpc/core_recovery_test.go +++ b/go/internal/mpc/core_recovery_test.go @@ -181,7 +181,7 @@ func TestRecoveryPublishesFullPriorAndReturnsToModel(t *testing.T) { return 40000 } return 500 - }, Record: func(base, planning []Slot, _ string, _ int64) { + }, Record: func(base, planning []Slot, _ []bool, _ string, _ int64) { if len(base) != len(planning) { t.Errorf("recorded mismatched forecasts: %d != %d", len(base), len(planning)) } diff --git a/go/internal/mpc/forecast_primary_test.go b/go/internal/mpc/forecast_primary_test.go index 2c91db4b8..1d7a2e3ad 100644 --- a/go/internal/mpc/forecast_primary_test.go +++ b/go/internal/mpc/forecast_primary_test.go @@ -40,7 +40,7 @@ func TestNativePrimaryForecastReachesSolverBeforeRisk(t *testing.T) { planning[i].PVW, planning[i].LoadW = -2000, 800 } }, - Record: func(base, planning []Slot, decisionID string, issuedAtMS int64) { + Record: func(base, planning []Slot, _ []bool, decisionID string, issuedAtMS int64) { if decisionID == "" || issuedAtMS <= 0 { t.Error("forecast archived before a plan was published") } diff --git a/go/internal/mpc/forecast_recovery_test.go b/go/internal/mpc/forecast_recovery_test.go index d11a3d6c6..4a208416f 100644 --- a/go/internal/mpc/forecast_recovery_test.go +++ b/go/internal/mpc/forecast_recovery_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" ) // Reproduce the reported chain: prices exist, one impossible future forecast @@ -89,7 +90,7 @@ func TestNativePlannerRetainsPricesAndHorizonAfterBadForecast(t *testing.T) { slots[0].PVW = math.NaN() } }, - Record: func(base, planning []Slot, _ string, _ int64) { + Record: func(base, planning []Slot, _ []bool, _ string, _ int64) { archivedBase = append([]Slot(nil), base...) archivedPlanning = append([]Slot(nil), planning...) }, @@ -198,3 +199,43 @@ func TestForecastRecoveryOnlyHandlesStructuredInfeasibility(t *testing.T) { } } } + +// The archive learns which published loads Core replaced. The live meter's +// in-flight load is a measurement, so a valid meter alone marks nothing. +func TestRecordMarksOnlyRecoveredLoads(t *testing.T) { + for _, invalid := range []bool{false, true} { + t.Run(map[bool]string{false: "live meter", true: "invalid model load"}[invalid], func(t *testing.T) { + svc := shadowTestService(t) + start := time.Now().UTC().Truncate(time.Hour) + svc.now = func() time.Time { return start.Add(5 * time.Minute) } + svc.HouseholdMeasurement = func() telemetry.ForecastReading { + return telemetry.ForecastReading{HouseholdW: 2345, Valid: true} + } + var base []Slot + var recovered []bool + svc.ForecastSnapshot = func(time.Time, []state.ForecastPoint) ForecastInputs { + return ForecastInputs{ + Resolve: func(_ context.Context, slots []Slot) []Slot { + for i := range slots { + slots[i].LoadW = 600 + 100*float64(i) + } + if invalid { + slots[2].LoadW = math.NaN() + } + return slots + }, + Record: func(b, _ []Slot, r []bool, _ string, _ int64) { + base, recovered = append([]Slot(nil), b...), append([]bool(nil), r...) + }, + } + } + if plan := svc.Replan(context.Background()); plan == nil { + t.Fatalf("no plan: %+v", svc.PlanSnapshot()) + } + want := []bool{false, false, invalid, false} + if len(base) != 4 || base[0].LoadW != 2345 || fmt.Sprint(recovered) != fmt.Sprint(want) { + t.Fatalf("recovered=%v want %v; base=%+v", recovered, want, base) + } + }) + } +} diff --git a/go/internal/mpc/forecast_snapshot.go b/go/internal/mpc/forecast_snapshot.go index db3a296b3..a0edd05be 100644 --- a/go/internal/mpc/forecast_snapshot.go +++ b/go/internal/mpc/forecast_snapshot.go @@ -26,7 +26,9 @@ type ForecastInputs struct { Risk func(base, planning []Slot, k float64) // Record runs only for a published plan, outside the service lock. // Implementations enqueue bounded immutable work instead of disk/network I/O. - Record func(base, planning []Slot, decisionID string, issuedAtMS int64) + // recoveredLoad marks the base loads Core replaced after the selected + // forecast failed; the live meter's in-flight load is not one of them. + Record func(base, planning []Slot, recoveredLoad []bool, decisionID string, issuedAtMS int64) } const ForecastMaxAge = 12 * time.Hour diff --git a/go/internal/mpc/recovery_inputs_test.go b/go/internal/mpc/recovery_inputs_test.go index 32ebb4559..a3d9d640f 100644 --- a/go/internal/mpc/recovery_inputs_test.go +++ b/go/internal/mpc/recovery_inputs_test.go @@ -34,7 +34,7 @@ func TestRecoveryPublishesBeforeCorruptFutureInputs(t *testing.T) { } return slots }, - Record: func(base, planning []Slot, _ string, _ int64) { + Record: func(base, planning []Slot, _ []bool, _ string, _ int64) { if len(base) != len(planning) { t.Errorf("recorded horizons differ: %d != %d", len(base), len(planning)) } diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 05d3c05a2..2dc8bdabc 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -349,6 +349,16 @@ func overlayLiveHouseLoad(slots []Slot, loadW float64, ok bool) { slots[0].LoadW = loadW } +// recoveredLoads marks the published loads Core replaced after the selected +// forecast failed. point already holds the live meter's in-flight load. +func recoveredLoads(point, published []Slot) []bool { + out := make([]bool, len(published)) + for i := range published { + out[i] = i < len(point) && published[i].LoadW != point[i].LoadW + } + return out +} + // New constructs a service. Caller wires it in main.go after store + telemetry. func New(st *state.Store, tl *telemetry.Store, zone string, p Params) *Service { s := &Service{ @@ -1620,6 +1630,9 @@ func (s *Service) runReplan(request replanRequest) *Plan { liveLoadW, liveLoadOK := s.liveHouseLoadW() liveLoadOK = liveLoadOK && finite(liveLoadW) && liveLoadW >= 0 overlayLiveHouseLoad(slots, liveLoadW, liveLoadOK) + // The point forecast as selected, with the live meter's in-flight load. + // A published load that differs from it is Core's recovery. + pointInputs := append([]Slot(nil), slots...) var forecastWarnings []string admitted, forecastErr := recoverInvalidForecastLoads(slots, legacySlots, baseLoad) if forecastErr != nil { @@ -2200,7 +2213,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { s.mu.Unlock() tell(changed) if captured.Record != nil { - captured.Record(baseForecastSlots, fallbackSlots, plan.DecisionID, replanAtMs) + captured.Record(baseForecastSlots, fallbackSlots, recoveredLoads(pointInputs, baseForecastSlots), plan.DecisionID, replanAtMs) } // Horizon statistics — surfaced in logs so operators can // reconstruct "what did the DP know?" without pulling the full From 01f4f6124e17692fe3add3e87143e36e6cbb9f6d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 09:45:26 +0200 Subject: [PATCH 2/6] fix(mpc): say on the plan when forecast caution is off When the planning style asks for caution and the plan uses the point forecast instead, the published plan now carries caution_off (HTTP and diagnostic) and cautionOff (the app's plan message), set again on every replan and cleared once caution works. A safety_k of 0 never reads as off. Each change is logged once. Also from the #1644 review: - corePlanWithForecastRecovery returns its error last - a planning failure that repeats keeps its first record, so a lasting caution fault no longer costs a FULL-sync write per replan; a hard failure that returns after a recovered plan is current again - sanitizeSlotLoads had no caller outside its test - the Energyplan budget comment described the old single PV margin - worker tests from #1644 are renamed so make native-solver-test runs them Co-Authored-By: Claude Opus 5.5 --- go/internal/appproto/plan.go | 1 + go/internal/appproto/plan_test.go | 21 +++ go/internal/mpc/caution_status_test.go | 151 ++++++++++++++++++++++ go/internal/mpc/diagnose.go | 3 + go/internal/mpc/energyplan.go | 4 +- go/internal/mpc/ev_goal_contract_test.go | 2 +- go/internal/mpc/execution_time_test.go | 2 +- go/internal/mpc/failure_diagnostic.go | 18 ++- go/internal/mpc/forecast_margin.go | 5 +- go/internal/mpc/forecast_margin_test.go | 12 +- go/internal/mpc/forecast_recovery.go | 11 +- go/internal/mpc/forecast_recovery_test.go | 6 +- go/internal/mpc/load_bounds.go | 6 - go/internal/mpc/load_bounds_test.go | 9 +- go/internal/mpc/mpc.go | 5 + go/internal/mpc/planning_status.go | 6 + go/internal/mpc/service.go | 47 +++++-- go/protocol/messages.go | 6 + 18 files changed, 269 insertions(+), 46 deletions(-) create mode 100644 go/internal/mpc/caution_status_test.go diff --git a/go/internal/appproto/plan.go b/go/internal/appproto/plan.go index 9cf3a1a23..54096f5eb 100644 --- a/go/internal/appproto/plan.go +++ b/go/internal/appproto/plan.go @@ -34,6 +34,7 @@ func planFrom(p *mpc.Plan, rev uint64, ceilingW *int64, uptimeMs int64, now time // A plan older than the control loop is willing to act on is not what the // box is doing any more, whatever it says. out.Stale = now.Sub(time.UnixMilli(p.GeneratedAtMs)) > mpc.MaxPlanAge + out.CautionOff = p.CautionOff mean := meanPriceOre(p.Actions) minSoC := minSoC(p.Actions) diff --git a/go/internal/appproto/plan_test.go b/go/internal/appproto/plan_test.go index 30e565acf..db026ec28 100644 --- a/go/internal/appproto/plan_test.go +++ b/go/internal/appproto/plan_test.go @@ -80,6 +80,27 @@ func TestPlanCarriesTheBatteryLevelAtEachSlotsEnd(t *testing.T) { } } +// A phone says when the plan does without the forecast caution the household +// chose. The flag is absent from a plan that keeps it, so an older app reads +// the same bytes. +func TestPlanSaysWhenForecastCautionIsOff(t *testing.T) { + h, box, rec, clock := newRig(t) + subscribe(t, h, rec) + plan := samplePlan(clock.now) + box.plan = plan + deliver(t, h, MsgPlanGet, ptrU32(5), nil) + if p := body[Plan](t, rec.only(t, MsgPlan)); p.CautionOff { + t.Fatal("a plan with caution reads as caution off") + } + + plan.CautionOff = true + rec.reset() + deliver(t, h, MsgPlanGet, ptrU32(6), nil) + if p := body[Plan](t, rec.only(t, MsgPlan)); !p.CautionOff { + t.Fatal("caution off did not reach the wire") + } +} + // Reason codes are stable machine strings. The box has no idea what language // anyone reads, and the app owns every word the user sees. func TestPlanReasonsAreStableCodes(t *testing.T) { diff --git a/go/internal/mpc/caution_status_test.go b/go/internal/mpc/caution_status_test.go new file mode 100644 index 000000000..fd61de7e0 --- /dev/null +++ b/go/internal/mpc/caution_status_test.go @@ -0,0 +1,151 @@ +package mpc + +import ( + "context" + "math" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/state" +) + +// A plan that falls back to the point forecast says so on the plan itself, +// not only in a warning string, and the next plan that keeps its caution +// clears it. A style that asks for no caution never reads as caution off. +func TestCautionOffIsPublishedAndClears(t *testing.T) { + for _, tc := range []struct { + name string + k float64 + fault string + cautionOff bool + }{ + {"caution kept", .3, "", false}, + {"caution infeasible", .3, "infeasible", true}, + {"caution inputs invalid", .3, "nan uncertainty", true}, + {"caution risk invalid", .3, "nan risk", true}, + {"no caution asked", 0, "infeasible", false}, + {"no caution asked with invalid inputs", 0, "nan uncertainty", false}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := shadowTestService(t) + start := time.Now().UTC().Truncate(time.Hour) + svc.now = func() time.Time { return start.Add(5 * time.Minute) } + svc.FuseMaxW, svc.PVForecastSafetyK = 3000, tc.k + fault := tc.fault + svc.PVUncertaintyW = func() float64 { + if fault == "nan uncertainty" { + return math.NaN() + } + return 0 + } + svc.ForecastSnapshot = func(time.Time, []state.ForecastPoint) ForecastInputs { + return ForecastInputs{Load: func(time.Time) float64 { return 1000 }, + PVUncertaintyW: svc.PVUncertaintyW(), + Risk: func(_, risk []Slot, _ float64) { + for i := range risk { + switch fault { + case "infeasible": + // More house load than the fuse and battery can serve. + risk[i].LoadW = 9000 + case "nan risk": + risk[i].LoadW = math.NaN() + default: + risk[i].LoadW = 1100 + } + } + }} + } + plan := svc.Replan(context.Background()) + if plan == nil || len(plan.Actions) != 4 { + t.Fatalf("no full plan: %+v", svc.PlanSnapshot()) + } + if plan.CautionOff != tc.cautionOff { + t.Fatalf("caution off = %v, want %v; warnings %q", plan.CautionOff, tc.cautionOff, svc.PlanSnapshot().Warnings) + } + if d := svc.Diagnose(); d == nil || d.CautionOff != tc.cautionOff { + t.Fatal("diagnostic lost the caution state") + } + if tc.cautionOff && !strings.Contains(strings.Join(svc.PlanSnapshot().Warnings, " "), "caution") { + t.Fatalf("no warning says why: %q", svc.PlanSnapshot().Warnings) + } + + fault = "" + if plan := svc.Replan(context.Background()); plan == nil || plan.CautionOff { + t.Fatalf("working caution did not clear the flag: %+v", plan) + } + }) + } +} + +// A caution failure that repeats on every replan is one record and one write. +// A changed message is new evidence. A hard failure that returns after a +// recovered plan is current again rather than marked recovered. +func TestRepeatedPlanningFailureIsWrittenOnce(t *testing.T) { + svc := shadowTestService(t) + start := time.Now().UTC().Truncate(time.Hour) + svc.now = func() time.Time { return start.Add(5 * time.Minute) } + svc.PVForecastSafetyK = .3 + uncertainty := math.NaN() + svc.PVUncertaintyW = func() float64 { return uncertainty } + if svc.Replan(context.Background()) == nil { + t.Fatal("no plan") + } + first := svc.PlanningFailureSnapshot() + svc.mu.RLock() + written := svc.persistedPlanningFailure + svc.mu.RUnlock() + if first == nil || first.Stage != "risk_inputs" || written == nil || first.RecoveredDecisionID == "" { + t.Fatalf("caution failure not recorded and recovered: %+v", first) + } + if svc.Replan(context.Background()) == nil { + t.Fatal("no plan") + } + svc.mu.RLock() + same := svc.persistedPlanningFailure == written && svc.lastPlanningFailure == written + svc.mu.RUnlock() + if again := svc.PlanningFailureSnapshot(); !same || again.CapturedAtMs != first.CapturedAtMs { + t.Fatal("a repeated failure was recorded and written again") + } + + // NaN and -1 give the same message, so only another field is a change. + uncertainty = -1 + if svc.Replan(context.Background()) == nil || svc.PlanningFailureSnapshot().Generation != first.Generation { + t.Fatal("the same message was recorded again") + } + uncertainty = 0 + relative := math.NaN() + svc.PVRelativeUncertainty = func() float64 { return relative } + if svc.Replan(context.Background()) == nil { + t.Fatal("no plan") + } + if changed := svc.PlanningFailureSnapshot(); changed.Error == first.Error || changed.Generation == first.Generation { + t.Fatalf("a changed message kept the old evidence: %+v", changed) + } + + // The same hard failure twice, with a recovered plan in between. + relative = 0 + if err := svc.Store.SavePrices(nil); err != nil { + t.Fatal(err) + } + svc.Zone = "SE4" + svc.Replan(context.Background()) + hard := svc.PlanningFailureSnapshot() + if hard == nil || hard.Stage != "inputs" || hard.RecoveredDecisionID != "" { + t.Fatalf("hard failure not recorded: %+v", hard) + } + svc.Zone = "SE3" + plan := svc.Replan(context.Background()) + if plan == nil || svc.PlanningFailureSnapshot().RecoveredDecisionID != plan.DecisionID { + t.Fatal("recovery not marked") + } + svc.Zone = "SE4" + svc.Replan(context.Background()) + if back := svc.PlanningFailureSnapshot(); back.Error != hard.Error || back.RecoveredDecisionID != "" { + t.Fatalf("returning failure still reads as recovered: %+v", back) + } + svc.persistPlanningFailure() + if !svc.PlanningFailureStorageSnapshot().Durable { + t.Fatal("current failure was not written") + } +} diff --git a/go/internal/mpc/diagnose.go b/go/internal/mpc/diagnose.go index 2e0d15617..e2ff2659a 100644 --- a/go/internal/mpc/diagnose.go +++ b/go/internal/mpc/diagnose.go @@ -113,6 +113,7 @@ type Diagnostic struct { Solver *SolverInfo `json:"solver,omitempty"` DPShadow *ShadowPlan `json:"dp_shadow,omitempty"` DPEvaluationShadow *ShadowPlan `json:"dp_evaluation_shadow,omitempty"` + CautionOff bool `json:"caution_off,omitempty"` OptimizerInput json.RawMessage `json:"optimizer_input,omitempty"` Params DiagnosticParams `json:"params"` Slots []DiagnosticSlot `json:"slots"` @@ -209,6 +210,7 @@ func buildDiagnostic(plan *Plan, slots []Slot, p Params, zone string, Solver: plan.Solver, DPShadow: plan.DPShadow, DPEvaluationShadow: plan.DPEvaluationShadow, + CautionOff: plan.CautionOff, OptimizerInput: append(json.RawMessage(nil), plan.OptimizerInput...), Params: DiagnosticParams{ Mode: p.Mode, @@ -447,6 +449,7 @@ func planFromDiagnostic(d *Diagnostic) (*Plan, []Slot, Params, time.Time, bool) Solver: d.Solver, DPShadow: d.DPShadow, DPEvaluationShadow: d.DPEvaluationShadow, + CautionOff: d.CautionOff, OptimizerInput: append(json.RawMessage(nil), d.OptimizerInput...), } return plan, slots, params, time.UnixMilli(replanAtMs), true diff --git a/go/internal/mpc/energyplan.go b/go/internal/mpc/energyplan.go index 1cd9e40ac..cefb6bf24 100644 --- a/go/internal/mpc/energyplan.go +++ b/go/internal/mpc/energyplan.go @@ -43,8 +43,8 @@ func energyplanTimeBudget(slots []Slot, p Params) time.Duration { batteries = 1 } assets := 3*batteries + 2*len(p.activeLoadpoints()) - // Core already adjusts PV to one downside horizon. That margin does not - // add worker scenarios or change this model's size. + // Core sends the point forecast and its caution forecast as separate + // requests of this size. Caution adds no worker scenarios. budget := energyplanSmallBudget if len(p.activeLoadpoints()) > 0 { budget = energyplanEVBudget diff --git a/go/internal/mpc/ev_goal_contract_test.go b/go/internal/mpc/ev_goal_contract_test.go index be8b15170..2645b700f 100644 --- a/go/internal/mpc/ev_goal_contract_test.go +++ b/go/internal/mpc/ev_goal_contract_test.go @@ -10,7 +10,7 @@ import ( // An unreachable departure changes the reported shortfall, never whether the // rest of the site receives a plan. These expectations use direct Wh arithmetic. -func TestEVGoalPlanAndPriceContract(t *testing.T) { +func TestNativeEVGoalPlanAndPriceContract(t *testing.T) { for _, backend := range []string{"native", "reserve"} { t.Run(backend, func(t *testing.T) { solve := coreReservePlan diff --git a/go/internal/mpc/execution_time_test.go b/go/internal/mpc/execution_time_test.go index 38f0c8b33..5a71fb08d 100644 --- a/go/internal/mpc/execution_time_test.go +++ b/go/internal/mpc/execution_time_test.go @@ -11,7 +11,7 @@ import ( "time" ) -func TestPartialSlotCoreAndWorkerReplay(t *testing.T) { +func TestNativePartialSlotCoreAndWorkerReplay(t *testing.T) { for _, native := range []bool{false, true} { t.Run(fmt.Sprint("native=", native), func(t *testing.T) { var worker *ExternalOptimizer diff --git a/go/internal/mpc/failure_diagnostic.go b/go/internal/mpc/failure_diagnostic.go index 72a8f64fb..8f7129af5 100644 --- a/go/internal/mpc/failure_diagnostic.go +++ b/go/internal/mpc/failure_diagnostic.go @@ -98,7 +98,7 @@ func (s *Service) PlanningFailureSnapshot() *PlanningFailure { } func (s *Service) recordPlanningFailure(r replanRequest, stage, message string, slots []Slot, p Params, optimizerErr error) { - if r.wasCanceledByService() { + if r.wasCanceledByService() || s.repeatsPlanningFailure(stage, message) { return } // Limit source rows before encoding; normal 48-hour, quarter-hour plans @@ -128,13 +128,27 @@ func (s *Service) recordPlanningFailure(r replanRequest, stage, message string, f.OptimizerRequest, f.OptimizerResponse = wire.request, wire.response } s.mu.Lock() - if !s.stopping && r.generation == s.latestReplanGeneration && !r.wasCanceledByService() { + if !s.stopping && r.generation == s.latestReplanGeneration && !r.wasCanceledByService() && !s.repeatsPlanningFailureLocked(stage, message) { s.lastPlanningFailure = f s.planningFailureRestored = false } s.mu.Unlock() } +// A failure that repeats keeps its first evidence. Writing it again would +// cost a FULL-sync commit on every replan while it lasts. Evidence restored +// from before a restart is replaced once by this run's. +func (s *Service) repeatsPlanningFailure(stage, message string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.repeatsPlanningFailureLocked(stage, message) +} + +func (s *Service) repeatsPlanningFailureLocked(stage, message string) bool { + f := s.lastPlanningFailure + return f != nil && !s.planningFailureRestored && f.Stage == stage && f.Error == message +} + type PlanningFailureStorage struct { Enabled bool `json:"enabled"` Durable bool `json:"durable"` diff --git a/go/internal/mpc/forecast_margin.go b/go/internal/mpc/forecast_margin.go index 1b2071ffe..6b09abc03 100644 --- a/go/internal/mpc/forecast_margin.go +++ b/go/internal/mpc/forecast_margin.go @@ -13,6 +13,9 @@ type forecastChoice struct { slots []Slot warning string marginErr error + // withoutCaution is set when the plan ignores a caution forecast that + // differed from the point forecast. + withoutCaution bool } func validateForecastCaution(p Params) error { @@ -84,7 +87,7 @@ func planWithForecastMargin(ctx context.Context, point, risk []Slot, p Params, n err = preservesDepartureGoals(p, plan, candidate) } if err != nil { - choice.marginErr = err + choice.marginErr, choice.withoutCaution = err, true choice.warning = "Forecast caution could not retain the point plan's limits and departure goals; using the point forecast: " + err.Error() return choice, nil } diff --git a/go/internal/mpc/forecast_margin_test.go b/go/internal/mpc/forecast_margin_test.go index 0bb427128..d4261af93 100644 --- a/go/internal/mpc/forecast_margin_test.go +++ b/go/internal/mpc/forecast_margin_test.go @@ -40,7 +40,7 @@ func (o *optionalMarginOptimizer) Optimize(ctx context.Context, slots []Slot, p return o.worker.Optimize(ctx, slots, p) } -func TestForecastMarginKeepsPointAfterOptionalFailure(t *testing.T) { +func TestNativeForecastMarginKeepsPointAfterOptionalFailure(t *testing.T) { for _, fault := range []string{"infeasible", "broken reply", "timeout", "invalid plan"} { t.Run(fault, func(t *testing.T) { svc := shadowTestService(t) @@ -51,6 +51,7 @@ func TestForecastMarginKeepsPointAfterOptionalFailure(t *testing.T) { optimizer := &optionalMarginOptimizer{worker: worker, fault: fault} svc.Optimizer = optimizer svc.Defaults.MaxChargeW, svc.Defaults.MaxDischargeW = 0, 0 + svc.PVForecastSafetyK = .3 svc.ForecastSnapshot = func(time.Time, []state.ForecastPoint) ForecastInputs { return ForecastInputs{Load: func(time.Time) float64 { return 1000 }, Risk: func(_, risk []Slot, _ float64) { @@ -70,6 +71,9 @@ func TestForecastMarginKeepsPointAfterOptionalFailure(t *testing.T) { if plan == nil || len(plan.Actions) != 4 || plan.Solver.Engine != "native" || plan.Solver.Fallback || optimizer.calls != 2 { t.Fatalf("optional failure lost the native point plan: status=%+v calls=%d", svc.PlanSnapshot(), optimizer.calls) } + if !plan.CautionOff { + t.Fatal("the point plan does not say caution is off") + } if err := ValidatePlan(svc.lastSlots, svc.lastParams, plan); err != nil { t.Fatal(err) } @@ -86,7 +90,7 @@ func TestForecastMarginKeepsPointAfterOptionalFailure(t *testing.T) { } } -func TestForecastMarginInvalidPVKeepsFullPointHorizon(t *testing.T) { +func TestNativeForecastMarginInvalidPVKeepsFullPointHorizon(t *testing.T) { for _, backend := range []string{"core", "native"} { for _, badSlot := range []int{0, 2} { t.Run(fmt.Sprintf("%s/slot%d", backend, badSlot), func(t *testing.T) { @@ -116,7 +120,7 @@ func TestForecastMarginInvalidPVKeepsFullPointHorizon(t *testing.T) { } } -func TestForecastMarginRetainsRealDepartureShortfall(t *testing.T) { +func TestNativeForecastMarginRetainsRealDepartureShortfall(t *testing.T) { for _, backend := range []string{"core", "native", "worker unavailable"} { t.Run(backend, func(t *testing.T) { svc := shadowTestService(t) @@ -340,7 +344,7 @@ func TestForecastMarginKeepsActualPublicationTime(t *testing.T) { // These fields describe optional caution, not equipment. Invalid values must // survive in support evidence while the effective solve uses the point model. -func TestForecastMarginBadOptionalParametersKeepPoint(t *testing.T) { +func TestNativeForecastMarginBadOptionalParametersKeepPoint(t *testing.T) { for _, backend := range []string{"core", "native"} { for _, field := range []string{"PVUncertaintyW", "PVRelativeUncertainty", "PVForecastSafetyK"} { for _, bad := range []struct { diff --git a/go/internal/mpc/forecast_recovery.go b/go/internal/mpc/forecast_recovery.go index 7e42fe06d..37ff6d652 100644 --- a/go/internal/mpc/forecast_recovery.go +++ b/go/internal/mpc/forecast_recovery.go @@ -95,18 +95,19 @@ func recoverInvalidForecastLoads(slots, legacy []Slot, priorW float64) ([]Slot, // Core keeps the point plan before optional caution or a degraded load prior. // A prior may extend a safe prefix, but cannot replace a complete point plan. -func corePlanWithForecastRecovery(ctx context.Context, risk, point []Slot, p Params, priorW float64, currentMeasured bool, now func() time.Time) (Plan, []Slot, error, string) { +func corePlanWithForecastRecovery(ctx context.Context, risk, point []Slot, p Params, priorW float64, currentMeasured bool, now func() time.Time) (forecastChoice, error) { choice, err := planWithForecastMargin(ctx, point, risk, p, now, corePlanWithRecovery) if err == nil && len(choice.slots) == len(point) { - return choice.plan, choice.slots, nil, choice.warning + return choice, nil } prior := priorForecast(point, priorW, currentMeasured) if prior == nil || sameForecast(point, prior) || ctx.Err() != nil { - return choice.plan, choice.slots, err, choice.warning + return choice, err } candidate, accepted, retryErr := corePlanWithRecovery(ctx, prior, p) if retryErr == nil && len(accepted) > len(choice.slots) { - return candidate, accepted, nil, forecastPriorWarning + return forecastChoice{plan: candidate, slots: accepted, warning: forecastPriorWarning, + withoutCaution: !sameForecast(point, risk)}, nil } - return choice.plan, choice.slots, err, choice.warning + return choice, err } diff --git a/go/internal/mpc/forecast_recovery_test.go b/go/internal/mpc/forecast_recovery_test.go index 4a208416f..a8735656c 100644 --- a/go/internal/mpc/forecast_recovery_test.go +++ b/go/internal/mpc/forecast_recovery_test.go @@ -142,7 +142,8 @@ func TestForecastRecoveryCannotChangeMeasuredOverload(t *testing.T) { if prior[0].LoadW != 55700 || prior[1].LoadW != 800 || slots[1].LoadW != 2000 { t.Fatal("lost actual overload or mutated raw inputs") } - plan, used, err, _ := corePlanWithForecastRecovery(context.Background(), slots, slots, p, 800, true, time.Now) + choice, err := corePlanWithForecastRecovery(context.Background(), slots, slots, p, 800, true, time.Now) + plan, used := choice.plan, choice.slots if err == nil || len(used) != 0 || len(plan.Actions) != 0 { t.Fatal("forecast prior hid a real current overload") } @@ -165,7 +166,8 @@ func TestForecastRecoveryKeepsValidBatteryAndPVPeaks(t *testing.T) { slots[12].PVW = -16000 p.Mode = ModeSelfConsumption } - plan, used, err, warning := corePlanWithForecastRecovery(context.Background(), slots, slots, p, 800, false, time.Now) + choice, err := corePlanWithForecastRecovery(context.Background(), slots, slots, p, 800, false, time.Now) + plan, used, warning := choice.plan, choice.slots, choice.warning if err != nil || warning != "" || len(used) != len(slots) || used[12].LoadW != slots[12].LoadW { t.Fatalf("valid peak was replaced: %v %s", err, warning) } diff --git a/go/internal/mpc/load_bounds.go b/go/internal/mpc/load_bounds.go index 4d6fb35f7..509e7347f 100644 --- a/go/internal/mpc/load_bounds.go +++ b/go/internal/mpc/load_bounds.go @@ -11,12 +11,6 @@ func sanitizeLoadW(w float64) float64 { return w } -func sanitizeSlotLoads(slots []Slot) { - for i := range slots { - slots[i].LoadW = sanitizeLoadW(slots[i].LoadW) - } -} - func sanitizePlanLoads(plan *Plan) { if plan == nil { return diff --git a/go/internal/mpc/load_bounds_test.go b/go/internal/mpc/load_bounds_test.go index 033ccc944..1ca44b9e7 100644 --- a/go/internal/mpc/load_bounds_test.go +++ b/go/internal/mpc/load_bounds_test.go @@ -16,15 +16,8 @@ func TestSanitizeLoadW(t *testing.T) { } } -func TestSanitizeSlotAndPlanLoads(t *testing.T) { +func TestSanitizePlanLoads(t *testing.T) { t.Parallel() - slots := []Slot{{LoadW: 50000}, {LoadW: 700}, {LoadW: -10}, {LoadW: math.NaN()}} - sanitizeSlotLoads(slots) - for i, want := range []float64{50000, 700, 0, 0} { - if slots[i].LoadW != want { - t.Fatalf("slot %d load = %v, want %v", i, slots[i].LoadW, want) - } - } plan := &Plan{Actions: []Action{{LoadW: -1}, {LoadW: 800}}} sanitizePlanLoads(plan) if plan.Actions[0].LoadW != 0 || plan.Actions[1].LoadW != 800 { diff --git a/go/internal/mpc/mpc.go b/go/internal/mpc/mpc.go index c26c1afe1..7030beda1 100644 --- a/go/internal/mpc/mpc.go +++ b/go/internal/mpc/mpc.go @@ -392,6 +392,11 @@ type Plan struct { // persisted Diagnostic for deterministic replay. OptimizerInput json.RawMessage `json:"-"` LoadpointShortfallWh map[string]float64 `json:"loadpoint_shortfall_wh,omitempty"` + // CautionOff is set when the planning style asks for forecast caution and + // this plan uses the point forecast instead: the caution inputs were + // invalid, or a caution plan could not keep the point plan's limits and + // departure goals. The planning warnings say why. + CautionOff bool `json:"caution_off,omitempty"` } // ShadowPlan is a challenger candidate calculated alongside the active plan. diff --git a/go/internal/mpc/planning_status.go b/go/internal/mpc/planning_status.go index 6a523e160..af7bb3a5f 100644 --- a/go/internal/mpc/planning_status.go +++ b/go/internal/mpc/planning_status.go @@ -11,6 +11,12 @@ func (s *Service) failedReplan(request replanRequest, message string) *Plan { s.mu.Lock() if request.generation == s.latestReplanGeneration && !request.wasCanceledByService() { s.lastPlanningError = message + // A repeated failure keeps its record; after a recovery it is current again. + if f := s.lastPlanningFailure; f != nil && f.RecoveredDecisionID != "" { + current := *f + current.RecoveredDecisionID, current.RecoveredAtMs = "", 0 + s.lastPlanningFailure = ¤t + } } plan := s.last s.mu.Unlock() diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 2dc8bdabc..6f49cb572 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -1745,11 +1745,15 @@ func (s *Service) runReplan(request replanRequest) *Plan { if pvRelative != nil || s.ForecastSnapshot != nil { p.PVRelativeUncertainty = pvRelativeUncertainty } + // Set when the plan does without its caution forecast. It reaches the + // published plan only when the planning style asks for caution. + cautionOff := false if cautionErr := validateForecastCaution(p); cautionErr != nil { // Preserve the raw optional model/preference before disabling caution. // Hardware, telemetry, prices and goals still pass their strict gates. s.recordPlanningFailure(request, "risk_inputs", cautionErr.Error(), fallbackSlots, p, nil) forecastWarnings = append(forecastWarnings, "Forecast caution is invalid; using the point forecast: "+cautionErr.Error()) + cautionOff = true p.PVForecastSafetyK, p.PVUncertaintyW, p.PVRelativeUncertainty = 0, 0, 0 } else { applyPVDownsidePerSlot(fallbackSlots, p.PVForecastSafetyK, @@ -1760,8 +1764,11 @@ func (s *Service) runReplan(request replanRequest) *Plan { } if recovered, err := recoverInvalidForecastLoads(fallbackSlots, slots, baseLoad); err != nil { s.recordPlanningFailure(request, "risk_inputs", err.Error(), fallbackSlots, p, nil) - forecastWarnings = append(forecastWarnings, err.Error()) + // The replacements here are the point loads, not legacy or the prior. + forecastWarnings = append(forecastWarnings, "Forecast caution had invalid load values; using the point forecast for those slots") fallbackSlots = recovered + // Point loads in every slot leave no caution; some slots leave some. + cautionOff = cautionOff || sameForecast(fallbackSlots, slots) } // Keep the point forecast distinct from the downside inputs passed to the // solver. Calibrating point errors against the risk adjustment biases them. @@ -1863,6 +1870,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { s.recordPlanningFailure(request, "risk_inputs", riskErr.Error(), fallbackSlots, p, nil) warnings = append(warnings, "Forecast caution inputs are invalid; using the point forecast: "+riskErr.Error()) fallbackSlots = append([]Slot(nil), slots...) + cautionOff = true } if err := validatePlanningParams(p); err != nil { slog.Error("mpc: invalid optimization parameters; keeping previous plan", "err", err) @@ -1921,13 +1929,13 @@ func (s *Service) runReplan(request replanRequest) *Plan { slots = fallbackSlots solverInputs = baseForecastSlots solveStart := time.Now() - var err error - var forecastWarning string - plan, slots, err, forecastWarning = corePlanWithForecastRecovery(context.WithoutCancel(ctx), slots, baseForecastSlots, p, baseLoad, liveLoadOK, s.planningNow) - if forecastWarning != "" { - s.recordPlanningFailure(request, "forecast_recovery", forecastWarning, fallbackSlots, p, nil) - warnings = append(warnings, forecastWarning) - if forecastWarning == forecastPriorWarning { + choice, err := corePlanWithForecastRecovery(context.WithoutCancel(ctx), slots, baseForecastSlots, p, baseLoad, liveLoadOK, s.planningNow) + plan, slots = choice.plan, choice.slots + cautionOff = cautionOff || choice.withoutCaution + if choice.warning != "" { + s.recordPlanningFailure(request, "forecast_recovery", choice.warning, fallbackSlots, p, nil) + warnings = append(warnings, choice.warning) + if choice.warning == forecastPriorWarning { baseForecastSlots = append([]Slot(nil), slots...) } } @@ -1950,6 +1958,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { return plan, inputs, err }) candidate, slots, err = choice.plan, choice.slots, solveErr + cautionOff = cautionOff || choice.withoutCaution if choice.warning != "" { s.recordPlanningFailure(request, "forecast_caution", choice.warning, fallbackSlots, p, choice.marginErr) warnings = append(warnings, choice.warning) @@ -1976,6 +1985,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { break } if retryErr == nil { + cautionOff = cautionOff || !sameForecast(fallbackSlots, baseForecastSlots) candidate, slots, err = recovered, attempt.slots, nil baseForecastSlots = append([]Slot(nil), attempt.base...) fallbackSlots = append([]Slot(nil), attempt.slots...) @@ -2067,12 +2077,12 @@ func (s *Service) runReplan(request replanRequest) *Plan { slots = fallbackSlots solverInputs = baseForecastSlots solveStart := time.Now() - var fallbackErr error - var forecastWarning string - plan, slots, fallbackErr, forecastWarning = corePlanWithForecastRecovery(context.WithoutCancel(ctx), slots, baseForecastSlots, p, baseLoad, liveLoadOK, s.planningNow) - if forecastWarning != "" { - warnings = append(warnings, forecastWarning) - if forecastWarning == forecastPriorWarning { + choice, fallbackErr := corePlanWithForecastRecovery(context.WithoutCancel(ctx), slots, baseForecastSlots, p, baseLoad, liveLoadOK, s.planningNow) + plan, slots = choice.plan, choice.slots + cautionOff = cautionOff || choice.withoutCaution + if choice.warning != "" { + warnings = append(warnings, choice.warning) + if choice.warning == forecastPriorWarning { baseForecastSlots = append([]Slot(nil), slots...) } } @@ -2189,6 +2199,9 @@ func (s *Service) runReplan(request replanRequest) *Plan { // actions yet, and nothing edits them after this point. s.setLivePVSurplusSoCCapsLocked(plan.Actions, p) plan.DecisionID = s.nextDecisionIDLocked() + // A safety_k of 0 asks for no caution; NaN asks for caution it cannot get. + plan.CautionOff = cautionOff && request.safetyK != 0 + cautionChanged := plan.CautionOff != (s.last != nil && s.last.CautionOff) s.last = &plan if f := s.lastPlanningFailure; f != nil && f.RecoveredDecisionID == "" { recovered := *f @@ -2212,6 +2225,12 @@ func (s *Service) runReplan(request replanRequest) *Plan { changed := s.planChanged s.mu.Unlock() tell(changed) + if cautionChanged && plan.CautionOff { + slog.Warn("mpc: forecast caution off; the plan uses the point forecast", + "decision_id", plan.DecisionID, "safety_k", request.safetyK, "warnings", warnings) + } else if cautionChanged { + slog.Info("mpc: forecast caution back on", "decision_id", plan.DecisionID) + } if captured.Record != nil { captured.Record(baseForecastSlots, fallbackSlots, recoveredLoads(pointInputs, baseForecastSlots), plan.DecisionID, replanAtMs) } diff --git a/go/protocol/messages.go b/go/protocol/messages.go index 5285f8a99..9dfa42b28 100644 --- a/go/protocol/messages.go +++ b/go/protocol/messages.go @@ -521,6 +521,12 @@ type Plan struct { // read. Absent on a box with no chargers or no planner. An older app // ignores it. Chargers []PlanCharger `cbor:"chargers,omitempty"` + // CautionOff is set when the household's planning style asks for + // forecast caution and this plan uses the point forecast instead, + // because the caution inputs were invalid or a cautious plan could not + // keep the plan's limits and departure goals. The box sets it again on + // every replan and clears it once caution works. An older app ignores it. + CautionOff bool `cbor:"cautionOff,omitempty"` } // PlanCharger is one charger's share of the plan. Keyed by the charger, From 26b53744bd6ef85f1dad55776d264157e75a0d0b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 09:48:15 +0200 Subject: [PATCH 3/6] feat(web): show Caution off on the plan brief Co-Authored-By: Claude Opus 5.5 --- .changeset/caution-off-visible.md | 5 +++++ web/plan-brief.js | 16 ++++++++++++---- web/plan-brief.test.mjs | 26 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 .changeset/caution-off-visible.md diff --git a/.changeset/caution-off-visible.md b/.changeset/caution-off-visible.md new file mode 100644 index 000000000..cf0e5f227 --- /dev/null +++ b/.changeset/caution-off-visible.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +When the planning style asks for forecast caution and the plan has to use the point forecast instead, the plan now says so: the box page shows "Caution off" and the app's plan message carries `cautionOff`. It clears once caution works again. The forecast archive no longer credits the live meter's current load to Core's recovery, and a lasting caution fault no longer rewrites its failure record on every replan. diff --git a/web/plan-brief.js b/web/plan-brief.js index a00a1a966..29fdd1a35 100644 --- a/web/plan-brief.js +++ b/web/plan-brief.js @@ -276,14 +276,19 @@ export function derivePlanBrief({ const solver = plan.solver || {}; const stale = Boolean(status.plan_stale); + // The household asked for forecast caution and this plan uses the point + // forecast instead. The planning warnings say why. + const cautionOff = Boolean(plan.caution_off); const plannerActive = String(status.mode || "").startsWith("planner_"); const state = stale ? { key: "stale", label: "Fallback active", tone: "warn" } : solver.fallback ? { key: "fallback", label: "Built-in plan active", tone: "warn" } - : plannerActive - ? { key: "active", label: "Plan active", tone: "active" } - : { key: "ready", label: "Plan ready", tone: "idle" }; + : cautionOff + ? { key: "caution_off", label: "Caution off", tone: "warn" } + : plannerActive + ? { key: "active", label: "Plan active", tone: "active" } + : { key: "ready", label: "Plan ready", tone: "idle" }; const live = actions.find((action) => ( now >= action.slot_start_ms && @@ -321,11 +326,14 @@ export function derivePlanBrief({ const finalAction = actions[actions.length - 1]; // The plan reaches as far as the published prices, and says how far. const forecast = { - label: "Published prices", + label: cautionOff ? "Point forecast" : "Published prices", detail: finalAction ? `Plan until ${formatPlanEnd(finalAction.slot_start_ms + (finalAction.slot_len_min || 15) * 60_000)}` : "The plan reaches as far as the published prices", }; + if (cautionOff) { + forecast.detail = `No margin for forecast error in this plan. ${forecast.detail}`; + } // Expected charge follows the next step. Charging the car reports that // car's planned SoC. The home battery on the same slot (start SoC in // the header) is a different number — a car at 76% must not read as diff --git a/web/plan-brief.test.mjs b/web/plan-brief.test.mjs index c9754e7a5..651259925 100644 --- a/web/plan-brief.test.mjs +++ b/web/plan-brief.test.mjs @@ -181,6 +181,32 @@ describe("plan brief normalization", () => { assert.equal(brief.planner.detail, "Worker unavailable"); }); + it("says when the plan does without the forecast caution the household chose", () => { + const warning = "Forecast caution is invalid; using the point forecast: pv_uncertainty_w must be finite and non-negative"; + const brief = derivePlanBrief({ + enabled: true, + plan: { actions: [slot(8), slot(23)], solver: {}, caution_off: true }, + planning: { warnings: [warning] }, + status: { mode: "planner_arbitrage" }, + now, + }); + + assert.equal(brief.state.key, "caution_off"); + assert.equal(brief.state.tone, "warn"); + assert.equal(brief.forecast.label, "Point forecast"); + assert.match(brief.forecast.detail, /^No margin for forecast error in this plan\. Plan until /); + assert.match(brief.constraint, /point forecast/); + + const kept = derivePlanBrief({ + enabled: true, + plan: { actions: [slot(8)], solver: {} }, + status: { mode: "planner_arbitrage" }, + now, + }); + assert.equal(kept.state.key, "active"); + assert.equal(kept.forecast.label, "Published prices"); + }); + it("surfaces active safety clamps and how far the published prices reach", () => { const brief = derivePlanBrief({ enabled: true, From 0820a1ff113dfaaf299f7385b6bb89f6a830fe4c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 10:17:15 +0200 Subject: [PATCH 4/6] perf(mpc): solve forecast caution first when both passes fit With a car plugged in, Energyplan spends its whole budget on each solve, so caution doubled replan time: 1.5 s to 3.0 s for a 35-hour horizon and 5 s to 10 s for 48 hours. When time remains for both passes, the caution plan is now solved first. A full cautious plan that misses no departure leaves nothing for a point plan to improve on, so it is published after one solve. Otherwise the point plan is solved and compared as before. Near the end of a slot the point plan still goes first. BenchmarkNativeCautionReplan measures it on the bundled worker. Co-Authored-By: Claude Opus 5.5 --- .changeset/caution-off-visible.md | 2 +- go/internal/mpc/caution_replan_bench_test.go | 147 +++++++++++++++++++ go/internal/mpc/caution_status_test.go | 52 +++++++ go/internal/mpc/forecast_margin.go | 110 ++++++++++---- go/internal/mpc/forecast_margin_test.go | 12 +- 5 files changed, 287 insertions(+), 36 deletions(-) create mode 100644 go/internal/mpc/caution_replan_bench_test.go diff --git a/.changeset/caution-off-visible.md b/.changeset/caution-off-visible.md index cf0e5f227..2657860a4 100644 --- a/.changeset/caution-off-visible.md +++ b/.changeset/caution-off-visible.md @@ -2,4 +2,4 @@ "ftw": patch --- -When the planning style asks for forecast caution and the plan has to use the point forecast instead, the plan now says so: the box page shows "Caution off" and the app's plan message carries `cautionOff`. It clears once caution works again. The forecast archive no longer credits the live meter's current load to Core's recovery, and a lasting caution fault no longer rewrites its failure record on every replan. +When the planning style asks for forecast caution and the plan has to use the point forecast instead, the plan now says so: the box page shows "Caution off" and the app's plan message carries `cautionOff`. It clears once caution works again. The forecast archive no longer credits the live meter's current load to Core's recovery, and a lasting caution fault no longer rewrites its failure record on every replan. With a car plugged in, a cautious plan that reaches every departure is now published after one Energyplan solve instead of two, which halves the time to a plan. diff --git a/go/internal/mpc/caution_replan_bench_test.go b/go/internal/mpc/caution_replan_bench_test.go new file mode 100644 index 000000000..f3696bb02 --- /dev/null +++ b/go/internal/mpc/caution_replan_bench_test.go @@ -0,0 +1,147 @@ +package mpc + +import ( + "context" + "fmt" + "math" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/state" +) + +// Replan wall time to a published plan with forecast caution on (Balanced, +// safety_k 0.3) and off (0), on the production planners and real horizons. +// The Energyplan cases need the bundled worker. From go/: +// +// FTW_NATIVE_SOLVER=$(python3 ../optimizer/native/verify.py --host-binary) \ +// go test ./internal/mpc -run '^$' -bench NativeCautionReplan -benchtime 5x +func BenchmarkNativeCautionReplan(b *testing.B) { + // A morning and an evening plug-in. In the evening the cautious plan + // discharges the battery for the extra house load while the car charges. + // With the point load that discharge would feed the car, so Core refuses + // it, and the case shows the cost of falling back to the point plan. + starts := map[string]time.Time{ + "morning": time.Date(2026, 10, 16, 7, 45, 0, 0, time.UTC), + "evening": time.Date(2026, 10, 15, 16, 0, 0, 0, time.UTC), + } + for _, planner := range []string{"energyplan", "core"} { + for _, at := range []string{"morning", "evening"} { + for _, site := range []struct { + name string + slots int + ev bool + }{ + {"battery/35h", 140, false}, + {"battery/48h", 193, false}, + {"battery+ev/35h", 140, true}, + {"battery+ev/48h", 193, true}, + } { + for _, k := range []float64{0, .3} { + b.Run(fmt.Sprintf("%s/%s/%s/k=%g", planner, at, site.name, k), func(b *testing.B) { + svc := cautionBenchService(b, starts[at], site.slots, site.ev, k) + if planner == "energyplan" { + template := nativeWorker(b, time.Second) + o, err := NewEnergyplanOptimizer(template.cfg.Command[0]) + _ = template.Close() + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = o.Close() }) + svc.Optimizer = o + // Start the worker outside the timed replans. + if plan := svc.Replan(context.Background()); plan == nil { + b.Fatalf("no warm-up plan: %+v", svc.PlanSnapshot()) + } + svc.shadowWG.Wait() + } + var walls []float64 + off := 0 + b.ResetTimer() + for i := 0; i < b.N; i++ { + start := time.Now() + plan := svc.Replan(context.Background()) + wall := time.Since(start) + b.StopTimer() + if plan == nil { + b.Fatalf("no plan: %+v", svc.PlanSnapshot()) + } + if plan.CautionOff { + if off++; off == 1 { + b.Logf("caution off: %q", svc.PlanSnapshot().Warnings) + } + } + walls = append(walls, float64(wall.Microseconds())/1000) + svc.shadowWG.Wait() + b.StartTimer() + } + slices.Sort(walls) + b.ReportMetric(walls[len(walls)/2], "median-ms") + b.ReportMetric(walls[len(walls)-1], "max-ms") + b.ReportMetric(float64(off), "caution-off") + }) + } + } + } + } +} + +func cautionBenchService(b *testing.B, start time.Time, slots int, ev bool, k float64) *Service { + b.Helper() + st, err := state.Open(filepath.Join(b.TempDir(), "bench.db")) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { st.Close() }) + prices := make([]state.PricePoint, slots) + for i := range prices { + at := start.Add(time.Duration(i) * 15 * time.Minute) + hour := float64(at.Hour()) + float64(at.Minute())/60 + // A two-peak day: cheap nights and middays, dear mornings and evenings. + spot := 60 + 70*math.Max(0, math.Sin((hour-5)/24*4*math.Pi)) + 5*math.Sin(float64(i)) + prices[i] = state.PricePoint{Zone: "SE3", SlotTsMs: at.UnixMilli(), SlotLenMin: 15, + SpotOreKwh: spot, TotalOreKwh: 1.25*spot + 80, Source: "bench", FetchedAtMs: start.UnixMilli()} + } + if err := st.SavePrices(prices); err != nil { + b.Fatal(err) + } + svc := New(st, nil, "SE3", Params{Mode: ModeArbitrage, SoCLevels: 51, ActionLevels: 21, + CapacityWh: 10000, InitialSoC: .5, SoCMin: .1, SoCMax: .95, MaxChargeW: 5000, MaxDischargeW: 5000, + ChargeEfficiency: .95, DischargeEfficiency: .95}) + svc.now = func() time.Time { return start.Add(time.Minute) } + svc.FuseMaxW, svc.BaseLoad, svc.PVForecastSafetyK = 17250, 800, k + if ev { + svc.Loadpoint = func(int) *LoadpointSpec { + return &LoadpointSpec{ID: "car", CapacityWh: 75000, InitialSoC: .4, SoCMax: 1, PluggedIn: true, + TargetSoC: .8, TargetSlotIdx: 28, Levels: 101, MaxChargeW: 11000, MinChargeW: 4140, + AllowedStepsW: []float64{0, 4140, 6900, 11000}, ChargeEfficiency: .9, NoBatteryToEV: true} + } + } + solar := func(at time.Time) float64 { + hour := float64(at.Hour()) + float64(at.Minute())/60 + return 6000 * math.Max(0, math.Sin((hour-6)/12*math.Pi)) + } + svc.ForecastSnapshot = func(time.Time, []state.ForecastPoint) ForecastInputs { + return ForecastInputs{ + PVRelativeUncertainty: .25, + Resolve: func(_ context.Context, in []Slot) []Slot { + for i := range in { + at := time.UnixMilli(in[i].StartMs) + in[i].PVW = -solar(at) + in[i].LoadW = 700 + 500*math.Max(0, math.Sin(float64(at.Hour()-16)/8*math.Pi)) + } + return in + }, + // The host's calibrated margin: PV downside and house-load upside. + Risk: func(base, planning []Slot, k float64) { + for i := range planning { + planning[i].PVW = base[i].PVW * (1 - .5*k) + planning[i].LoadW = base[i].LoadW + k*400 + } + }, + } + } + return svc +} diff --git a/go/internal/mpc/caution_status_test.go b/go/internal/mpc/caution_status_test.go index fd61de7e0..a35a03b87 100644 --- a/go/internal/mpc/caution_status_test.go +++ b/go/internal/mpc/caution_status_test.go @@ -149,3 +149,55 @@ func TestRepeatedPlanningFailureIsWrittenOnce(t *testing.T) { t.Fatal("current failure was not written") } } + +// With time for both passes the caution plan is solved first. When it meets +// every departure no point plan can do better, so one solve publishes it. +// A missed departure needs the point plan to compare against, and so does a +// slot about to end, where caution must not use up the point plan's time. +func TestForecastCautionSolvesFirstWhenBothPassesFit(t *testing.T) { + for _, tc := range []struct { + name string + target float64 + leftover time.Duration + order string + caution bool + }{ + {"battery only", 0, time.Hour, "caution", true}, + {"reachable departure", .3, time.Hour, "caution", true}, + {"missed departure", .99, time.Hour, "caution point", true}, + {"slot about to end", 0, 5 * time.Second, "point caution", true}, + } { + t.Run(tc.name, func(t *testing.T) { + slots, p := externalTestFixture() + p.SoCLevels, p.ActionLevels = 21, 11 + if tc.target > 0 { + p.Loadpoint = &LoadpointSpec{ID: "car", CapacityWh: 10000, InitialSoC: .2, SoCMax: 1, Levels: 11, + PluggedIn: true, TargetSoC: tc.target, TargetSlotIdx: 0, AllowedStepsW: []float64{0, 1000}, MaxChargeW: 1000, + ChargeEfficiency: 1, NoBatteryToEV: true} + p.Loadpoints = []*LoadpointSpec{p.Loadpoint} + } + risk := append([]Slot(nil), slots...) + for i := range risk { + risk[i].LoadW += 100 + } + end := time.UnixMilli(slots[0].StartMs + int64(slots[0].LenMin)*60000) + var order []string + choice, err := planWithForecastMargin(context.Background(), slots, risk, p, + func() time.Time { return end.Add(-tc.leftover) }, + func(ctx context.Context, in []Slot, p Params) (Plan, []Slot, error) { + if in[0].LoadW == risk[0].LoadW { + order = append(order, "caution") + } else { + order = append(order, "point") + } + return corePlanWithRecovery(ctx, in, p) + }) + if err != nil || strings.Join(order, " ") != tc.order { + t.Fatalf("solve order %q, want %q: %v", order, tc.order, err) + } + if got := choice.slots[0].LoadW == risk[0].LoadW; got != tc.caution || choice.withoutCaution { + t.Fatalf("caution plan chosen = %v, want %v: %s", got, tc.caution, choice.warning) + } + }) + } +} diff --git a/go/internal/mpc/forecast_margin.go b/go/internal/mpc/forecast_margin.go index 6b09abc03..424fc0f08 100644 --- a/go/internal/mpc/forecast_margin.go +++ b/go/internal/mpc/forecast_margin.go @@ -34,31 +34,54 @@ func validateForecastCaution(p Params) error { return nil } -// Keep a validated point plan before trying optional forecast caution. A risk -// pass may neither shorten its horizon nor worsen any departure inside it. +// Keep a validated point plan whenever optional forecast caution fails. A +// risk pass may neither shorten its horizon nor worsen any departure inside it. +// +// With time for both passes, caution goes first. A full cautious plan that +// misses no departure leaves nothing for a point plan to improve on, so it is +// published after one solve rather than two: the worker spends its whole +// budget on a plan with a car, and the second pass doubled plug-in-to-plan. func planWithForecastMargin(ctx context.Context, point, risk []Slot, p Params, now func() time.Time, solve forecastSolve) (forecastChoice, error) { started := time.Now() - plan, used, err := solve(ctx, point, p) - if err == nil { - err = ValidatePlan(used, p, &plan) + solvePoint := func() (forecastChoice, error) { + plan, used, err := solve(ctx, point, p) + if err == nil { + err = ValidatePlan(used, p, &plan) + } + return forecastChoice{plan: plan, slots: used}, err } - choice := forecastChoice{plan: plan, slots: used} - if err != nil || len(point) == 0 || len(used) != len(point) || sameForecast(point, risk) { - return choice, err + if len(point) == 0 || sameForecast(point, risk) { + return solvePoint() } // ExecutionStartMs is the frozen energy snapshot, not the time left now. // The service clock also makes historical test snapshots deterministic. end, endErr := checkedSlotEndMs(point[0].StartMs, point[0].LenMin) if endErr != nil { - return choice, endErr + choice, err := solvePoint() + if err == nil { + err = endErr + } + return choice, err } remaining := time.UnixMilli(end).Sub(now()) - energyplanPublishMargin + available := remaining + if deadline, ok := ctx.Deadline(); ok { + available = min(available, time.Until(deadline)) + } + cautionFirst := available >= forecastRecoveryBudget + var choice forecastChoice + if !cautionFirst { + var err error + if choice, err = solvePoint(); err != nil || len(choice.slots) != len(point) { + return choice, err + } + } budget := min(forecastRecoveryBudget-time.Since(started), remaining) marginCtx, cancel := context.WithTimeout(ctx, budget) defer cancel() var candidate Plan var accepted []Slot - err = marginCtx.Err() + err := marginCtx.Err() if err == nil && len(risk) != len(point) { err = fmt.Errorf("forecast caution changed the planning horizon") } @@ -74,7 +97,8 @@ func planWithForecastMargin(ctx context.Context, point, risk []Slot, p Params, n if err == nil { candidate, accepted, err = solve(marginCtx, risk, p) } - if err == nil && len(accepted) != len(used) { + // Both orders keep only a full point plan, so caution must be full too. + if err == nil && len(accepted) != len(point) { err = fmt.Errorf("forecast caution shortened the planning horizon") } if err == nil { @@ -83,8 +107,17 @@ func planWithForecastMargin(ctx context.Context, point, risk []Slot, p Params, n if err == nil { err = validateMarginPointLoad(point, accepted, p, candidate) } + if cautionFirst { + if err == nil && missesNoDeparture(p, candidate) { + return forecastChoice{plan: candidate, slots: accepted}, nil + } + var pointErr error + if choice, pointErr = solvePoint(); pointErr != nil || len(choice.slots) != len(point) { + return choice, pointErr + } + } if err == nil { - err = preservesDepartureGoals(p, plan, candidate) + err = preservesDepartureGoals(p, choice.plan, candidate) } if err != nil { choice.marginErr, choice.withoutCaution = err, true @@ -124,24 +157,7 @@ func preservesDepartureGoals(p Params, point, risk Plan) error { if i >= len(risk.Actions) { return fmt.Errorf("forecast caution omits departure for %s", lp.ID) } - // The plans have passed Core replay. Compare their allocated Wh, not - // reported SoC or the notification's minimum-run shortfall threshold. - missing := func(plan Plan) float64 { - energy := lp.InitialSoC * lp.CapacityWh - efficiency := lp.ChargeEfficiency - if efficiency <= 0 { - efficiency = .9 - } - for _, a := range plan.Actions[:i+1] { - power := a.LoadpointPowerW[lp.ID] - if len(a.LoadpointPowerW) == 0 && lpIdx == 0 { - power = a.LoadpointW - } - energy += power * a.DurationHours() * efficiency - } - return max(0, lp.TargetSoC*lp.CapacityWh-energy) - } - pointMissing, riskMissing := missing(point), missing(risk) + pointMissing, riskMissing := departureShortfallWh(point, lp, lpIdx), departureShortfallWh(risk, lp, lpIdx) // One Wh is the worker's deadline-energy comparison tolerance. if !finite(riskMissing) || riskMissing > pointMissing+1 { return fmt.Errorf("forecast caution increases %s departure shortfall from %.3f to %.3f Wh", lp.ID, pointMissing, riskMissing) @@ -149,3 +165,37 @@ func preservesDepartureGoals(p Params, point, risk Plan) error { } return nil } + +// missesNoDeparture holds when the plan reaches every departure goal inside it +// within the same tolerance, so preservesDepartureGoals accepts it against any +// point plan. +func missesNoDeparture(p Params, plan Plan) bool { + for lpIdx, lp := range p.activeLoadpoints() { + i := lp.TargetSlotIdx + if lp.TargetSoC <= 0 || i < 0 || i >= len(plan.Actions) { + continue + } + if missing := departureShortfallWh(plan, lp, lpIdx); !finite(missing) || missing > 1 { + return false + } + } + return true +} + +// The plans have passed Core replay. Compare their allocated Wh, not reported +// SoC or the notification's minimum-run shortfall threshold. +func departureShortfallWh(plan Plan, lp *LoadpointSpec, lpIdx int) float64 { + energy := lp.InitialSoC * lp.CapacityWh + efficiency := lp.ChargeEfficiency + if efficiency <= 0 { + efficiency = .9 + } + for _, a := range plan.Actions[:lp.TargetSlotIdx+1] { + power := a.LoadpointPowerW[lp.ID] + if len(a.LoadpointPowerW) == 0 && lpIdx == 0 { + power = a.LoadpointW + } + energy += power * a.DurationHours() * efficiency + } + return max(0, lp.TargetSoC*lp.CapacityWh-energy) +} diff --git a/go/internal/mpc/forecast_margin_test.go b/go/internal/mpc/forecast_margin_test.go index d4261af93..2bee655c9 100644 --- a/go/internal/mpc/forecast_margin_test.go +++ b/go/internal/mpc/forecast_margin_test.go @@ -23,7 +23,8 @@ func (*optionalMarginOptimizer) UsesDownsidePV() bool { return true } func (*optionalMarginOptimizer) Close() error { return nil } func (o *optionalMarginOptimizer) Optimize(ctx context.Context, slots []Slot, p Params) (Plan, error) { o.calls++ - if o.calls == 1 { + // The fixture's point load is 1000 W and its caution load 1200 W. + if slots[0].LoadW != 1200 { return o.worker.Optimize(ctx, slots, p) } switch o.fault { @@ -181,8 +182,9 @@ func TestForecastMarginCannotTradeDepartureGoalsBetweenCars(t *testing.T) { var point, candidate Plan choice, err := planWithForecastMargin(context.Background(), slots, risk, p, func() time.Time { return time.UnixMilli(slots[0].ExecutionStart()) }, func(_ context.Context, inputs []Slot, p Params) (Plan, []Slot, error) { calls++ + cautious := inputs[0].LoadW == 1200 powers := [][2]float64{{0, 2000}, {2000, 2000}} - if calls == 2 { + if cautious { powers = [][2]float64{{2000, 2000}, {2000, 1000}} } plan := Plan{CapacityWh: p.CapacityWh, InitialSoC: p.InitialSoC, HorizonSlots: 2, Mode: p.Mode} @@ -209,10 +211,10 @@ func TestForecastMarginCannotTradeDepartureGoalsBetweenCars(t *testing.T) { if err := ValidatePlan(inputs, p, &plan); err != nil { t.Fatal(err) } - if calls == 1 { - point = plan - } else { + if cautious { candidate = plan + } else { + point = plan } return plan, inputs, nil }) From 30784e0ee17081dd3789b3c87a3c27fb3a886bc5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 10:19:18 +0200 Subject: [PATCH 5/6] test(control): pass the recovered-load mask to Record Co-Authored-By: Claude Opus 5.5 --- go/internal/control/forecast_scenarios_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/internal/control/forecast_scenarios_test.go b/go/internal/control/forecast_scenarios_test.go index a1227ef12..5e8fb94bf 100644 --- a/go/internal/control/forecast_scenarios_test.go +++ b/go/internal/control/forecast_scenarios_test.go @@ -426,7 +426,7 @@ func scenarioPlannerPV(t *testing.T, providerW, learnedW, weight float64) float6 svc.Horizon = time.Hour var built []mpc.Slot svc.ForecastSnapshot = func(time.Time, []state.ForecastPoint) mpc.ForecastInputs { - return mpc.ForecastInputs{Weather: weather, PV: func(time.Time, float64) float64 { return learnedW }, PVWeight: func(time.Time) float64 { return weight }, Load: func(time.Time) float64 { return 1000 }, Record: func(base, planning []mpc.Slot, _ string, _ int64) { built = append([]mpc.Slot(nil), base...) }} + return mpc.ForecastInputs{Weather: weather, PV: func(time.Time, float64) float64 { return learnedW }, PVWeight: func(time.Time) float64 { return weight }, Load: func(time.Time) float64 { return 1000 }, Record: func(base, planning []mpc.Slot, _ []bool, _ string, _ int64) { built = append([]mpc.Slot(nil), base...) }} } if svc.Replan(context.Background()) == nil || len(built) != 1 { t.Fatalf("planner did not publish one forecast slot: %d", len(built)) From 0a00b75479a0136a4cd154c7b0d583189bae13e6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 11:04:02 +0200 Subject: [PATCH 6/6] fix(mpc): keep cautious plans a car-plus-battery site can run The point-load replay refused nearly every cautious plan with a car: the plan discharges the battery for the extra house load while the car charges, and with that load gone the discharge fed the car or exported. Dispatch already stops discharge at the house when battery_covers_ev is off, and with it on the owner lets the battery feed the car. The replay now judges only the mode's export rule and grid limits; the vehicle rules stay on the cautious plan's own inputs. From the review of #1663: - the caution budget is taken after the point solve, so it ends in the slot - caution first leaves the point plan a full fleet budget - a shortened point plan reads as caution off - only a downside planner loses caution to a prior - a repeated failure compares its message without digits Co-Authored-By: Claude Opus 5.5 --- .changeset/caution-off-visible.md | 2 +- go/internal/mpc/caution_replan_bench_test.go | 29 ++-- go/internal/mpc/caution_status_test.go | 133 +++++++++++++++++++ go/internal/mpc/ev_reserve.go | 6 +- go/internal/mpc/external_optimizer.go | 14 +- go/internal/mpc/failure_diagnostic.go | 14 +- go/internal/mpc/forecast_margin.go | 25 +++- go/internal/mpc/forecast_margin_test.go | 42 ++++-- go/internal/mpc/reserve_price.go | 2 +- go/internal/mpc/service.go | 3 +- 10 files changed, 233 insertions(+), 37 deletions(-) diff --git a/.changeset/caution-off-visible.md b/.changeset/caution-off-visible.md index 2657860a4..27a5e103d 100644 --- a/.changeset/caution-off-visible.md +++ b/.changeset/caution-off-visible.md @@ -2,4 +2,4 @@ "ftw": patch --- -When the planning style asks for forecast caution and the plan has to use the point forecast instead, the plan now says so: the box page shows "Caution off" and the app's plan message carries `cautionOff`. It clears once caution works again. The forecast archive no longer credits the live meter's current load to Core's recovery, and a lasting caution fault no longer rewrites its failure record on every replan. With a car plugged in, a cautious plan that reaches every departure is now published after one Energyplan solve instead of two, which halves the time to a plan. +When the planning style asks for forecast caution and the plan has to use the point forecast instead, the plan now says so: the box page shows "Caution off" and the app's plan message carries `cautionOff`. It clears once caution works again. A cautious plan is no longer refused just because the car charges while the battery discharges: dispatch already keeps the battery off a protected car. With a car plugged in, an accepted cautious plan now takes one Energyplan solve instead of two; a refused one still takes two. The forecast archive no longer credits the live meter's current load to Core's recovery, and a lasting caution fault no longer rewrites its failure record on every replan. diff --git a/go/internal/mpc/caution_replan_bench_test.go b/go/internal/mpc/caution_replan_bench_test.go index f3696bb02..b396ebd52 100644 --- a/go/internal/mpc/caution_replan_bench_test.go +++ b/go/internal/mpc/caution_replan_bench_test.go @@ -19,10 +19,8 @@ import ( // FTW_NATIVE_SOLVER=$(python3 ../optimizer/native/verify.py --host-binary) \ // go test ./internal/mpc -run '^$' -bench NativeCautionReplan -benchtime 5x func BenchmarkNativeCautionReplan(b *testing.B) { - // A morning and an evening plug-in. In the evening the cautious plan - // discharges the battery for the extra house load while the car charges. - // With the point load that discharge would feed the car, so Core refuses - // it, and the case shows the cost of falling back to the point plan. + // A morning and an evening plug-in, with and without battery_covers_ev. + // The caution-off metric counts plans that fell back to the point plan. starts := map[string]time.Time{ "morning": time.Date(2026, 10, 16, 7, 45, 0, 0, time.UTC), "evening": time.Date(2026, 10, 15, 16, 0, 0, 0, time.UTC), @@ -30,18 +28,21 @@ func BenchmarkNativeCautionReplan(b *testing.B) { for _, planner := range []string{"energyplan", "core"} { for _, at := range []string{"morning", "evening"} { for _, site := range []struct { - name string - slots int - ev bool + name string + slots int + ev bool + coversEV bool }{ - {"battery/35h", 140, false}, - {"battery/48h", 193, false}, - {"battery+ev/35h", 140, true}, - {"battery+ev/48h", 193, true}, + {"battery/35h", 140, false, false}, + {"battery/48h", 193, false, false}, + {"battery+ev/35h", 140, true, false}, + {"battery+ev/48h", 193, true, false}, + {"battery-covers-ev/35h", 140, true, true}, + {"battery-covers-ev/48h", 193, true, true}, } { for _, k := range []float64{0, .3} { b.Run(fmt.Sprintf("%s/%s/%s/k=%g", planner, at, site.name, k), func(b *testing.B) { - svc := cautionBenchService(b, starts[at], site.slots, site.ev, k) + svc := cautionBenchService(b, starts[at], site.slots, site.ev, site.coversEV, k) if planner == "energyplan" { template := nativeWorker(b, time.Second) o, err := NewEnergyplanOptimizer(template.cfg.Command[0]) @@ -88,7 +89,7 @@ func BenchmarkNativeCautionReplan(b *testing.B) { } } -func cautionBenchService(b *testing.B, start time.Time, slots int, ev bool, k float64) *Service { +func cautionBenchService(b *testing.B, start time.Time, slots int, ev, coversEV bool, k float64) *Service { b.Helper() st, err := state.Open(filepath.Join(b.TempDir(), "bench.db")) if err != nil { @@ -116,7 +117,7 @@ func cautionBenchService(b *testing.B, start time.Time, slots int, ev bool, k fl svc.Loadpoint = func(int) *LoadpointSpec { return &LoadpointSpec{ID: "car", CapacityWh: 75000, InitialSoC: .4, SoCMax: 1, PluggedIn: true, TargetSoC: .8, TargetSlotIdx: 28, Levels: 101, MaxChargeW: 11000, MinChargeW: 4140, - AllowedStepsW: []float64{0, 4140, 6900, 11000}, ChargeEfficiency: .9, NoBatteryToEV: true} + AllowedStepsW: []float64{0, 4140, 6900, 11000}, ChargeEfficiency: .9, NoBatteryToEV: !coversEV} } } solar := func(at time.Time) float64 { diff --git a/go/internal/mpc/caution_status_test.go b/go/internal/mpc/caution_status_test.go index a35a03b87..989f8c82e 100644 --- a/go/internal/mpc/caution_status_test.go +++ b/go/internal/mpc/caution_status_test.go @@ -2,6 +2,7 @@ package mpc import ( "context" + "errors" "math" "strings" "testing" @@ -201,3 +202,135 @@ func TestForecastCautionSolvesFirstWhenBothPassesFit(t *testing.T) { }) } } + +// The caution solve must end inside the slot after a slow point solve, and +// when caution goes first it leaves the point plan a full worker budget. +func TestForecastCautionBudgetEndsInsideTheSlot(t *testing.T) { + for _, tc := range []struct { + name string + left time.Duration + // The latest caution deadline, counted from the slot's end. + latest time.Duration + }{ + {"point first after a slow point solve", 2 * time.Second, energyplanPublishMargin}, + {"caution first", forecastRecoveryBudget + time.Second, energyplanPublishMargin + energyplanFleetBudget}, + } { + t.Run(tc.name, func(t *testing.T) { + slots, p := externalTestFixture() + slots = slots[:1] + p.SoCLevels, p.ActionLevels = 21, 11 + risk := append([]Slot(nil), slots...) + risk[0].LoadW += 100 + began := time.Now() + slotEnd := began.Add(tc.left) + serviceEnd := time.UnixMilli(slots[0].StartMs + int64(slots[0].LenMin)*60000) + now := func() time.Time { return serviceEnd.Add(-tc.left).Add(time.Since(began)) } + var deadline time.Time + _, err := planWithForecastMargin(context.Background(), slots, risk, p, now, + func(ctx context.Context, in []Slot, p Params) (Plan, []Slot, error) { + if in[0].LoadW == risk[0].LoadW { + deadline, _ = ctx.Deadline() + } else { + time.Sleep(300 * time.Millisecond) + } + return corePlanWithRecovery(ctx, in, p) + }) + if err != nil || deadline.IsZero() { + t.Fatalf("no caution solve: %v", err) + } + if limit := slotEnd.Add(-tc.latest).Add(20 * time.Millisecond); deadline.After(limit) { + t.Fatalf("caution may run until %v after the slot's last safe moment", deadline.Sub(slotEnd.Add(-tc.latest))) + } + }) + } +} + +// A point plan that covers only part of the horizon never keeps caution, in +// either order, so the published plan says so. +func TestShortenedPointPlanIsWithoutCaution(t *testing.T) { + for _, left := range []time.Duration{2 * time.Second, time.Hour} { + t.Run(left.String(), func(t *testing.T) { + slots, p := externalTestFixture() + p.SoCLevels, p.ActionLevels = 21, 11 + risk := append([]Slot(nil), slots...) + for i := range risk { + risk[i].LoadW += 100 + } + end := time.UnixMilli(slots[0].StartMs + int64(slots[0].LenMin)*60000) + choice, err := planWithForecastMargin(context.Background(), slots, risk, p, + func() time.Time { return end.Add(-left) }, + func(ctx context.Context, in []Slot, p Params) (Plan, []Slot, error) { + if in[0].LoadW == risk[0].LoadW { + return Plan{}, nil, errors.New("caution unavailable") + } + return corePlanWithRecovery(ctx, in[:1], p) + }) + if err != nil || len(choice.slots) != 1 || !choice.withoutCaution { + t.Fatalf("shortened point plan: slots=%d without caution=%v err=%v", len(choice.slots), choice.withoutCaution, err) + } + }) + } +} + +// priorOnlyOptimizer finds every forecast infeasible except the configured +// load prior, so the service recovers with the prior. +type priorOnlyOptimizer struct { + downside bool + priorW float64 +} + +func (o priorOnlyOptimizer) UsesDownsidePV() bool { return o.downside } +func (priorOnlyOptimizer) Close() error { return nil } +func (o priorOnlyOptimizer) Optimize(ctx context.Context, slots []Slot, p Params) (Plan, error) { + if slots[len(slots)-1].LoadW != o.priorW { + return Plan{}, &externalError{Code: "infeasible", Message: "test"} + } + plan, _, err := corePlanWithRecovery(ctx, slots, p) + return plan, err +} + +// Only a planner that solves the caution forecast loses it to a prior. A +// planner that never used caution does not read as caution off. +func TestPriorRecoveryMarksCautionOffOnlyForDownsidePlanners(t *testing.T) { + for _, downside := range []bool{false, true} { + t.Run(map[bool]string{false: "point planner", true: "downside planner"}[downside], func(t *testing.T) { + svc := shadowTestService(t) + start := time.Now().UTC().Truncate(time.Hour) + svc.now = func() time.Time { return start.Add(5 * time.Minute) } + svc.PVForecastSafetyK = .3 + svc.Optimizer = priorOnlyOptimizer{downside: downside, priorW: svc.BaseLoad} + svc.ForecastSnapshot = func(time.Time, []state.ForecastPoint) ForecastInputs { + return ForecastInputs{Load: func(time.Time) float64 { return 1000 }, + Risk: func(_, risk []Slot, _ float64) { + for i := range risk { + risk[i].LoadW = 1200 + } + }} + } + plan := svc.Replan(context.Background()) + svc.shadowWG.Wait() + if plan == nil || plan.Solver == nil || plan.Solver.Fallback || svc.lastSlots[3].LoadW != svc.BaseLoad { + t.Fatalf("no prior plan from the optimizer: %+v", svc.PlanSnapshot()) + } + if plan.CautionOff != downside { + t.Fatalf("caution off = %v, want %v", plan.CautionOff, downside) + } + }) + } +} + +// A slot number or a watt figure that moves between replans does not make a +// lasting failure new evidence. +func TestRepeatedFailureIgnoresChangingNumbers(t *testing.T) { + svc := shadowTestService(t) + svc.lastPlanningFailure = &PlanningFailure{Stage: "forecast_caution", Error: "slot 6 grid_w -120.5 violates grid limits"} + if !svc.repeatsPlanningFailure("forecast_caution", "slot 17 grid_w -98.25 violates grid limits") { + t.Fatal("changed numbers made the same failure new") + } + if svc.repeatsPlanningFailure("forecast_caution", "slot 6 grid_w -120.5 violates mode") { + t.Fatal("a different failure was taken for a repeat") + } + if svc.repeatsPlanningFailure("risk_inputs", "slot 6 grid_w -120.5 violates grid limits") { + t.Fatal("a different stage was taken for a repeat") + } +} diff --git a/go/internal/mpc/ev_reserve.go b/go/internal/mpc/ev_reserve.go index bdc5ec0b7..d6cf98bc3 100644 --- a/go/internal/mpc/ev_reserve.go +++ b/go/internal/mpc/ev_reserve.go @@ -227,7 +227,7 @@ func reserveSlotCost(slot Slot, p Params, a Action) float64 { // Check the minimum and maximum simultaneous EV draw. Full-interval charges // never contribute an off state; partial charges may start or stop together. // Both endpoints must respect the site limit and the battery/PV policies. -func validateEVPulse(slot Slot, p Params, a Action, pvW float64) error { +func validateEVPulse(slot Slot, p Params, a Action, pvW float64, vehicleRules bool) error { if len(a.LoadpointMaxPowerW) == 0 { return nil } @@ -261,7 +261,7 @@ func validateEVPulse(slot Slot, p Params, a Action, pvW float64) error { if lp.SurplusOnly { solarPeak += peak } - if peak > 0 && lp.blocksBatteryToEV() && loadpoint.BatteryDischargeFeedsEV(a.BatteryW, peak, slot.LoadW, pvW) { + if vehicleRules && peak > 0 && lp.blocksBatteryToEV() && loadpoint.BatteryDischargeFeedsEV(a.BatteryW, peak, slot.LoadW, pvW) { return fmt.Errorf("battery discharge feeds EV pulse") } } @@ -277,7 +277,7 @@ func validateEVPulse(slot Slot, p Params, a Action, pvW float64) error { if !modeAllows(p.Mode, baseline, grid, a.BatteryW) { return fmt.Errorf("EV pulse violates mode") } - if power > 0 && a.BatteryW < 0 && grid < -50 { + if vehicleRules && power > 0 && a.BatteryW < 0 && grid < -50 { return fmt.Errorf("EV pulse charges during battery export") } } diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index 3e597acbb..3b4a4b83b 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -435,6 +435,14 @@ const solverZeroPowerToleranceW = 1e-5 // this boundary: NaN, stale slot alignment, energy drift, illegal EV steps, or // mode/grid-limit violations reject the entire plan. func ValidatePlan(slots []Slot, p Params, plan *Plan) error { + return validatePlan(slots, p, plan, true) +} + +// vehicleRules judges how the battery and the cars share power: no battery +// discharge into a protected car, and no car charging while the battery +// drives export. Only a replay of a plan already accepted against its own +// inputs leaves them out. +func validatePlan(slots []Slot, p Params, plan *Plan, vehicleRules bool) error { if err := validatePartialSlots(slots); err != nil { return err } @@ -627,11 +635,11 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { if lp.SurplusOnly && surplusOnlyExceedsHousePV(powerW, slot.LoadW, effectivePVW) { return fmt.Errorf("slot %d surplus-only loadpoint %s exceeds PV leftover after house load", i, lp.ID) } - if powerW > solverZeroPowerToleranceW && a.BatteryW < -solverZeroPowerToleranceW && a.GridW < -50 { + if vehicleRules && powerW > solverZeroPowerToleranceW && a.BatteryW < -solverZeroPowerToleranceW && a.GridW < -50 { return fmt.Errorf("slot %d loadpoint %s charges during battery-driven export: ev_w=%.9f battery_w=%.9f grid_w=%.9f", i, lp.ID, powerW, a.BatteryW, a.GridW) } - if lp.blocksBatteryToEV() && loadpoint.BatteryDischargeFeedsEV(a.BatteryW, powerW, slot.LoadW, effectivePVW) { + if vehicleRules && lp.blocksBatteryToEV() && loadpoint.BatteryDischargeFeedsEV(a.BatteryW, powerW, slot.LoadW, effectivePVW) { return fmt.Errorf("slot %d battery discharge feeds loadpoint %s", i, lp.ID) } } @@ -647,7 +655,7 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { (slot.Limits.MaxExportW > 0 && a.GridW < -slot.Limits.MaxExportW-solverGridLimitToleranceW) { return fmt.Errorf("slot %d grid_w %.3f violates grid limits", i, a.GridW) } - if err := validateEVPulse(slot, p, a, effectivePVW); err != nil { + if err := validateEVPulse(slot, p, a, effectivePVW, vehicleRules); err != nil { return fmt.Errorf("slot %d: %w", i, err) } wantCost := reserveSlotCost(slot, p, a) diff --git a/go/internal/mpc/failure_diagnostic.go b/go/internal/mpc/failure_diagnostic.go index 8f7129af5..9b3576979 100644 --- a/go/internal/mpc/failure_diagnostic.go +++ b/go/internal/mpc/failure_diagnostic.go @@ -9,6 +9,7 @@ import ( "reflect" "strings" "time" + "unicode" ) const planningFailureByteLimit = 1 << 20 @@ -146,7 +147,18 @@ func (s *Service) repeatsPlanningFailure(stage, message string) bool { func (s *Service) repeatsPlanningFailureLocked(stage, message string) bool { f := s.lastPlanningFailure - return f != nil && !s.planningFailureRestored && f.Stage == stage && f.Error == message + return f != nil && !s.planningFailureRestored && f.Stage == stage && withoutDigits(f.Error) == withoutDigits(message) +} + +// A slot index or a watt figure in the message changes from replan to replan +// while the failure stays the same. +func withoutDigits(message string) string { + return strings.Map(func(r rune) rune { + if unicode.IsDigit(r) { + return -1 + } + return r + }, message) } type PlanningFailureStorage struct { diff --git a/go/internal/mpc/forecast_margin.go b/go/internal/mpc/forecast_margin.go index 424fc0f08..762b817bc 100644 --- a/go/internal/mpc/forecast_margin.go +++ b/go/internal/mpc/forecast_margin.go @@ -63,8 +63,8 @@ func planWithForecastMargin(ctx context.Context, point, risk []Slot, p Params, n } return choice, err } - remaining := time.UnixMilli(end).Sub(now()) - energyplanPublishMargin - available := remaining + remaining := func() time.Duration { return time.UnixMilli(end).Sub(now()) - energyplanPublishMargin } + available := remaining() if deadline, ok := ctx.Deadline(); ok { available = min(available, time.Until(deadline)) } @@ -73,10 +73,17 @@ func planWithForecastMargin(ctx context.Context, point, risk []Slot, p Params, n if !cautionFirst { var err error if choice, err = solvePoint(); err != nil || len(choice.slots) != len(point) { + // A shortened point plan never keeps caution. + choice.withoutCaution = err == nil return choice, err } } - budget := min(forecastRecoveryBudget-time.Since(started), remaining) + // The point solve took time; caution must still end inside the slot. + budget := min(forecastRecoveryBudget-time.Since(started), remaining()) + if cautionFirst { + // Leave the point plan the largest worker budget in case it is needed. + budget = min(budget, available-energyplanFleetBudget) + } marginCtx, cancel := context.WithTimeout(ctx, budget) defer cancel() var candidate Plan @@ -113,6 +120,7 @@ func planWithForecastMargin(ctx context.Context, point, risk []Slot, p Params, n } var pointErr error if choice, pointErr = solvePoint(); pointErr != nil || len(choice.slots) != len(point) { + choice.withoutCaution = pointErr == nil return choice, pointErr } } @@ -129,7 +137,14 @@ func planWithForecastMargin(ctx context.Context, point, risk []Slot, p Params, n // Reuse Core replay with the same physical battery/EV choices and PV downside, // but without optional extra house load. Recompute only balance and accounting -// on a copy: invented load cannot permit battery export or battery-fed charging. +// on a copy: invented load cannot permit battery export the mode forbids, or +// break a grid limit. +// +// The vehicle rules stay with the cautious plan's own inputs, where it has +// already passed them. Load that does not show up cannot make the battery +// feed a protected car: with battery_covers_ev off, dispatch stops discharge +// at the house. With it on, the owner lets the battery feed the car, and the +// battery energy the house does not use is export, which the mode judges here. func validateMarginPointLoad(point, risk []Slot, p Params, plan Plan) error { slots := append([]Slot(nil), point...) plan.Actions = append([]Action(nil), plan.Actions...) @@ -142,7 +157,7 @@ func validateMarginPointLoad(point, risk []Slot, p Params, plan Plan) error { a.CostOre = reserveSlotCost(slots[i], p, *a) plan.TotalCostOre += a.CostOre } - if err := ValidatePlan(slots, p, &plan); err != nil { + if err := validatePlan(slots, p, &plan, false); err != nil { return fmt.Errorf("forecast caution requires extra house load to obey site policy: %w", err) } return nil diff --git a/go/internal/mpc/forecast_margin_test.go b/go/internal/mpc/forecast_margin_test.go index 2bee655c9..807402fc1 100644 --- a/go/internal/mpc/forecast_margin_test.go +++ b/go/internal/mpc/forecast_margin_test.go @@ -87,6 +87,11 @@ func TestNativeForecastMarginKeepsPointAfterOptionalFailure(t *testing.T) { if failure == nil || failure.RecoveredDecisionID != plan.DecisionID || !svc.PlanningFailureStorageSnapshot().Durable { t.Fatal("optional failure lost its durable evidence") } + optimizer.fault = "" + if next := svc.Replan(context.Background()); next == nil || next.CautionOff || next.Solver.Engine != "native" { + t.Fatalf("working caution did not clear the flag on the Energyplan path: %+v", svc.PlanSnapshot()) + } + svc.shadowWG.Wait() }) } } @@ -231,29 +236,50 @@ func TestForecastMarginCannotTradeDepartureGoalsBetweenCars(t *testing.T) { } } +// Invented house load cannot buy battery export the mode forbids. How the +// battery and the car share power is judged on the cautious plan's own inputs +// and again at dispatch, so a point-load replay does not refuse it: with +// battery_covers_ev off, dispatch stops discharge at the house, and with it +// on the owner lets the battery feed the car. func TestForecastMarginCannotGrantExtraDischargePermission(t *testing.T) { - for _, mode := range []Mode{ModeSelfConsumption, ModeArbitrage} { - t.Run(string(mode), func(t *testing.T) { + for _, tc := range []struct { + name string + mode Mode + car bool + coversEV bool + export bool + refused bool + }{ + {"self consumption export", ModeSelfConsumption, false, false, false, true}, + {"protected car", ModeArbitrage, true, false, false, false}, + {"car covered and export allowed", ModeArbitrage, true, true, true, false}, + {"car covered and export forbidden", ModePassiveArbitrage, true, true, true, true}, + } { + t.Run(tc.name, func(t *testing.T) { slots, p := externalTestFixture() slots = slots[:1] - slots[0].LoadW, p.Mode, p.InitialSoC = 2000, mode, .5 + slots[0].LoadW, p.Mode, p.InitialSoC = 2000, tc.mode, .5 p.DischargeEfficiency = 1 a := Action{SlotStartMs: slots[0].StartMs, SlotLenMin: 60, LoadW: 2000, BatteryW: -1500, GridW: 500, SoC: .35} - if mode == ModeArbitrage { + if tc.car { p.Loadpoint = &LoadpointSpec{ID: "car", CapacityWh: 10000, InitialSoC: .2, SoCMax: 1, Levels: 11, PluggedIn: true, - TargetSoC: .3, TargetSlotIdx: 0, MaxChargeW: 1000, AllowedStepsW: []float64{0, 1000}, ChargeEfficiency: 1, NoBatteryToEV: true} + TargetSoC: .3, TargetSlotIdx: 0, MaxChargeW: 1000, AllowedStepsW: []float64{0, 1000}, ChargeEfficiency: 1, NoBatteryToEV: !tc.coversEV} a.LoadpointW, a.LoadpointSoC, a.GridW = 1000, .3, 1500 } + if tc.export { + // The battery covers the house and the car; nothing is imported. + a.BatteryW, a.SoC, a.GridW = -3000, .2, 0 + } a.CostOre = reserveSlotCost(slots[0], p, a) - plan := Plan{Mode: mode, CapacityWh: p.CapacityWh, InitialSoC: .5, HorizonSlots: 1, Actions: []Action{a}, TotalCostOre: a.CostOre} + plan := Plan{Mode: tc.mode, CapacityWh: p.CapacityWh, InitialSoC: .5, HorizonSlots: 1, Actions: []Action{a}, TotalCostOre: a.CostOre} if err := ValidatePlan(slots, p, &plan); err != nil { t.Fatalf("risk candidate must satisfy its inflated load: %v", err) } point := append([]Slot(nil), slots...) point[0].LoadW = 1000 before, _ := json.Marshal(plan) - if err := validateMarginPointLoad(point, slots, p, plan); err == nil { - t.Fatal("optional load granted battery export or battery-to-EV permission") + if err := validateMarginPointLoad(point, slots, p, plan); (err != nil) != tc.refused { + t.Fatalf("refused = %v, want %v: %v", err != nil, tc.refused, err) } after, _ := json.Marshal(plan) if string(before) != string(after) { diff --git a/go/internal/mpc/reserve_price.go b/go/internal/mpc/reserve_price.go index faf841c0c..b1610a2a3 100644 --- a/go/internal/mpc/reserve_price.go +++ b/go/internal/mpc/reserve_price.go @@ -52,7 +52,7 @@ func priceReserveCharging(ctx context.Context, slots []Slot, p Params, reserve P LoadpointPowerW: map[string]float64{lp.ID: peak / 2}, LoadpointMaxPowerW: map[string]float64{lp.ID: peak}, } - if validateEVPulse(slot, p, probe, slot.PVW) != nil { + if validateEVPulse(slot, p, probe, slot.PVW, true) != nil { continue } hours := slot.DurationHours() diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 6f49cb572..f37ac3154 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -1985,7 +1985,8 @@ func (s *Service) runReplan(request replanRequest) *Plan { break } if retryErr == nil { - cautionOff = cautionOff || !sameForecast(fallbackSlots, baseForecastSlots) + // Only a downside planner solved the caution forecast. + cautionOff = cautionOff || (downsidePrimary && !sameForecast(fallbackSlots, baseForecastSlots)) candidate, slots, err = recovered, attempt.slots, nil baseForecastSlots = append([]Slot(nil), attempt.base...) fallbackSlots = append([]Slot(nil), attempt.slots...)