diff --git a/.changeset/caution-off-visible.md b/.changeset/caution-off-visible.md new file mode 100644 index 000000000..27a5e103d --- /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. 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/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/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/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)) 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..b396ebd52 --- /dev/null +++ b/go/internal/mpc/caution_replan_bench_test.go @@ -0,0 +1,148 @@ +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, 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), + } + for _, planner := range []string{"energyplan", "core"} { + for _, at := range []string{"morning", "evening"} { + for _, site := range []struct { + name string + slots int + ev bool + coversEV bool + }{ + {"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, site.coversEV, 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, coversEV 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: !coversEV} + } + } + 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 new file mode 100644 index 000000000..989f8c82e --- /dev/null +++ b/go/internal/mpc/caution_status_test.go @@ -0,0 +1,336 @@ +package mpc + +import ( + "context" + "errors" + "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") + } +} + +// 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) + } + }) + } +} + +// 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/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/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/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/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/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 72a8f64fb..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 @@ -98,7 +99,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 +129,38 @@ 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 && 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 { 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..762b817bc 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 { @@ -31,31 +34,61 @@ 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 := func() time.Duration { return 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) { + // A shortened point plan never keeps caution. + choice.withoutCaution = err == nil + return choice, err + } + } + // 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) } - remaining := time.UnixMilli(end).Sub(now()) - energyplanPublishMargin - 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") } @@ -71,7 +104,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 { @@ -80,11 +114,21 @@ 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) { + choice.withoutCaution = pointErr == nil + return choice, pointErr + } + } if err == nil { - err = preservesDepartureGoals(p, plan, candidate) + err = preservesDepartureGoals(p, choice.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 } @@ -93,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...) @@ -106,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 @@ -121,24 +172,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) @@ -146,3 +180,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 0bb427128..807402fc1 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 { @@ -40,7 +41,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 +52,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 +72,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) } @@ -82,11 +87,16 @@ func TestForecastMarginKeepsPointAfterOptionalFailure(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() }) } } -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 +126,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) @@ -177,8 +187,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} @@ -205,10 +216,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 }) @@ -225,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) { @@ -340,7 +372,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_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.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 d11a3d6c6..a8735656c 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...) }, @@ -141,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") } @@ -164,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) } @@ -198,3 +201,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/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/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/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 05d3c05a2..f37ac3154 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 { @@ -1732,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, @@ -1747,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. @@ -1850,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) @@ -1908,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...) } } @@ -1937,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) @@ -1963,6 +1985,8 @@ func (s *Service) runReplan(request replanRequest) *Plan { break } if retryErr == nil { + // 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...) @@ -2054,12 +2078,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...) } } @@ -2176,6 +2200,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 @@ -2199,8 +2226,14 @@ 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, 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 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, 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,