diff --git a/.changeset/forecast-planner-recovery.md b/.changeset/forecast-planner-recovery.md new file mode 100644 index 00000000..d1914707 --- /dev/null +++ b/.changeset/forecast-planner-recovery.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep price planning when a load forecast cannot fit the site. Retry with the point forecast and then the configured load prior, keeping hardware limits, current measurements and departure goals. Retain the rejected inputs for support and report when the plan uses a simpler forecast. diff --git a/.changeset/optional-forecast-caution.md b/.changeset/optional-forecast-caution.md new file mode 100644 index 00000000..18f5ee8d --- /dev/null +++ b/.changeset/optional-forecast-caution.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep a validated point plan when optional forecast caution fails, shortens the plan or worsens a car's departure goal. Try the point forecast before a load prior in Core fallback, and keep raw rejected risk inputs for support. diff --git a/.changeset/reserve-charging-prices.md b/.changeset/reserve-charging-prices.md new file mode 100644 index 00000000..28607317 --- /dev/null +++ b/.changeset/reserve-charging-prices.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Use available prices in reserve charging plans. Move EV charging to cheaper legal slots while retaining the same energy, departure time, battery trajectory and site limits. diff --git a/go/cmd/ftw/forecast_primary_test.go b/go/cmd/ftw/forecast_primary_test.go index 5d02b2d5..8b78de3b 100644 --- a/go/cmd/ftw/forecast_primary_test.go +++ b/go/cmd/ftw/forecast_primary_test.go @@ -316,3 +316,26 @@ func trainNativeLoad(t *testing.T, r *rustForecast, target time.Time) { } } } + +func TestForecastRecoveryArchiveDoesNotClaimRejectedModel(t *testing.T) { + at := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) + f := primaryFixture(at, primaryReply) + in := f.Snapshot(at, trackerWeather(at, at)) + base := in.Resolve(context.Background(), trackerSlots(at, 4)) + rawLoad := base[0].LoadW + for i := range base { + base[i].LoadW = 800 + } + in.Record(base, base, "recovered-decision", at.UnixMilli()) + issue := (<-f.queue).issue + champion := primarySeries(t, issue, "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 raw := primarySeries(t, issue, "energyplan").Points[0]; raw.LoadW != rawLoad || raw.LoadSource == "core_recovery" { + t.Fatal("raw model prediction was overwritten") + } + if err := issue.Validate(); err != nil { + t.Fatal(err) + } +} diff --git a/go/cmd/ftw/forecast_tracking.go b/go/cmd/ftw/forecast_tracking.go index 3863bf67..0c5a8d35 100644 --- a/go/cmd/ftw/forecast_tracking.go +++ b/go/cmd/ftw/forecast_tracking.go @@ -681,6 +681,9 @@ func (f *forecastTracker) Snapshot(_ time.Time, weather []state.ForecastPoint) m } version := forecastModelRevision(issue.Models) 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) 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)}) @@ -718,6 +721,26 @@ 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 { + out := append([]forecasting.Point(nil), selected...) + for i, slot := range base { + if i >= len(out) { + break + } + if slot.LoadW != out[i].LoadW { + 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 +} + // Preserve the source and quality used for each signal, while recording the // actual capped or risk-adjusted watts passed through MPC. func primaryForecastPoints(slots []mpc.Slot, fallback, selected []forecasting.Point) []forecasting.Point { diff --git a/go/internal/mpc/core_recovery_test.go b/go/internal/mpc/core_recovery_test.go index 8f9cb22d..e4b13ed3 100644 --- a/go/internal/mpc/core_recovery_test.go +++ b/go/internal/mpc/core_recovery_test.go @@ -160,7 +160,7 @@ func TestCoreRecoveryRejectsUnsafeCurrentSlotAndInvalidInputs(t *testing.T) { } } -func TestRecoveryPublishesPersistsAndReturnsToFullHorizon(t *testing.T) { +func TestRecoveryPublishesFullPriorAndReturnsToModel(t *testing.T) { for _, externalFailure := range []bool{false, true} { t.Run(map[bool]string{false: "core", true: "external failure"}[externalFailure], func(t *testing.T) { svc := shadowTestService(t) @@ -193,16 +193,21 @@ func TestRecoveryPublishesPersistsAndReturnsToFullHorizon(t *testing.T) { } plan := svc.Replan(context.Background()) snap := svc.PlanSnapshot() - if plan == nil || len(plan.Actions) != 2 || snap.Outdated || snap.Error != "" || len(snap.Warnings) == 0 { - t.Fatalf("safe prefix not published: %+v", snap) + if plan == nil || len(plan.Actions) != 4 || snap.Outdated || snap.Error != "" || len(snap.Warnings) == 0 { + t.Fatalf("full prior plan not published: %+v", snap) } if _, ok := svc.SlotDirectiveAt(svc.now()); !ok { t.Fatal("safe current plan cannot dispatch") } - if _, ok := svc.SlotDirectiveAt(start.Add(2 * time.Hour)); ok { - t.Fatal("plan dispatches beyond its validated prefix") + if _, ok := svc.SlotDirectiveAt(start.Add(2 * time.Hour)); !ok { + t.Fatal("valid prior did not retain the full price horizon") } - if saved == nil || len(saved.Slots) != 2 || saved.Horizon != 2 || recorded != 2 { + for _, slot := range svc.lastSlots { + if slot.LoadW != 500 { + t.Fatal("rejected forecast reached accepted plan") + } + } + if saved == nil || len(saved.Slots) != 4 || saved.Horizon != 4 || recorded != 4 { t.Fatalf("diagnostic or forecast archive lost accepted horizon: saved=%+v recorded=%d", saved, recorded) } if err := ValidatePlan(svc.lastSlots, svc.lastParams, plan); err != nil { diff --git a/go/internal/mpc/energyplan.go b/go/internal/mpc/energyplan.go index fb30fcce..1cd9e40a 100644 --- a/go/internal/mpc/energyplan.go +++ b/go/internal/mpc/energyplan.go @@ -6,7 +6,7 @@ import ( "time" ) -// EnergyplanOptimizer runs the compiled worker on Core's downside forecast. +// EnergyplanOptimizer runs the compiled worker on the forecast Core selects. // Source and builds belong to the private Energyplan repository. type EnergyplanOptimizer struct { *ExternalOptimizer @@ -65,8 +65,8 @@ func energyplanTimeBudget(slots []Slot, p Params) time.Duration { return budget } -// Optimize solves the slots Service has already moved to the PV downside, so -// the worker never adds a second risk margin. +// Optimize solves the slots Service selects, including point and caution +// attempts. The worker never adds a second risk margin. func (o *EnergyplanOptimizer) Optimize(ctx context.Context, slots []Slot, p Params) (Plan, error) { budget := energyplanTimeBudget(slots, p) if budget <= 0 { diff --git a/go/internal/mpc/energyplan_fault_test.go b/go/internal/mpc/energyplan_fault_test.go index 6664943e..de432e2b 100644 --- a/go/internal/mpc/energyplan_fault_test.go +++ b/go/internal/mpc/energyplan_fault_test.go @@ -77,10 +77,10 @@ func TestNativeEnergyplanEVRecoveryFallbackFaults(t *testing.T) { if previous == nil || previous.Solver == nil || previous.Solver.Fallback || previous.InitialSoC != measuredSoC { t.Fatalf("native baseline did not publish from measured low SoC: %+v", previous) } + svc.shadowWG.Wait() if err := ValidatePlan(svc.lastSlots, svc.lastParams, previous); err != nil { t.Fatal(err) } - svc.shadowWG.Wait() previous = svc.Latest() previousBytes, _ = json.Marshal(previous) } diff --git a/go/internal/mpc/ev_goal_contract_test.go b/go/internal/mpc/ev_goal_contract_test.go new file mode 100644 index 00000000..be8b1517 --- /dev/null +++ b/go/internal/mpc/ev_goal_contract_test.go @@ -0,0 +1,104 @@ +package mpc + +import ( + "context" + "encoding/json" + "math" + "testing" + "time" +) + +// 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) { + for _, backend := range []string{"native", "reserve"} { + t.Run(backend, func(t *testing.T) { + solve := coreReservePlan + if backend == "native" { + worker := nativeWorker(t, time.Second) + t.Cleanup(func() { _ = worker.Close() }) + solve = worker.Optimize + } + for _, tc := range []struct { + name string + due, cheap int + houseW float64 + partial bool + wantShortfall float64 + wantCostlyWh float64 + }{ + {"enough cheaper hours", 15, 8, 1800, false, 0, 0}, + {"expensive energy needed for goal", 15, 12, 1800, false, 0, 10380.0/.9 - 10350}, + {"unreachable departure", 0, 8, 1800, false, 10380 - 10350*.25*.9, 10350 * .25}, + {"forty-five seconds to departure", 0, 8, 1800, true, 10380 - 10350*.0125*.9, 10350 * .0125}, + {"no legal charger step fits", 15, 8, 14000, false, 10380, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + start := time.Date(2026, 10, 15, 17, 0, 0, 0, time.UTC) + slots := make([]Slot, 16) + for i := range slots { + price := 450.0 + if i >= tc.cheap { + price = 50 + } + slots[i] = Slot{StartMs: start.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(), LenMin: 15, + LoadW: tc.houseW, PriceOre: price, SpotOre: price, + Limits: PowerLimits{MaxImportW: 17250, MaxExportW: 17250}} + } + if tc.partial { + slots[0].ExecutionStartMs = slots[0].StartMs + (14*time.Minute + 15*time.Second).Milliseconds() + } + p := Params{Mode: ModeArbitrage, CapacityWh: 15000, InitialSoC: .5, + SoCMin: .1, SoCMax: .9, SoCLevels: 101, ActionLevels: 201, + ChargeEfficiency: .95, DischargeEfficiency: .95, + Loadpoint: &LoadpointSpec{ID: "car", CapacityWh: 86500, InitialSoC: .68, + SoCMax: 1, Levels: 101, PluggedIn: true, TargetSoC: .8, TargetSlotIdx: tc.due, + MinChargeW: 4140, MaxChargeW: 11000, AllowedStepsW: chargerSteps(690), + ChargeEfficiency: .9, NoBatteryToEV: true}} + // Use three-phase steps from 6 to 15 A, up to 10,350 W. + p.Loadpoint.AllowedStepsW = p.Loadpoint.AllowedStepsW[:len(p.Loadpoint.AllowedStepsW)-1] + before, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + plan, err := solve(context.Background(), slots, p) + if err != nil { + t.Fatalf("EV goal disabled site planning: %v", err) + } + if len(plan.Actions) != len(slots) || plan.HorizonSlots != len(slots) { + t.Fatalf("EV goal shortened the site plan: %d actions, horizon %d", len(plan.Actions), plan.HorizonSlots) + } + if err := ValidatePlan(slots, p, &plan); err != nil { + t.Fatal(err) + } + if got := plan.LoadpointShortfallWh["car"]; math.Abs(got-tc.wantShortfall) > 1 { + t.Fatalf("shortfall %.6f Wh, want %.6f", got, tc.wantShortfall) + } + var deliveredWh, costlyWh float64 + for i, a := range plan.Actions { + deliveredWh += a.LoadpointW * slots[i].DurationHours() + if i < tc.cheap { + costlyWh += a.LoadpointW * slots[i].DurationHours() + } + if i > tc.due && a.LoadpointW > 1e-6 { + t.Fatalf("slot %d charges after departure: %.6f W", i, a.LoadpointW) + } + if math.Abs(a.SoC-p.InitialSoC) > 1e-6 || math.Abs(a.BatteryW) > 1e-6 { + t.Fatal("EV goal changed the fixed battery trajectory") + } + } + if math.Abs(deliveredWh*.9+tc.wantShortfall-10380) > 1 || math.Abs(costlyWh-tc.wantCostlyWh) > 1 { + t.Fatalf("delivered %.6f Wh, expensive %.6f Wh; want %.6f and %.6f", deliveredWh, costlyWh, (10380-tc.wantShortfall)/.9, tc.wantCostlyWh) + } + if got := plan.Actions[tc.due].LoadpointSoC*86500 + tc.wantShortfall; math.Abs(got-.8*86500) > 1 { + t.Fatalf("departure energy plus missing energy %.6f Wh, want 69200", got) + } + after, err := json.Marshal(p) + if err != nil || string(before) != string(after) { + t.Fatal("planner changed the requested goal or physical settings") + } + }) + } + }) + } +} diff --git a/go/internal/mpc/ev_reserve.go b/go/internal/mpc/ev_reserve.go index 1813f088..bdc5ec0b 100644 --- a/go/internal/mpc/ev_reserve.go +++ b/go/internal/mpc/ev_reserve.go @@ -10,8 +10,8 @@ import ( "github.com/srcfl/ftw/go/internal/loadpoint" ) -// coreReservePlan makes prompt progress toward an EV deadline when the worker -// is unavailable. It uses exact energy, legal on-power and idle/charge pulses; +// coreReservePlan retains an EV departure schedule when the worker is +// unavailable. It uses exact energy, legal on-power and idle/charge pulses; // optional home-battery trading must not consume the car's available headroom. // The service still validates the complete plan before publication. func coreReservePlan(ctx context.Context, slots []Slot, p Params) (Plan, error) { @@ -117,7 +117,7 @@ func coreReservePlan(ctx context.Context, slots []Slot, p Params) (Plan, error) if lp == nil { plan.Solver.Backend = "site_reserve" } - return plan, nil + return priceReserveCharging(ctx, slots, p, plan) } type reserveEnergyBand struct{ min, max float64 } diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index 3b55d7ef..3e597acb 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -168,6 +168,10 @@ type externalError struct { Message string `json:"message"` } +func (e *externalError) Error() string { + return fmt.Sprintf("optimizer %s: %s", e.Code, e.Message) +} + type externalPlan struct { FlexShortfallWh map[string]float64 `json:"flex_shortfall_wh,omitempty"` Mode Mode `json:"mode"` @@ -240,7 +244,7 @@ func (o *ExternalOptimizer) Optimize(ctx context.Context, slots []Slot, p Params if response.Error == nil { return Plan{}, errors.New("optimizer returned an unspecified error") } - return Plan{}, fmt.Errorf("optimizer %s: %s", response.Error.Code, response.Error.Message) + return Plan{}, response.Error } if err := validateExternalAssets(request, response.Plan); err != nil { return Plan{}, fmt.Errorf("optimizer contract rejected: %w", err) diff --git a/go/internal/mpc/failure_diagnostic_test.go b/go/internal/mpc/failure_diagnostic_test.go index a6ea5b1a..18706240 100644 --- a/go/internal/mpc/failure_diagnostic_test.go +++ b/go/internal/mpc/failure_diagnostic_test.go @@ -24,18 +24,19 @@ func TestFailureEvidenceKeepsRejectedDownsideInputs(t *testing.T) { svc.PV = func(time.Time, float64) float64 { return 1000 } svc.PVForecastSafetyK = 1 svc.PVUncertaintyW = func() float64 { return math.NaN() } - if svc.Replan(context.Background()) != nil { - t.Fatal("invalid current downside forecast became a plan") + plan := svc.Replan(context.Background()) + if plan == nil || len(plan.Actions) != 4 { + t.Fatal("invalid optional downside forecast blocked a valid point plan") } failure := svc.PlanningFailureSnapshot() if failure == nil || failure.Stage != "risk_inputs" { t.Fatalf("rejected downside evidence was overwritten: %+v", failure) } var inputs struct { - Slots []map[string]any `json:"slots"` + Params map[string]any `json:"params"` } - if err := json.Unmarshal(failure.Inputs, &inputs); err != nil || len(inputs.Slots) == 0 || inputs.Slots[0]["PVW"] != "NaN" { - t.Fatalf("failure reports the valid base forecast instead of rejected risk inputs: %s %v", failure.Inputs, err) + if err := json.Unmarshal(failure.Inputs, &inputs); err != nil || inputs.Params["PVUncertaintyW"] != "NaN" { + t.Fatalf("failure lost the raw uncertainty before caution was disabled: %s %v", failure.Inputs, err) } restored := New(svc.Store, nil, svc.Zone, svc.Defaults).PlanningFailureSnapshot() if restored == nil || !bytes.Equal(restored.Inputs, failure.Inputs) || restored.Stage != "risk_inputs" { @@ -343,13 +344,19 @@ func TestNativeSupportFailureEvidenceMatchesRejectedWorkerRequest(t *testing.T) svc.Optimizer = &EnergyplanOptimizer{ExternalOptimizer: worker} plan := svc.Replan(context.Background()) failure := svc.PlanningFailureSnapshot() - if plan == nil || len(plan.Actions) != 2 || failure == nil || failure.OptimizerRequest == nil || failure.OptimizerResponse == nil { + if plan == nil || len(plan.Actions) != 4 || failure == nil || failure.OptimizerRequest == nil || failure.OptimizerResponse == nil { t.Fatalf("native failure or safe recovery missing: plan=%v failure=%+v", plan != nil, failure) } var request externalRequest var response externalResponse if json.Unmarshal(failure.OptimizerRequest.JSON, &request) != nil || json.Unmarshal(failure.OptimizerResponse.JSON, &response) != nil || - request.RequestID != response.RequestID || response.OK || len(request.Slots) != 4 || !strings.Contains(failure.Error, "infeasible") { + request.RequestID != response.RequestID || response.OK || len(request.Slots) != 4 || request.Slots[2].LoadW != 40000 || !strings.Contains(failure.Error, "infeasible") { t.Fatal("captured exchange does not reproduce the rejected native request") } + if plan.Solver.Engine != "native" || plan.Solver.Fallback || failure.RecoveredDecisionID != plan.DecisionID { + t.Fatal("simpler forecast did not recover native price planning") + } + if err := ValidatePlan(svc.lastSlots, svc.lastParams, plan); err != nil { + t.Fatal(err) + } } diff --git a/go/internal/mpc/forecast_margin.go b/go/internal/mpc/forecast_margin.go new file mode 100644 index 00000000..1b2071ff --- /dev/null +++ b/go/internal/mpc/forecast_margin.go @@ -0,0 +1,148 @@ +package mpc + +import ( + "context" + "fmt" + "time" +) + +type forecastSolve func(context.Context, []Slot, Params) (Plan, []Slot, error) + +type forecastChoice struct { + plan Plan + slots []Slot + warning string + marginErr error +} + +func validateForecastCaution(p Params) error { + for _, value := range []struct { + name string + v float64 + }{ + {"pv_uncertainty_w", p.PVUncertaintyW}, + {"pv_relative_uncertainty", p.PVRelativeUncertainty}, + {"pv_forecast_safety_k", p.PVForecastSafetyK}, + } { + if err := requireNonNegativePlanningValue(value.name, value.v); err != nil { + return err + } + } + 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. +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) + } + choice := forecastChoice{plan: plan, slots: used} + if err != nil || len(point) == 0 || len(used) != len(point) || sameForecast(point, risk) { + return choice, err + } + // 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 + } + 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() + if err == nil && len(risk) != len(point) { + err = fmt.Errorf("forecast caution changed the planning horizon") + } + if err == nil { + for i, slot := range risk { + slot.PVW, slot.LoadW = point[i].PVW, point[i].LoadW + if slot != point[i] { + err = fmt.Errorf("forecast caution changed slot %d market data, timeline or site limits", i) + break + } + } + } + if err == nil { + candidate, accepted, err = solve(marginCtx, risk, p) + } + if err == nil && len(accepted) != len(used) { + err = fmt.Errorf("forecast caution shortened the planning horizon") + } + if err == nil { + err = ValidatePlan(accepted, p, &candidate) + } + if err == nil { + err = validateMarginPointLoad(point, accepted, p, candidate) + } + if err == nil { + err = preservesDepartureGoals(p, plan, candidate) + } + if err != nil { + choice.marginErr = err + choice.warning = "Forecast caution could not retain the point plan's limits and departure goals; using the point forecast: " + err.Error() + return choice, nil + } + return forecastChoice{plan: candidate, slots: accepted}, nil +} + +// 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. +func validateMarginPointLoad(point, risk []Slot, p Params, plan Plan) error { + slots := append([]Slot(nil), point...) + plan.Actions = append([]Action(nil), plan.Actions...) + plan.TotalCostOre = 0 + for i := range slots { + a := &plan.Actions[i] + a.GridW += point[i].LoadW - risk[i].LoadW + slots[i].PVW = risk[i].PVW + a.LoadW = point[i].LoadW + a.CostOre = reserveSlotCost(slots[i], p, *a) + plan.TotalCostOre += a.CostOre + } + if err := ValidatePlan(slots, p, &plan); err != nil { + return fmt.Errorf("forecast caution requires extra house load to obey site policy: %w", err) + } + return nil +} + +func preservesDepartureGoals(p Params, point, risk Plan) error { + for lpIdx, lp := range p.activeLoadpoints() { + i := lp.TargetSlotIdx + if lp.TargetSoC <= 0 || i < 0 || i >= len(point.Actions) { + continue + } + 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) + // 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) + } + } + return nil +} diff --git a/go/internal/mpc/forecast_margin_recovery_test.go b/go/internal/mpc/forecast_margin_recovery_test.go new file mode 100644 index 00000000..913929e1 --- /dev/null +++ b/go/internal/mpc/forecast_margin_recovery_test.go @@ -0,0 +1,124 @@ +package mpc + +import ( + "context" + "math" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/forecasting" + "github.com/srcfl/ftw/go/internal/state" +) + +// A point load above the fuse can be feasible with battery support. Optional +// forecast caution must not turn that valid point forecast into a reserve plan. +func TestNativePlannerRecoversValidPointFromImpossibleLoadMargin(t *testing.T) { + for _, backend := range []string{"native", "core", "worker unavailable"} { + t.Run(backend, func(t *testing.T) { + for _, tc := range []struct { + name string + peak, power float64 + goalBlocked bool + }{ + {"infeasible margin", 22400, 10000, false}, + {"feasible margin misses reachable departure", 12000, 0, true}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := shadowTestService(t) + var worker *ExternalOptimizer + if backend == "native" { + worker = nativeWorker(t, time.Second) + svc.Optimizer = &EnergyplanOptimizer{ExternalOptimizer: worker} + } else if backend == "worker unavailable" { + svc.Optimizer = &physicsGateFailingOptimizer{} + } + t.Cleanup(func() { + svc.replanWG.Wait() + svc.shadowWG.Wait() + if worker != nil { + _ = worker.Close() + } + }) + start := time.Now().UTC().Truncate(time.Hour).Add(time.Hour) + origin := start.Add(5 * time.Minute) + svc.now = func() time.Time { return origin } + svc.FuseMaxW, svc.BaseLoad, svc.PVForecastSafetyK = 17250, 800, .6 + svc.Defaults.Mode = ModeArbitrage + svc.Defaults.CapacityWh = 15000 + svc.Defaults.MaxChargeW, svc.Defaults.MaxDischargeW = tc.power, tc.power + svc.Loadpoint = func(int) *LoadpointSpec { + return &LoadpointSpec{ID: "car", CapacityWh: 86500, InitialSoC: .68, + SoCMax: 1, Levels: 101, PluggedIn: true, TargetSoC: .8, TargetSlotIdx: 19, + MinChargeW: 4140, MaxChargeW: 11000, AllowedStepsW: []float64{0, 4140, 6900, 10350}, + ChargeEfficiency: .9, NoBatteryToEV: true} + } + for i := 0; i < 20; i++ { + price := 450.0 + if i >= 8 { + price = 50 + } + if err := svc.Store.SavePrices([]state.PricePoint{{Zone: "SE3", SlotTsMs: start.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(), SlotLenMin: 15, + SpotOreKwh: price, TotalOreKwh: price, Source: "test", FetchedAtMs: start.UnixMilli()}}); err != nil { + t.Fatal(err) + } + } + calibrator := forecasting.NewCalibrator(nil, "test", origin.UnixMilli()) + var rejected []Slot + svc.ForecastSnapshot = func(time.Time, []state.ForecastPoint) ForecastInputs { + return ForecastInputs{ + Load: func(at time.Time) float64 { + if tc.goalBlocked || !at.Before(start.Add(4*time.Hour)) { + return tc.peak + } + return 1800 + }, + Risk: func(base, planning []Slot, k float64) { + for i, s := range base { + band := calibrator.BandForInterval("legacy_shadow", "load", s.StartMs, s.StartMs+int64(s.LenMin)*60000, s.LoadW) + planning[i].LoadW = s.LoadW + k*(band.HighW-s.LoadW) + } + rejected = append([]Slot(nil), planning...) + }, + } + } + plan := svc.Replan(context.Background()) + svc.shadowWG.Wait() + if plan == nil || plan.Solver == nil || len(plan.Actions) != 20 || (backend == "native" && (plan.Solver.Engine != "native" || plan.Solver.Fallback)) { + t.Fatalf("optional caution disabled native planning: plan=%+v status=%+v", plan, svc.PlanSnapshot()) + } + if err := ValidatePlan(svc.lastSlots, svc.lastParams, plan); err != nil { + t.Fatal(err) + } + if (!tc.goalBlocked && plan.Actions[0].LoadpointW > 1e-6) || math.Abs(plan.Actions[19].LoadpointSoC-.8) > 1e-5 { + t.Fatalf("optional margin lost the reachable departure goal: solver=%+v shortfall=%v", plan.Solver, plan.LoadpointShortfallWh) + } + if len(rejected) != 20 || math.Abs(rejected[16].LoadW-tc.peak*1.27) > 1e-6 || svc.lastSlots[16].LoadW != tc.peak { + t.Fatal("recovery changed a feasible point peak or used the configured prior") + } + if !strings.Contains(strings.Join(svc.PlanSnapshot().Warnings, " "), "point forecast") { + t.Fatal("dropping the impossible risk margin was hidden") + } + if worker == nil { + return + } + // Isolate the house constraint from the car. One margin makes even + // the house impossible; the other fits the house but blocks the car. + p := svc.lastParams + p.Loadpoint, p.Loadpoints = nil, nil + withoutCar, err := worker.Optimize(context.Background(), rejected, p) + if tc.goalBlocked { + if err != nil { + t.Fatalf("house-only risk plan: %v", err) + } + if err := ValidatePlan(rejected, p, &withoutCar); err != nil { + t.Fatal(err) + } + } else if !forecastInfeasible(err) { + t.Fatalf("impossible house margin was attributed to the car: %v", err) + } + }) + } + }) + } +} diff --git a/go/internal/mpc/forecast_margin_test.go b/go/internal/mpc/forecast_margin_test.go new file mode 100644 index 00000000..0bb42712 --- /dev/null +++ b/go/internal/mpc/forecast_margin_test.go @@ -0,0 +1,391 @@ +package mpc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/state" +) + +type optionalMarginOptimizer struct { + worker *ExternalOptimizer + fault string + calls int +} + +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 { + return o.worker.Optimize(ctx, slots, p) + } + switch o.fault { + case "infeasible": + return Plan{}, &externalError{Code: "infeasible", Message: "optional margin"} + case "broken reply": + return Plan{}, errors.New("decode optimizer response: invalid JSON") + case "timeout": + <-ctx.Done() + return Plan{}, ctx.Err() + case "invalid plan": + return Plan{Actions: []Action{{GridW: math.NaN()}}}, nil + } + return o.worker.Optimize(ctx, slots, p) +} + +func TestForecastMarginKeepsPointAfterOptionalFailure(t *testing.T) { + for _, fault := range []string{"infeasible", "broken reply", "timeout", "invalid plan"} { + t.Run(fault, 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) } + worker := nativeWorker(t, time.Second) + t.Cleanup(func() { svc.replanWG.Wait(); svc.shadowWG.Wait(); _ = worker.Close() }) + optimizer := &optionalMarginOptimizer{worker: worker, fault: fault} + svc.Optimizer = optimizer + svc.Defaults.MaxChargeW, svc.Defaults.MaxDischargeW = 0, 0 + 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 + } + }} + } + ctx := context.Background() + if fault == "timeout" { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 200*time.Millisecond) + defer cancel() + } + plan := svc.Replan(ctx) + svc.shadowWG.Wait() + 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 err := ValidatePlan(svc.lastSlots, svc.lastParams, plan); err != nil { + t.Fatal(err) + } + for _, slot := range svc.lastSlots { + if slot.LoadW != 1000 { + t.Fatal("optional failure changed the point forecast") + } + } + failure := svc.PlanningFailureSnapshot() + if failure == nil || failure.RecoveredDecisionID != plan.DecisionID || !svc.PlanningFailureStorageSnapshot().Durable { + t.Fatal("optional failure lost its durable evidence") + } + }) + } +} + +func TestForecastMarginInvalidPVKeepsFullPointHorizon(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) { + svc := shadowTestService(t) + start := time.Now().UTC().Truncate(time.Hour) + svc.now = func() time.Time { return start.Add(5 * time.Minute) } + if backend == "native" { + worker := nativeWorker(t, time.Second) + t.Cleanup(func() { svc.replanWG.Wait(); svc.shadowWG.Wait(); _ = worker.Close() }) + svc.Optimizer = &EnergyplanOptimizer{ExternalOptimizer: worker} + } + svc.ForecastSnapshot = func(time.Time, []state.ForecastPoint) ForecastInputs { + return ForecastInputs{Load: func(time.Time) float64 { return 1000 }, + Risk: func(_, risk []Slot, _ float64) { risk[badSlot].PVW = math.NaN() }} + } + plan := svc.Replan(context.Background()) + svc.shadowWG.Wait() + if plan == nil || len(plan.Actions) != 4 || len(svc.lastSlots) != 4 { + t.Fatal("invalid optional PV removed a valid point interval") + } + failure := svc.PlanningFailureSnapshot() + if failure == nil || !strings.Contains(string(failure.Inputs), `"NaN"`) || failure.RecoveredDecisionID != plan.DecisionID { + t.Fatal("raw invalid risk was hidden") + } + }) + } + } +} + +func TestForecastMarginRetainsRealDepartureShortfall(t *testing.T) { + for _, backend := range []string{"core", "native", "worker unavailable"} { + t.Run(backend, func(t *testing.T) { + svc := shadowTestService(t) + if backend == "native" { + worker := nativeWorker(t, time.Second) + t.Cleanup(func() { svc.replanWG.Wait(); svc.shadowWG.Wait(); _ = worker.Close() }) + svc.Optimizer = &EnergyplanOptimizer{ExternalOptimizer: worker} + } else if backend == "worker unavailable" { + svc.Optimizer = &physicsGateFailingOptimizer{} + } + start := time.Now().UTC().Truncate(time.Hour) + svc.now = func() time.Time { return start.Add(5 * time.Minute) } + svc.Defaults.MaxChargeW, svc.Defaults.MaxDischargeW = 0, 0 + svc.FuseMaxW, svc.BaseLoad = 17250, 800 + svc.Loadpoint = func(int) *LoadpointSpec { + return &LoadpointSpec{ID: "car", CapacityWh: 86500, InitialSoC: .68, SoCMax: 1, + Levels: 101, PluggedIn: true, TargetSoC: .8, TargetSlotIdx: 0, + AllowedStepsW: []float64{0, 4140, 6900, 10350}, MinChargeW: 4140, MaxChargeW: 11000, ChargeEfficiency: .9, NoBatteryToEV: true} + } + svc.ForecastSnapshot = func(time.Time, []state.ForecastPoint) ForecastInputs { + return ForecastInputs{Load: func(time.Time) float64 { return 12000 }, + Risk: func(_, risk []Slot, _ float64) { + for i := range risk { + risk[i].LoadW = 15000 + } + }} + } + plan := svc.Replan(context.Background()) + svc.shadowWG.Wait() + want := 10380 - 4140*(55.0/60)*.9 + if plan == nil || len(plan.Actions) != 4 || math.Abs(plan.LoadpointShortfallWh["car"]-want) > 1 || plan.Actions[0].LoadpointW != 4140 { + t.Fatalf("unreachable goal changed the real shortfall: %+v", svc.PlanSnapshot()) + } + if svc.lastSlots[0].LoadW != 12000 { + t.Fatal("unreachable goal triggered a fictitious load prior") + } + }) + } +} + +func TestForecastMarginCannotTradeDepartureGoalsBetweenCars(t *testing.T) { + slots, p := externalTestFixture() + p.MaxChargeW, p.MaxDischargeW = 0, 0 + p.Loadpoints = nil + for _, id := range []string{"first", "second"} { + p.Loadpoints = append(p.Loadpoints, &LoadpointSpec{ID: id, CapacityWh: 10000, InitialSoC: .2, SoCMax: 1, Levels: 11, + PluggedIn: true, TargetSoC: .8, TargetSlotIdx: 1, AllowedStepsW: []float64{0, 2000}, MaxChargeW: 2000, ChargeEfficiency: 1}) + } + for i := range slots { + slots[i].PVW, slots[i].LoadW = 0, 1000 + slots[i].Limits = PowerLimits{MaxImportW: 10000, MaxExportW: 10000} + } + risk := append([]Slot(nil), slots...) + for i := range risk { + risk[i].LoadW = 1200 + } + calls := 0 + 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++ + powers := [][2]float64{{0, 2000}, {2000, 2000}} + if calls == 2 { + powers = [][2]float64{{2000, 2000}, {2000, 1000}} + } + plan := Plan{CapacityWh: p.CapacityWh, InitialSoC: p.InitialSoC, HorizonSlots: 2, Mode: p.Mode} + soc := [2]float64{.2, .2} + for i, slot := range inputs { + a := Action{SlotStartMs: slot.StartMs, SlotLenMin: slot.LenMin, SoC: p.InitialSoC, + LoadW: slot.LoadW, LoadpointPowerW: map[string]float64{}, LoadpointSoCByID: map[string]float64{}, LoadpointMaxPowerW: map[string]float64{}} + for j, lp := range p.Loadpoints { + w := powers[i][j] + soc[j] += w * slot.DurationHours() / 10000 + a.LoadpointPowerW[lp.ID], a.LoadpointSoCByID[lp.ID] = w, soc[j] + a.LoadpointMaxPowerW[lp.ID] = 0 + if w > 0 { + a.LoadpointMaxPowerW[lp.ID] = 2000 + } + a.GridW += w + } + a.GridW += slot.LoadW + a.LoadpointW, a.LoadpointSoC = powers[i][0], soc[0] + a.CostOre = reserveSlotCost(slot, p, a) + plan.TotalCostOre += a.CostOre + plan.Actions = append(plan.Actions, a) + } + if err := ValidatePlan(inputs, p, &plan); err != nil { + t.Fatal(err) + } + if calls == 1 { + point = plan + } else { + candidate = plan + } + return plan, inputs, nil + }) + if err != nil || calls != 2 || choice.warning == "" || choice.slots[0].LoadW != 1000 { + t.Fatalf("risk traded one car's departure for another: %+v %v", choice, err) + } + if candidate.LoadpointShortfallWh["first"]+candidate.LoadpointShortfallWh["second"] >= point.LoadpointShortfallWh["first"]+point.LoadpointShortfallWh["second"] { + t.Fatal("fixture must improve total shortfall while worsening the second car") + } + before, _ := json.Marshal(point) + after, _ := json.Marshal(choice.plan) + if string(before) != string(after) { + t.Fatal("point plan changed when risk was rejected") + } +} + +func TestForecastMarginCannotGrantExtraDischargePermission(t *testing.T) { + for _, mode := range []Mode{ModeSelfConsumption, ModeArbitrage} { + t.Run(string(mode), func(t *testing.T) { + slots, p := externalTestFixture() + slots = slots[:1] + slots[0].LoadW, p.Mode, p.InitialSoC = 2000, mode, .5 + p.DischargeEfficiency = 1 + a := Action{SlotStartMs: slots[0].StartMs, SlotLenMin: 60, LoadW: 2000, BatteryW: -1500, GridW: 500, SoC: .35} + if mode == ModeArbitrage { + 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} + a.LoadpointW, a.LoadpointSoC, a.GridW = 1000, .3, 1500 + } + a.CostOre = reserveSlotCost(slots[0], p, a) + plan := Plan{Mode: 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") + } + after, _ := json.Marshal(plan) + if string(before) != string(after) { + t.Fatal("diagnostic replay changed the candidate") + } + }) + } +} + +func TestForecastMarginOnlyUsesTimeLeftAfterPointPlan(t *testing.T) { + slots, p := externalTestFixture() + slots = slots[:1] + slots[0].ExecutionStartMs = slots[0].StartMs + 3600000 - 1 + p.MaxChargeW, p.MaxDischargeW = 0, 0 + risk := append([]Slot(nil), slots...) + risk[0].LoadW += 100 + calls := 0 + choice, err := planWithForecastMargin(context.Background(), slots, risk, p, func() time.Time { return time.UnixMilli(slots[0].ExecutionStart()) }, func(ctx context.Context, inputs []Slot, p Params) (Plan, []Slot, error) { + calls++ + plan, err := coreReservePlan(ctx, inputs, p) + return plan, inputs, err + }) + if err != nil || calls != 1 || choice.warning == "" || choice.slots[0].LoadW != slots[0].LoadW { + t.Fatalf("optional risk spent the point plan's publication time: calls=%d err=%v warning=%s", calls, err, choice.warning) + } +} + +func TestForecastMarginRetainsCautionWhenGoalsAndLimitsAllowIt(t *testing.T) { + slots, p := externalTestFixture() + p.SoCLevels, p.ActionLevels = 11, 5 + p.MaxChargeW, p.MaxDischargeW = 0, 0 + risk := append([]Slot(nil), slots...) + for i := range risk { + risk[i].LoadW += 100 + } + choice, err := planWithForecastMargin(context.Background(), slots, risk, p, func() time.Time { return time.UnixMilli(slots[0].ExecutionStart()) }, corePlanWithRecovery) + if err != nil || choice.warning != "" || choice.slots[0].LoadW != risk[0].LoadW { + t.Fatalf("valid optional caution was discarded: %v %s", err, choice.warning) + } +} + +func TestForecastMarginCannotChangeSiteOrMarketInputs(t *testing.T) { + slots, p := externalTestFixture() + p.SoCLevels, p.ActionLevels = 11, 5 + p.MaxChargeW, p.MaxDischargeW = 0, 0 + risk := append([]Slot(nil), slots...) + risk[0].LoadW += 100 + risk[0].Limits.MaxImportW += 1000 + calls := 0 + choice, err := planWithForecastMargin(context.Background(), slots, risk, p, func() time.Time { return time.UnixMilli(slots[0].ExecutionStart()) }, func(ctx context.Context, inputs []Slot, p Params) (Plan, []Slot, error) { + calls++ + return corePlanWithRecovery(ctx, inputs, p) + }) + if err != nil || calls != 1 || choice.warning == "" || choice.slots[0].Limits != slots[0].Limits { + t.Fatalf("optional forecast changed the site limits: %v %+v", err, choice) + } +} + +func TestForecastMarginKeepsActualPublicationTime(t *testing.T) { + slots, p := externalTestFixture() + slots = slots[:1] + p.MaxChargeW, p.MaxDischargeW = 0, 0 + end := time.Now().Add(150 * time.Millisecond).UnixMilli() + // This snapshot was frozen 850 ms ago; 1 s was then available. + slots[0].StartMs = end - 3600000 + slots[0].LenMin = 60 + slots[0].ExecutionStartMs = end - 1000 + slots[0].PVW, slots[0].LoadW = 0, 1000 + risk := append([]Slot(nil), slots...) + risk[0].LoadW += 100 + calls := 0 + began := time.Now() + choice, err := planWithForecastMargin(context.Background(), slots, risk, p, time.Now, func(ctx context.Context, inputs []Slot, p Params) (Plan, []Slot, error) { + calls++ + if calls == 2 { + <-ctx.Done() + return Plan{}, nil, ctx.Err() + } + plan, err := coreReservePlan(ctx, inputs, p) + return plan, inputs, err + }) + if err != nil || choice.warning == "" || len(choice.plan.Actions) != 1 { + t.Fatalf("fixture did not retain its point plan: %v %+v", err, choice) + } + elapsed := time.Since(began) + if time.Now().UnixMilli() >= end { + t.Fatalf("optional risk spent the point plan's actual publication time: elapsed=%v; only 150ms remained at entry, frozen duration=1s, calls=%d", elapsed, calls) + } +} + +// 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) { + for _, backend := range []string{"core", "native"} { + for _, field := range []string{"PVUncertaintyW", "PVRelativeUncertainty", "PVForecastSafetyK"} { + for _, bad := range []struct { + name string + v float64 + raw any + }{{"NaN", math.NaN(), "NaN"}, {"positive infinity", math.Inf(1), "+Inf"}, {"negative infinity", math.Inf(-1), "-Inf"}, {"negative", -1, float64(-1)}} { + t.Run(backend+"/"+field+"/"+bad.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.Defaults.MaxChargeW, svc.Defaults.MaxDischargeW = 0, 0 + svc.PVForecastSafetyK = 1 + svc.PV = func(time.Time, float64) float64 { return 1000 } + if backend == "native" { + worker := nativeWorker(t, time.Second) + t.Cleanup(func() { svc.replanWG.Wait(); svc.shadowWG.Wait(); _ = worker.Close() }) + svc.Optimizer = &EnergyplanOptimizer{ExternalOptimizer: worker} + } + switch field { + case "PVUncertaintyW": + svc.PVUncertaintyW = func() float64 { return bad.v } + case "PVRelativeUncertainty": + svc.PVRelativeUncertainty = func() float64 { return bad.v } + case "PVForecastSafetyK": + svc.PVForecastSafetyK = bad.v + } + plan := svc.Replan(context.Background()) + svc.shadowWG.Wait() + if plan == nil || len(plan.Actions) != 4 || len(svc.PlanSnapshot().Warnings) == 0 { + t.Fatalf("optional %s blocked the point plan: %+v", field, svc.PlanSnapshot()) + } + if svc.lastParams.PVUncertaintyW != 0 || svc.lastParams.PVRelativeUncertainty != 0 || svc.lastParams.PVForecastSafetyK != 0 { + t.Fatal("invalid caution remained in effective planning parameters") + } + failure := svc.PlanningFailureSnapshot() + var inputs struct { + Params map[string]any `json:"params"` + } + if failure == nil || json.Unmarshal(failure.Inputs, &inputs) != nil || inputs.Params[field] != bad.raw || + failure.RecoveredDecisionID != plan.DecisionID || !svc.PlanningFailureStorageSnapshot().Durable { + t.Fatalf("raw %s was lost before disabling caution: %+v", field, failure) + } + }) + } + } + } +} diff --git a/go/internal/mpc/forecast_recovery.go b/go/internal/mpc/forecast_recovery.go new file mode 100644 index 00000000..7e42fe06 --- /dev/null +++ b/go/internal/mpc/forecast_recovery.go @@ -0,0 +1,112 @@ +package mpc + +import ( + "context" + "errors" + "fmt" + "time" +) + +// Two bounded worker passes may each need the fleet budget. +const forecastRecoveryBudget = 12 * time.Second + +const forecastPriorWarning = "Load forecast cannot be served within site limits; using the configured load prior" + +// Only a well-formed infeasibility result justifies changing forecast +// assumptions. Timeouts, broken replies and invalid hardware settings do not. +func forecastInfeasible(err error) bool { + var reply *externalError + return errors.As(err, &reply) && reply.Code == "infeasible" +} + +func sameForecast(a, b []Slot) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].PVW != b[i].PVW || a[i].LoadW != b[i].LoadW { + return false + } + } + return true +} + +// A configured prior is an explicit degraded forecast, never a new physical +// limit or a claim about measured demand. Keep qualified current house load. +func priorForecast(base []Slot, loadW float64, currentMeasured bool) []Slot { + if !finite(loadW) || loadW < 0 { + return nil + } + out := append([]Slot(nil), base...) + for i := range out { + if i != 0 || !currentMeasured { + out[i].LoadW = loadW + } + } + if validatePlanningSlots(out) != nil { + return nil + } + return out +} + +type forecastAttempt struct { + slots []Slot + base []Slot + reason string +} + +// One point-forecast retry and one prior retry at most. The same optimiser +// decides feasibility and price choice; Core does not duplicate its physics. +func forecastRecoveryAttempts(planning, base []Slot, priorW float64, currentMeasured bool) []forecastAttempt { + attempts := []forecastAttempt{} + if !sameForecast(planning, base) && validatePlanningSlots(base) == nil { + attempts = append(attempts, forecastAttempt{base, base, "Forecast risk margin cannot be served within site limits; using the point forecast"}) + } + prior := priorForecast(base, priorW, currentMeasured) + if prior != nil && !sameForecast(planning, prior) && !sameForecast(base, prior) { + attempts = append(attempts, forecastAttempt{prior, prior, forecastPriorWarning}) + } + return attempts +} + +// Non-finite or negative model load is missing data, not zero consumption. +// Keep the raw values for diagnosis; use an independent numeric forecast. +func recoverInvalidForecastLoads(slots, legacy []Slot, priorW float64) ([]Slot, error) { + out := append([]Slot(nil), slots...) + count := 0 + for i := range out { + if finite(out[i].LoadW) && out[i].LoadW >= 0 { + continue + } + replacement := priorW + if i < len(legacy) && finite(legacy[i].LoadW) && legacy[i].LoadW >= 0 { + replacement = legacy[i].LoadW + } + if finite(replacement) && replacement >= 0 { + out[i].LoadW = replacement + count++ + } + } + if count > 0 { + return out, fmt.Errorf("Rejected %d invalid load forecasts; using legacy forecasts or the configured load prior", count) + } + return out, nil +} + +// 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) { + 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 + } + prior := priorForecast(point, priorW, currentMeasured) + if prior == nil || sameForecast(point, prior) || ctx.Err() != nil { + return choice.plan, choice.slots, err, choice.warning + } + candidate, accepted, retryErr := corePlanWithRecovery(ctx, prior, p) + if retryErr == nil && len(accepted) > len(choice.slots) { + return candidate, accepted, nil, forecastPriorWarning + } + return choice.plan, choice.slots, err, choice.warning +} diff --git a/go/internal/mpc/forecast_recovery_test.go b/go/internal/mpc/forecast_recovery_test.go new file mode 100644 index 00000000..d11a3d6c --- /dev/null +++ b/go/internal/mpc/forecast_recovery_test.go @@ -0,0 +1,200 @@ +package mpc + +import ( + "context" + "encoding/json" + "fmt" + "math" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/state" +) + +// Reproduce the reported chain: prices exist, one impossible future forecast +// used to fail the native optimiser and truncate the reserve before cheap hours. +func TestNativePlannerRetainsPricesAndHorizonAfterBadForecast(t *testing.T) { + for _, fault := range []string{"primary", "legacy", "both", "legacy only", "nan load", "infinite load", "negative load", "both nan", "huge load", "risk nan load", "risk load", "risk energy"} { + t.Run(fault, func(t *testing.T) { + svc := shadowTestService(t) + worker := nativeWorker(t, 500*time.Millisecond) + t.Cleanup(func() { svc.replanWG.Wait(); svc.shadowWG.Wait(); _ = worker.Close() }) + svc.Optimizer = &EnergyplanOptimizer{ExternalOptimizer: worker} + start := time.Now().UTC().Truncate(time.Hour) + svc.now = func() time.Time { return start.Add(5 * time.Minute) } + svc.FuseMaxW, svc.BaseLoad, svc.PVForecastSafetyK = 17250, 800, 1 + svc.Defaults.Mode = ModeArbitrage + svc.Defaults.MaxChargeW, svc.Defaults.MaxDischargeW = 0, 0 + svc.Loadpoint = func(int) *LoadpointSpec { + return &LoadpointSpec{ID: "car", CapacityWh: 10000, InitialSoC: .2, SoCMax: 1, PluggedIn: true, + TargetSoC: .8, TargetSlotIdx: 3, Levels: 11, MaxChargeW: 11000, MinChargeW: 4140, + AllowedStepsW: []float64{0, 4140, 6900, 11000}, ChargeEfficiency: .9, NoBatteryToEV: true} + } + for i := 0; i < 4; i++ { + price := 450.0 + if i >= 2 { + price = 50 + } + if err := svc.Store.SavePrices([]state.PricePoint{{Zone: "SE3", SlotTsMs: start.Add(time.Duration(i) * time.Hour).UnixMilli(), SlotLenMin: 60, SpotOreKwh: price, TotalOreKwh: price, Source: "test", FetchedAtMs: start.UnixMilli()}}); err != nil { + t.Fatal(err) + } + } + var archivedBase, archivedPlanning []Slot + svc.ForecastSnapshot = func(time.Time, []state.ForecastPoint) ForecastInputs { + return ForecastInputs{ + Load: func(at time.Time) float64 { + if at.Equal(start.Add(2*time.Hour)) && (fault == "legacy" || fault == "both" || fault == "legacy only") { + return 55700 + } + if fault == "both nan" { + return math.NaN() + } + return 1800 + }, + Resolve: func(_ context.Context, slots []Slot) []Slot { + if fault == "legacy only" { + return slots + } + for i := range slots { + slots[i].LoadW = 1700 + } + switch fault { + case "primary", "both": + slots[2].LoadW = 55700 + case "legacy": + slots[2].LoadW = 1800 // this primary is healthy + case "negative load": + slots[2].LoadW = -1000 + case "huge load": + slots[2].LoadW = math.MaxFloat64 + case "nan load", "both nan": + slots[2].LoadW = math.NaN() + case "infinite load": + slots[2].LoadW = math.Inf(1) + } + return slots + }, + Risk: func(_ []Slot, slots []Slot, _ float64) { + switch fault { + case "risk nan load": + slots[2].LoadW = math.NaN() + case "risk load": + slots[2].LoadW = 55700 + case "risk energy": + for i := range slots { + slots[i].LoadW = 18000 + } + case "risk nan pv": + slots[0].PVW = math.NaN() + } + }, + Record: func(base, planning []Slot, _ string, _ int64) { + archivedBase = append([]Slot(nil), base...) + archivedPlanning = append([]Slot(nil), planning...) + }, + } + } + plan := svc.Replan(context.Background()) + if plan == nil || plan.Solver == nil || plan.Solver.Engine != "native" || plan.Solver.Fallback || len(plan.Actions) != 4 { + t.Fatalf("forecast failure disabled native price planning: solver=%+v status=%+v", plan.Solver, svc.PlanSnapshot()) + } + if plan.Actions[0].LoadpointW > 1e-6 || plan.Actions[1].LoadpointW > 1e-6 || math.Abs(plan.Actions[3].LoadpointSoC-.8) > 1e-5 { + t.Fatalf("lost cheap charging or departure goal: %+v", plan.Actions) + } + var request externalRequest + if err := json.Unmarshal(plan.OptimizerInput, &request); err != nil { + t.Fatal(err) + } + for i, slot := range svc.lastSlots { + if slot.Limits.MaxImportW != 17250 || slot.StartMs != start.Add(time.Duration(i)*time.Hour).UnixMilli() || slot.PriceOre != request.Slots[i].PriceOre { + t.Fatalf("forecast recovery changed market or limits: %+v", slot) + } + if slot.LoadW > 1800 || !finite(slot.LoadW) || slot.LoadW != archivedPlanning[i].LoadW || archivedBase[i].LoadW > 1800 { + t.Fatalf("bad forecast reached native solver/archive: %+v", slot) + } + } + if err := ValidatePlan(svc.lastSlots, svc.lastParams, plan); err != nil { + t.Fatal(err) + } + if fault == "legacy" { + if len(svc.PlanSnapshot().Warnings) != 0 { + t.Fatal("healthy primary inherited rejected legacy values") + } + } else { + failure := svc.PlanningFailureSnapshot() + if failure == nil || failure.RecoveredDecisionID != plan.DecisionID || !svc.PlanningFailureStorageSnapshot().Durable { + t.Fatalf("rejected raw forecast was not retained durably: %+v", failure) + } + if len(svc.PlanSnapshot().Warnings) == 0 || strings.Contains(strings.Join(svc.PlanSnapshot().Warnings, " "), "Plan ends at") { + t.Fatal("forecast recovery was hidden or reported as a shortened plan") + } + } + }) + } +} + +func TestForecastRecoveryCannotChangeMeasuredOverload(t *testing.T) { + slots, p := recoveryFixture() + slots[0].LoadW = 55700 + prior := priorForecast(slots, 800, true) + 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) + if err == nil || len(used) != 0 || len(plan.Actions) != 0 { + t.Fatal("forecast prior hid a real current overload") + } +} + +func TestForecastRecoveryKeepsValidBatteryAndPVPeaks(t *testing.T) { + for _, kind := range []string{"stored energy", "advance charging", "PV"} { + t.Run(kind, func(t *testing.T) { + slots, p := recoveryFixture() + p.Loadpoint = nil + switch kind { + case "stored energy": + slots[12].LoadW = 19000 + p.Mode = ModeSelfConsumption + case "advance charging": + slots[12].LoadW = 19000 + p.InitialSoC = p.SoCMin + case "PV": + slots[12].LoadW = 30000 + slots[12].PVW = -16000 + p.Mode = ModeSelfConsumption + } + plan, used, err, warning := corePlanWithForecastRecovery(context.Background(), slots, slots, p, 800, false, time.Now) + if err != nil || warning != "" || len(used) != len(slots) || used[12].LoadW != slots[12].LoadW { + t.Fatalf("valid peak was replaced: %v %s", err, warning) + } + assertRecoveryPlan(t, used, p, plan) + }) + } +} + +func TestForecastRecoveryOnlyHandlesStructuredInfeasibility(t *testing.T) { + for _, code := range []string{"infeasible", "invalid_request", "numerical_failure", "deadline_exceeded", "cancelled"} { + err := &optimizerFailureError{cause: &externalError{Code: code, Message: "infeasible"}} + if forecastInfeasible(err) != (code == "infeasible") { + t.Fatal("error message changed retry policy:", code) + } + } + if forecastInfeasible(fmt.Errorf("optimizer infeasible: untrusted text")) || forecastInfeasible(context.DeadlineExceeded) { + t.Fatal("text/timeout was treated as a forecast verdict") + } + slots, _ := recoveryFixture() + base := append([]Slot(nil), slots...) + slots[2].LoadW = 55700 + attempts := forecastRecoveryAttempts(slots, base, 800, false) + if len(attempts) != 2 { + t.Fatalf("unbounded or lost recovery attempts: %d", len(attempts)) + } + for _, attempt := range attempts { + for i, slot := range attempt.slots { + if slot.StartMs != slots[i].StartMs || slot.LenMin != slots[i].LenMin || slot.PriceOre != slots[i].PriceOre || slot.SpotOre != slots[i].SpotOre || slot.Limits != slots[i].Limits { + t.Fatal("recovery changed market, timeline or limits") + } + } + } +} diff --git a/go/internal/mpc/params_validation_test.go b/go/internal/mpc/params_validation_test.go index c95afa2a..7ee03ecd 100644 --- a/go/internal/mpc/params_validation_test.go +++ b/go/internal/mpc/params_validation_test.go @@ -500,12 +500,6 @@ func TestReplanRejectsInvalidPhysicsBeforeSolver(t *testing.T) { {"negative export limit", func(s *Service) { s.FuseMaxW, s.MaxExportW = 11000, -1 }}, {"nan export limit", func(s *Service) { s.FuseMaxW, s.MaxExportW = 11000, math.NaN() }}, {"infinite export limit", func(s *Service) { s.FuseMaxW, s.MaxExportW = 11000, math.Inf(1) }}, - {"nan pv uncertainty", func(s *Service) { - s.PVUncertaintyW = func() float64 { return math.NaN() } - }}, - {"negative pv uncertainty", func(s *Service) { - s.PVUncertaintyW = func() float64 { return -1 } - }}, {"invalid plugged loadpoint", func(s *Service) { s.Loadpoints = func(int) []*LoadpointSpec { return []*LoadpointSpec{{ID: "garage", PluggedIn: true, CapacityWh: 0, Levels: 1}} diff --git a/go/internal/mpc/reserve_price.go b/go/internal/mpc/reserve_price.go new file mode 100644 index 00000000..faf841c0 --- /dev/null +++ b/go/internal/mpc/reserve_price.go @@ -0,0 +1,117 @@ +package mpc + +import ( + "cmp" + "context" + "math" + "slices" +) + +// Price the reserve's EV energy without changing its battery trajectory or +// delivered energy. Each slot offers a legal on-power for part of the interval. +// This is a bounded improvement to a feasible reserve, not a global optimum. +func priceReserveCharging(ctx context.Context, slots []Slot, p Params, reserve Plan) (Plan, error) { + loads := p.activeLoadpoints() + // This pass prices energy only; retain the reserve when a demand tariff + // couples costs across slots. The native planner owns those tariff costs. + if len(loads) != 1 || len(p.DemandCharges) > 0 { + return reserve, nil + } + lp := loads[0] + eff := lp.ChargeEfficiency + if eff <= 0 { + eff = .9 + } + energyWh := 0.0 + for i, a := range reserve.Actions { + energyWh += a.LoadpointW * slots[i].DurationHours() * eff + } + if energyWh <= 1e-6 { + return reserve, nil + } + type offer struct { + index int + peakW, unitCost float64 + } + offers := make([]offer, 0, len(slots)) + steps := lp.normalizedSteps() + for i, slot := range slots { + if err := ctx.Err(); err != nil { + return Plan{}, err + } + if i > lp.TargetSlotIdx { + break + } + baseGrid := slot.LoadW + slot.PVW + reserve.Actions[i].BatteryW + for j := len(steps) - 1; j > 0; j-- { + peak := steps[j] + // A half-slot probe checks both the idle and charging endpoints. + probe := Action{ + BatteryW: reserve.Actions[i].BatteryW, GridW: baseGrid + peak/2, + LoadpointW: peak / 2, + LoadpointPowerW: map[string]float64{lp.ID: peak / 2}, + LoadpointMaxPowerW: map[string]float64{lp.ID: peak}, + } + if validateEVPulse(slot, p, probe, slot.PVW) != nil { + continue + } + hours := slot.DurationHours() + offCost := SlotGridCostOre(slot, baseGrid*hours/1000, p) + onCost := SlotGridCostOre(slot, (baseGrid+peak)*hours/1000, p) + offers = append(offers, offer{i, peak, (onCost - offCost) / (peak * hours * eff)}) + break + } + } + slices.SortStableFunc(offers, func(a, b offer) int { return cmp.Compare(a.unitCost, b.unitCost) }) + priced := reserve + priced.Actions = append([]Action(nil), reserve.Actions...) + for i := range priced.Actions { + a := &priced.Actions[i] + a.LoadpointW = 0 + a.LoadpointPowerW = map[string]float64{lp.ID: 0} + a.LoadpointMaxPowerW = map[string]float64{lp.ID: 0} + a.LoadpointSoCByID = map[string]float64{lp.ID: lp.InitialSoC} + } + remaining := energyWh + for _, o := range offers { + if err := ctx.Err(); err != nil { + return Plan{}, err + } + if remaining <= 1e-6 { + break + } + hours := slots[o.index].DurationHours() + average := min(o.peakW, remaining/(eff*hours)) + a := &priced.Actions[o.index] + a.LoadpointW = average + a.LoadpointPowerW[lp.ID] = average + a.LoadpointMaxPowerW[lp.ID] = o.peakW + remaining -= average * eff * hours + } + if remaining > 1e-6 { + return reserve, nil + } + evEnergy := lp.InitialSoC * lp.CapacityWh + priced.TotalCostOre = 0 + for i := range priced.Actions { + a := &priced.Actions[i] + evEnergy += a.LoadpointW * slots[i].DurationHours() * eff + a.LoadpointSoC = evEnergy / lp.CapacityWh + a.LoadpointSoCByID[lp.ID] = a.LoadpointSoC + a.GridW = slots[i].LoadW + slots[i].PVW + a.BatteryW + a.LoadpointW + a.CostOre = reserveSlotCost(slots[i], p, *a) + priced.TotalCostOre += a.CostOre + } + // Price improvements never trade away the reserve's physical guarantees. + // Keep its independently valid plan if allocation or replay cannot match it. + if math.Abs(evEnergy-lp.InitialSoC*lp.CapacityWh-energyWh) > 1e-6 || + priced.TotalCostOre >= reserve.TotalCostOre-1e-6 || ValidatePlan(slots, p, &priced) != nil { + return reserve, nil + } + if reserve.Solver != nil { + info := *reserve.Solver + info.PolicyVersion = "core-reserve-prices-v1" + priced.Solver = &info + } + return priced, nil +} diff --git a/go/internal/mpc/reserve_price_test.go b/go/internal/mpc/reserve_price_test.go new file mode 100644 index 00000000..8a108529 --- /dev/null +++ b/go/internal/mpc/reserve_price_test.go @@ -0,0 +1,182 @@ +package mpc + +import ( + "context" + "encoding/json" + "math" + "strings" + "testing" + "time" +) + +func reservePriceFailureFixture() ([]Slot, Params) { + slots, p := recoveryFixture() + p.Loadpoint.CapacityWh = 10000 + p.Loadpoint.InitialSoC = .2 + p.Loadpoint.TargetSoC = .8 + p.Loadpoint.TargetSlotIdx = 16 // The actual departure stays beyond the safe prefix. + for i := range slots { + slots[i].PriceOre, slots[i].SpotOre = 450, 450 + if i >= 4 { + slots[i].PriceOre, slots[i].SpotOre = 50, 50 + } + } + slots[8].LoadW = 55700 + return slots, p +} + +func TestCoreRecoveryReserveUsesCheaperSlotsBeforeShortenedHorizon(t *testing.T) { + slots, p := reservePriceFailureFixture() + plan, used, err := corePlanWithRecovery(context.Background(), slots, p) + if err != nil { + t.Fatal(err) + } + if len(used) != 8 || len(plan.Actions) != 8 { + t.Fatalf("wrong safe prefix: %d slots, %d actions", len(used), len(plan.Actions)) + } + if err := ValidatePlan(used, p, &plan); err != nil { + t.Fatal(err) + } + for i, a := range plan.Actions[:4] { + if a.LoadpointW > 1e-6 { + t.Fatalf("slot %d charges at 4.50 SEK/kWh despite enough later capacity at 0.50: %.3f W", i, a.LoadpointW) + } + } + if last := plan.Actions[len(plan.Actions)-1].LoadpointSoC; math.Abs(last-.8) > 1e-9 { + t.Fatalf("moving to cheaper slots lost the exact EV target: %.12f", last) + } + if len(plan.LoadpointShortfallWh) != 0 || p.Loadpoint.TargetSlotIdx != 16 { + t.Fatal("the shortened plan moved the actual departure or reported it missed") + } +} + +func TestNativeFailureRecoveryKeepsPricesBeforeImpossibleSlot(t *testing.T) { + worker := nativeWorker(t, time.Second) + t.Cleanup(func() { _ = worker.Close() }) + slots, p := reservePriceFailureFixture() + if _, err := worker.Optimize(context.Background(), slots, p); err == nil || !strings.Contains(err.Error(), "infeasible") { + t.Fatalf("bad future forecast did not reproduce native infeasibility with valid prices: %v", err) + } + plan, used, err := corePlanWithRecovery(context.Background(), slots, p) + if err != nil { + t.Fatal(err) + } + if len(used) != 8 || plan.Actions[0].LoadpointW != 0 { + t.Fatalf("native failure lost the safe cheaper reserve: horizon=%d first=%+v", len(used), plan.Actions[0]) + } + if err := ValidatePlan(used, p, &plan); err != nil { + t.Fatal(err) + } +} + +func TestReservePriceChoiceRetainsDepartureAndPhysicalLimits(t *testing.T) { + for _, tc := range []struct { + name string + change func([]Slot, *Params) + wantFirstW float64 + wantShortfall float64 + }{ + {"cheap later", func(_ []Slot, _ *Params) {}, 0, 0}, + {"departure now", func(_ []Slot, p *Params) { p.Loadpoint.TargetSlotIdx = 0 }, 2000, 4000}, + {"cheap slot blocked by fuse", func(slots []Slot, _ *Params) { slots[1].LoadW = 3000 }, 2000, 0}, + {"average cannot hide illegal on-power", func(slots []Slot, _ *Params) { slots[1].Limits.MaxImportW = 1500 }, 2000, 0}, + {"partial current interval needed", func(slots []Slot, p *Params) { + slots[0].ExecutionStartMs = slots[0].StartMs + 50*60000 + p.Loadpoint.TargetSoC = .82 + }, 1200, 0}, + {"negative price without extra energy", func(slots []Slot, p *Params) { + p.Loadpoint.TargetSoC = .4 + slots[1].PriceOre, slots[1].SpotOre = -50, -50 + }, 0, 0}, + {"solar-only keeps its source", func(slots []Slot, p *Params) { + p.Loadpoint.TargetSoC = .4 + p.Loadpoint.SurplusOnly = true + slots[0].PVW = -2500 + }, 2000, 0}, + {"PV opportunity cost beats later import", func(slots []Slot, p *Params) { + p.Loadpoint.TargetSoC = .4 + slots[0].PVW, slots[0].SpotOre = -2500, 30 + }, 2000, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + slots, p := recoveryFixture() + slots = slots[:4] + for i := range slots { + slots[i].StartMs = slots[0].StartMs + int64(i)*3600000 + slots[i].LenMin, slots[i].LoadW = 60, 500 + slots[i].PriceOre, slots[i].SpotOre = 50, 50 + slots[i].Limits = PowerLimits{MaxImportW: 3000, MaxExportW: 3000} + } + slots[0].PriceOre, slots[0].SpotOre = 450, 450 + p.MaxChargeW, p.MaxDischargeW = 0, 0 + p.Loadpoint.CapacityWh = 10000 + p.Loadpoint.ChargeEfficiency = 1 + p.Loadpoint.InitialSoC, p.Loadpoint.TargetSoC, p.Loadpoint.SoCMax = .2, .8, 1 + p.Loadpoint.TargetSlotIdx = 3 + p.Loadpoint.MinChargeW, p.Loadpoint.MaxChargeW = 2000, 2000 + p.Loadpoint.AllowedStepsW = []float64{0, 2000} + tc.change(slots, &p) + before, _ := json.Marshal(p) + plan := mustCoreReservePlan(t, slots, p) + if err := ValidatePlan(slots, p, &plan); err != nil { + t.Fatal(err) + } + if math.Abs(plan.Actions[0].LoadpointW-tc.wantFirstW) > 1e-6 { + t.Fatalf("first EV action %.6f W, want %.6f W", plan.Actions[0].LoadpointW, tc.wantFirstW) + } + if tc.name == "cheap later" && math.Abs(plan.TotalCostOre-600) > 1e-6 { + // House: 0.5 kWh at 4.50 plus 1.5 kWh at 0.50 = 3 SEK. + // Car: 6 kWh at 0.50 = 3 SEK. The old early charge costs 14 SEK total. + t.Fatalf("reserve cost %.6f ore, want 600", plan.TotalCostOre) + } + if math.Abs(plan.LoadpointShortfallWh[p.Loadpoint.ID]-tc.wantShortfall) > 1e-6 { + t.Fatalf("real departure shortfall changed: %+v", plan.LoadpointShortfallWh) + } + goal := p.Loadpoint.TargetSoC*p.Loadpoint.CapacityWh - tc.wantShortfall + atDeparture := plan.Actions[p.Loadpoint.TargetSlotIdx].LoadpointSoC * p.Loadpoint.CapacityWh + if math.Abs(atDeparture-goal) > 1e-6 { + t.Fatalf("wrong energy at departure: %.6f, want %.6f Wh", atDeparture, goal) + } + for i, a := range plan.Actions { + if a.BatteryW != 0 || a.SoC != p.InitialSoC || (i > p.Loadpoint.TargetSlotIdx && a.LoadpointW != 0) { + t.Fatalf("price choice changed battery trajectory or charged after departure: %+v", a) + } + } + after, _ := json.Marshal(p) + if string(before) != string(after) { + t.Fatal("reserve pricing changed the stored planning goals") + } + }) + } +} + +func TestReservePriceChoiceDoesNotCreateDemandPeak(t *testing.T) { + slots, p := recoveryFixture() + slots = slots[:2] + for i := range slots { + slots[i].StartMs = slots[0].StartMs + int64(i)*3600000 + slots[i].LenMin = 60 + slots[i].Limits = PowerLimits{MaxImportW: 10000, MaxExportW: 10000} + } + slots[0].LoadW, slots[0].PriceOre, slots[0].SpotOre = 3000, 450, 450 + slots[1].LoadW, slots[1].PriceOre, slots[1].SpotOre = 5000, 400, 400 + p.MaxChargeW, p.MaxDischargeW = 0, 0 + p.Loadpoint.CapacityWh, p.Loadpoint.ChargeEfficiency = 10000, 1 + p.Loadpoint.InitialSoC, p.Loadpoint.TargetSoC, p.Loadpoint.SoCMax = .2, .4, 1 + p.Loadpoint.TargetSlotIdx = 1 + p.Loadpoint.MinChargeW, p.Loadpoint.MaxChargeW = 2000, 2000 + p.Loadpoint.AllowedStepsW = []float64{0, 2000} + p.DemandCharges = []DemandCharge{{ID: "monthly", PricePerKW: 10000, TopN: 1, AlreadyKW: []float64{5}, Hours: []DemandHour{ + {StartMs: slots[0].StartMs, EndMs: slots[1].StartMs}, + {StartMs: slots[1].StartMs, EndMs: slots[1].StartMs + 3600000}, + }}} + plan := mustCoreReservePlan(t, slots, p) + if err := ValidatePlan(slots, p, &plan); err != nil { + t.Fatal(err) + } + // Moving 2 kWh saves 1 SEK in energy but raises the monthly peak from + // 5 to 7 kW, adding 200 SEK. The energy-only pass must retain the reserve. + if plan.Actions[0].LoadpointW != 2000 || plan.Actions[1].LoadpointW != 0 || plan.Actions[1].GridW > 5000 { + t.Fatalf("energy repricing created an avoidable demand peak: %+v", plan.Actions) + } +} diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index e0c89b66..f8ddf377 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -1586,7 +1586,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { slots = buildSlots(prices, forecasts, baseLoad, now.UnixMilli(), pv, correct, load, captured.PVWeight) // Resolve receives the complete legacy forecast, so its frozen shadow // matches what the previous pipeline would have used. - sanitizeSlotLoads(slots) + legacySlots := append([]Slot(nil), slots...) if captured.Resolve != nil && len(slots) > 0 { if request.wasCanceledByService() { return s.canceledReplan(request, "forecast-start") @@ -1610,12 +1610,17 @@ func (s *Service) runReplan(request replanRequest) *Plan { slots[i].PVW, slots[i].LoadW = resolved[i].PVW, resolved[i].LoadW } } - sanitizeSlotLoads(slots) - forecastInputs = slots - { - loadW, ok := s.liveHouseLoadW() - overlayLiveHouseLoad(slots, loadW, ok) + forecastInputs = append([]Slot(nil), slots...) + liveLoadW, liveLoadOK := s.liveHouseLoadW() + liveLoadOK = liveLoadOK && finite(liveLoadW) && liveLoadW >= 0 + overlayLiveHouseLoad(slots, liveLoadW, liveLoadOK) + var forecastWarnings []string + admitted, forecastErr := recoverInvalidForecastLoads(slots, legacySlots, baseLoad) + if forecastErr != nil { + s.recordPlanningFailure(request, "forecast_inputs", forecastErr.Error(), slots, p, nil) + forecastWarnings = append(forecastWarnings, forecastErr.Error()) } + slots = admitted // Qualified load models own their level. Unqualified historic daily // totals must not impose a floor on a changed or low-load household. if len(slots) == 0 { @@ -1631,9 +1636,8 @@ func (s *Service) runReplan(request replanRequest) *Plan { slog.Error("mpc: invalid current planning input; keeping previous plan", "err", inputErr) return fail("inputs", inputErr.Error()) } - // Keep the point forecast and a downside-PV copy. The Core DP and the - // Energyplan worker both plan against the downside copy; the point - // forecast stays for diagnostics and forecast calibration. + // Keep point and caution inputs separate. Both planners retain a valid + // point plan before trying caution; calibration still uses point errors. fallbackSlots := append([]Slot(nil), slots...) var pvUncertaintyW, pvRelativeUncertainty float64 pvUncertainty := s.PVUncertaintyW @@ -1722,10 +1726,23 @@ func (s *Service) runReplan(request replanRequest) *Plan { if pvRelative != nil || s.ForecastSnapshot != nil { p.PVRelativeUncertainty = pvRelativeUncertainty } - applyPVDownsidePerSlot(fallbackSlots, p.PVForecastSafetyK, - p.PVRelativeUncertainty, p.PVUncertaintyW) - if captured.Risk != nil { - captured.Risk(slots, fallbackSlots, p.PVForecastSafetyK) + 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()) + p.PVForecastSafetyK, p.PVUncertaintyW, p.PVRelativeUncertainty = 0, 0, 0 + } else { + applyPVDownsidePerSlot(fallbackSlots, p.PVForecastSafetyK, + p.PVRelativeUncertainty, p.PVUncertaintyW) + if captured.Risk != nil { + captured.Risk(slots, fallbackSlots, p.PVForecastSafetyK) + } + } + if recovered, err := recoverInvalidForecastLoads(fallbackSlots, slots, baseLoad); err != nil { + s.recordPlanningFailure(request, "risk_inputs", err.Error(), fallbackSlots, p, nil) + forecastWarnings = append(forecastWarnings, err.Error()) + fallbackSlots = recovered } // Keep the point forecast distinct from the downside inputs passed to the // solver. Calibrating point errors against the risk adjustment biases them. @@ -1789,7 +1806,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { } } } - warnings := limitPlanningLoadpointSteps(&p) + warnings := append(forecastWarnings, limitPlanningLoadpointSteps(&p)...) s.mu.Lock() if request.generation == s.latestReplanGeneration { s.planningWarnings = warnings @@ -1810,21 +1827,23 @@ func (s *Service) runReplan(request replanRequest) *Plan { } } - for _, inputs := range [][]Slot{slots, fallbackSlots} { - prefix, err := planningInputPrefix(inputs) - if err != nil { - s.recordPlanningFailure(request, "risk_inputs", err.Error(), inputs, p, nil) - } - if len(prefix) == 0 { - slog.Error("mpc: invalid current planning input; keeping previous plan", "err", err) - // Keep the rejected risk inputs captured above, rather than replacing - // them with the base forecast selected by the generic failure path. - return s.failedReplan(request, err.Error()) - } - if len(prefix) < len(slots) { - slots, fallbackSlots = slots[:len(prefix)], fallbackSlots[:len(prefix)] - inputErr = err - } + prefix, slotErr := planningInputPrefix(slots) + if slotErr != nil { + s.recordPlanningFailure(request, "forecast_inputs", slotErr.Error(), slots, p, nil) + } + if len(prefix) == 0 { + return s.failedReplan(request, slotErr.Error()) + } + if len(prefix) < len(slots) { + slots, fallbackSlots = slots[:len(prefix)], fallbackSlots[:len(prefix)] + baseForecastSlots = baseForecastSlots[:len(prefix)] + inputErr = slotErr + } + // Optional risk inputs cannot invalidate or shorten a valid point forecast. + if riskErr := validatePlanningSlots(fallbackSlots); riskErr != nil { + 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...) } if err := validatePlanningParams(p); err != nil { slog.Error("mpc: invalid optimization parameters; keeping previous plan", "err", err) @@ -1879,14 +1898,21 @@ func (s *Service) runReplan(request replanRequest) *Plan { slots = fallbackSlots } if coreChampion { - // Core is the planner. It solves the downside-PV slots — forecast - // minus k·σ per slot — so the plan it publishes is the one that does - // not bet on sun that may not arrive. + // Keep a point plan before trying optional forecast caution. slots = fallbackSlots - solverInputs = slots + solverInputs = baseForecastSlots solveStart := time.Now() var err error - plan, slots, err = corePlanWithRecovery(context.WithoutCancel(ctx), slots, p) + 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 { + baseForecastSlots = append([]Slot(nil), slots...) + } + } + fallbackSlots = append([]Slot(nil), slots...) if request.wasCanceledByService() { return s.canceledReplan(request, "core-recovery") } @@ -1896,8 +1922,54 @@ func (s *Service) runReplan(request replanRequest) *Plan { setCoreReserveSolver(&plan, p, msSince(solveStart)) } else { solverInputs = slots - candidate, err := s.Optimizer.Optimize(ctx, slots, p) + var candidate Plan + var err error + if downsidePrimary { + choice, solveErr := planWithForecastMargin(ctx, baseForecastSlots, fallbackSlots, p, s.planningNow, + func(ctx context.Context, inputs []Slot, p Params) (Plan, []Slot, error) { + plan, err := s.Optimizer.Optimize(ctx, inputs, p) + return plan, inputs, err + }) + candidate, slots, err = choice.plan, choice.slots, solveErr + if choice.warning != "" { + s.recordPlanningFailure(request, "forecast_caution", choice.warning, fallbackSlots, p, choice.marginErr) + warnings = append(warnings, choice.warning) + } + if err == nil { + fallbackSlots = append([]Slot(nil), slots...) + } + } else { + candidate, err = s.Optimizer.Optimize(ctx, slots, p) + } + solverInputs = slots optimizerErr = err + if forecastInfeasible(err) && !request.wasCanceledByService() { + // Preserve the original failed exchange even when a simpler forecast + // lets this same optimiser recover. Goals and limits stay unchanged. + s.recordPlanningFailure(request, "primary_optimizer", err.Error(), slots, p, err) + retryCtx, cancelRetry := context.WithTimeout(ctx, forecastRecoveryBudget) + for _, attempt := range forecastRecoveryAttempts(slots, baseForecastSlots, baseLoad, liveLoadOK) { + if retryCtx.Err() != nil { + break + } + recovered, retryErr := s.Optimizer.Optimize(retryCtx, attempt.slots, p) + if request.wasCanceledByService() { + break + } + if retryErr == nil { + candidate, slots, err = recovered, attempt.slots, nil + baseForecastSlots = append([]Slot(nil), attempt.base...) + fallbackSlots = append([]Slot(nil), attempt.slots...) + warnings = append(warnings, attempt.reason) + slog.Warn("mpc: optimiser recovered with a simpler forecast", "reason", attempt.reason) + break + } + if !forecastInfeasible(retryErr) { + break + } + } + cancelRetry() + } if request.wasCanceledByService() { return s.canceledReplan(request, "primary-solve") } @@ -1974,10 +2046,18 @@ func (s *Service) runReplan(request replanRequest) *Plan { s.recordPlanningFailure(request, "primary_optimizer", err.Error(), slots, p, optimizerErr) slog.Error("mpc: primary optimizer failed; using Core DP fallback", "err", err) slots = fallbackSlots - solverInputs = slots + solverInputs = baseForecastSlots solveStart := time.Now() var fallbackErr error - plan, slots, fallbackErr = corePlanWithRecovery(context.WithoutCancel(ctx), slots, p) + 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 { + baseForecastSlots = append([]Slot(nil), slots...) + } + } + fallbackSlots = append([]Slot(nil), slots...) if request.wasCanceledByService() { return s.canceledReplan(request, "core-recovery") } @@ -1996,6 +2076,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { } } } + solverInputs = slots var horizonWarning string if inputErr != nil { horizonWarning = markShortenedPlan(&plan, slots, "Later planning inputs are invalid: "+inputErr.Error()+".")