From 66b7fe7d742e97b611c37c666bbe7c402f041751 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 25 Sep 2026 14:13:21 +0200 Subject: [PATCH] feat(planner): plan with the published prices only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The planner filled every slot after the last published day-ahead price with an hour-of-week climatology from the priceforecast package, marked it confidence 0.6 and blended it toward the horizon mean. The owner decided that only the published prices count: they always reach 11–35 hours ahead, and the nearest hours carry the value. - Delete go/internal/priceforecast, its refit loop and seed CSV import. - mpc: remove PricePredictor, Service.Price, extendPricesWithForecast and Service.GridTariffOreKwh. The plan ends at the last published price; Horizon stays an upper bound. `mpc: replanned` logs prices_until. - Remove Slot/Action.Confidence, the DP's blend toward the mean, the "(predicted)" reason tag, and the field in diagnostics and the Home Assistant payload. horizonMeans becomes horizonMeanPrice; the export mean only fed the blend. - The Energyplan request still requires `confidence` (deny_unknown_fields), so Core sends 1 until the worker drops it (srcfl/energyplan#41). - The pricefc/state config key stays, as with Home Link and Ask why: nothing reads it, and an older release after a rollback uses it. - Web: no predicted zone, bars, badge or legend; the plan brief says "Published prices · Plan until Sat 24:00"; the chart's default view ends where the prices end; help texts say the plan reaches as far as prices are published. Closes #1439. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MuerPFZFG88kgu8sWVHeq7 --- .changeset/published-prices-only.md | 5 + go/cmd/ftw/main.go | 26 +- go/internal/control/ev_site_test.go | 6 +- go/internal/ha/bridge.go | 18 +- go/internal/ha/plan_payload.go | 46 +- go/internal/ha/plan_payload_test.go | 3 +- go/internal/mpc/charging_periods_test.go | 2 +- go/internal/mpc/demand_test.go | 32 +- go/internal/mpc/diagnose.go | 16 +- go/internal/mpc/diagnose_test.go | 36 +- go/internal/mpc/energyplan_test.go | 10 +- go/internal/mpc/ev_reserve.go | 2 +- go/internal/mpc/ev_reserve_test.go | 2 +- go/internal/mpc/execution_time_test.go | 4 +- go/internal/mpc/external_optimizer.go | 6 +- go/internal/mpc/external_optimizer_test.go | 31 +- go/internal/mpc/loadpoint_service_test.go | 79 ++- go/internal/mpc/loadpoint_spec_test.go | 12 +- go/internal/mpc/mpc.go | 124 ++--- go/internal/mpc/mpc_test.go | 45 +- go/internal/mpc/mpc_threshold_test.go | 16 +- go/internal/mpc/native_bench_test.go | 2 +- .../mpc/native_beta4_regression_test.go | 2 +- go/internal/mpc/native_optimizer_test.go | 1 - go/internal/mpc/params_validation.go | 4 - go/internal/mpc/params_validation_test.go | 6 +- go/internal/mpc/parity_fixes_test.go | 44 +- go/internal/mpc/power_limits_test.go | 30 +- go/internal/mpc/reason_test.go | 2 +- go/internal/mpc/replay_bench_test.go | 1 - go/internal/mpc/resolution_test.go | 24 +- .../mpc/self_consumption_horizon_test.go | 1 - go/internal/mpc/service.go | 126 +---- go/internal/mpc/service_test.go | 108 +--- go/internal/mpc/strict_sc_test.go | 14 +- go/internal/mpc/validate_dp_test.go | 13 +- go/internal/priceforecast/forecast.go | 524 ------------------ go/internal/priceforecast/forecast_test.go | 278 ---------- go/internal/state/store.go | 5 + web/diagnose.js | 12 +- web/index.html | 5 +- web/mobile-information-architecture.test.mjs | 2 +- web/plan-brief.js | 33 +- web/plan-brief.test.mjs | 23 +- web/plan.js | 94 +--- web/settings/tabs/planner.js | 2 +- web/style.css | 11 - 47 files changed, 352 insertions(+), 1536 deletions(-) create mode 100644 .changeset/published-prices-only.md delete mode 100644 go/internal/priceforecast/forecast.go delete mode 100644 go/internal/priceforecast/forecast_test.go diff --git a/.changeset/published-prices-only.md b/.changeset/published-prices-only.md new file mode 100644 index 000000000..7f16d200d --- /dev/null +++ b/.changeset/published-prices-only.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +The planner uses the electricity prices that are published, and no longer estimates prices beyond them. The plan reaches as far as the day-ahead prices, which is 11–35 hours: tomorrow's prices usually arrive around 13:00. The Plan view has no "predicted" zone any more. It says how far the plan reaches, for example "Plan until Sat 24:00", and the chart ends there. The Home Assistant plan and price attributes no longer include `confidence`, which was always 1 outside the removed forecast. An EV target later than the last published price is planned within the known prices until the next day's prices arrive. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index e331ff2be..a2b270fe1 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -60,7 +60,6 @@ import ( "github.com/srcfl/ftw/go/internal/notifications" "github.com/srcfl/ftw/go/internal/nova" "github.com/srcfl/ftw/go/internal/ocpp" - "github.com/srcfl/ftw/go/internal/priceforecast" "github.com/srcfl/ftw/go/internal/prices" "github.com/srcfl/ftw/go/internal/proxy" "github.com/srcfl/ftw/go/internal/pvmodel" @@ -930,7 +929,7 @@ func main() { // Forward-declared so the hot-reload closure below can push // capacity changes into the running planner. Assigned at line - // ~450 after all its dependencies (pvSvc, loadSvc, priceFc) are + // ~450 after all its dependencies (pvSvc, loadSvc) are // wired up. nil until that point — the reload closure guards. var mpcSvc *mpc.Service @@ -1131,7 +1130,6 @@ func main() { mpcSvc.ExportBonusOreKwh = newCfg.Price.ExportBonusOreKwh mpcSvc.ExportFeeOreKwh = newCfg.Price.ExportFeeOreKwh mpcSvc.ExportFloorOreKwh = newCfg.Price.ExportFloorOreKwh - mpcSvc.GridTariffOreKwh = newCfg.Price.GridTariffOreKwh mpcSvc.VATPercent = newCfg.Price.VATPercent mpcSvc.DemandPricePerKW = newCfg.Price.DemandPricePerKW mpcSvc.DemandTopN = newCfg.Price.DemandTopN @@ -1268,25 +1266,6 @@ func main() { priceSvc = prices.FromConfig(cfg.Price, st, fxSvc) - // ---- Price forecaster (fills in beyond day-ahead publication) ---- - zones := []string{"SE3"} - if cfg.Price != nil && cfg.Price.Zone != "" { - zones = []string{cfg.Price.Zone} - } - priceFc := priceforecast.NewService(st, zones) - // Optional: seed from bundled CSV on first boot. Idempotent so safe - // to call every boot — no-op once data is already in the store. - seedPath := filepath.Join(filepath.Dir(*configPath), "seed", "prices.csv") - if _, err := os.Stat(seedPath); err == nil { - n, err := priceFc.SeedFromCSV(seedPath) - if err != nil { - slog.Warn("priceforecast seed failed", "path", seedPath, "err", err) - } else if n > 0 { - slog.Info("priceforecast seeded", "rows", n, "path", seedPath) - } - } - priceFc.Start(ctx) - defer priceFc.Stop() if priceSvc != nil { priceSvc.Start(ctx) defer priceSvc.Stop() @@ -1650,7 +1629,6 @@ func main() { mpcSvc.MinArbitrageSpreadOreKwh = cfg.Planner.MinArbitrageSpreadOreKwh } mpcSvc.Load = loadSvc.Predict - mpcSvc.Price = priceFc.Predict mpcSvc.SiteMeter = cfg.SiteMeterDriver() // The mathematical planner co-optimizes every scheduled loadpoint. // The service retains the first entry for its Go-DP emergency fallback. @@ -1837,7 +1815,6 @@ func main() { mpcSvc.ExportBonusOreKwh = cfg.Price.ExportBonusOreKwh mpcSvc.ExportFeeOreKwh = cfg.Price.ExportFeeOreKwh mpcSvc.ExportFloorOreKwh = cfg.Price.ExportFloorOreKwh - mpcSvc.GridTariffOreKwh = cfg.Price.GridTariffOreKwh mpcSvc.VATPercent = cfg.Price.VATPercent mpcSvc.DemandPricePerKW = cfg.Price.DemandPricePerKW mpcSvc.DemandTopN = cfg.Price.DemandTopN @@ -4312,7 +4289,6 @@ func (b mpcPlanBridge) LatestActions() []ha.PlanAction { PriceOre: a.PriceOre, SpotOre: a.SpotOre, CostOre: a.CostOre, - Confidence: a.Confidence, Reason: a.Reason, EMSMode: a.EMSMode, PVW: a.PVW, diff --git a/go/internal/control/ev_site_test.go b/go/internal/control/ev_site_test.go index 353a0cb6f..6a079b0d8 100644 --- a/go/internal/control/ev_site_test.go +++ b/go/internal/control/ev_site_test.go @@ -63,12 +63,10 @@ func TestEVSiteOptimizeThenDispatchChargesEVFromPVBesideBatteryImport(t *testing slots := []mpc.Slot{ { StartMs: slot.UnixMilli(), LenMin: 60, - PriceOre: 20, SpotOre: 10, LoadW: evComboLoadW, PVW: evComboPVW, Confidence: 1, - }, + PriceOre: 20, SpotOre: 10, LoadW: evComboLoadW, PVW: evComboPVW}, { StartMs: slot.Add(time.Hour).UnixMilli(), LenMin: 60, - PriceOre: 300, SpotOre: 240, LoadW: 2500, PVW: 0, Confidence: 1, - }, + PriceOre: 300, SpotOre: 240, LoadW: 2500, PVW: 0}, } params := mpc.Params{ Mode: mpc.ModeArbitrage, diff --git a/go/internal/ha/bridge.go b/go/internal/ha/bridge.go index d3b9e8a79..e7c077876 100644 --- a/go/internal/ha/bridge.go +++ b/go/internal/ha/bridge.go @@ -72,7 +72,6 @@ type PlanAction struct { PriceOre float64 // total consumer price (öre/kWh) SpotOre float64 // raw wholesale spot price (öre/kWh) CostOre float64 // expected cost this slot (öre, negative = revenue) - Confidence float64 // forecast confidence 0–1 Reason string // human-readable DP reason EMSMode string // effective EMS mode the planner chose PVW float64 // planned PV output (site-sign, ≤ 0) @@ -623,7 +622,7 @@ func (b *Bridge) publishDiscovery() { total++ // Current electricity price sensor: state = total consumer price (öre/kWh), - // attrs = spot_ore, cost_ore, confidence, reason, ems_mode. + // attrs = spot_ore, cost_ore, reason, ems_mode. priceMsg := b.withAvail(map[string]any{ "name": "Electricity Price", "unique_id": b.deviceID + "_price_ore", @@ -1151,7 +1150,7 @@ func (b *Bridge) announceVehicleDriver(dev map[string]any, driver string) { // Assistant's recorder keeps it (#1296) // - plan_schedule_json: the full schedule with every field, own topic // - price_ore: current consumer electricity price (öre/kWh) -// - price_json: price attributes (spot_ore, cost_ore, confidence, reason, ems_mode) +// - price_json: price attributes (spot_ore, cost_ore, reason, ems_mode) func (b *Bridge) publishPlan() { actions := b.plan.LatestActions() now := time.Now() @@ -1160,7 +1159,7 @@ func (b *Bridge) publishPlan() { currentAction := snapshot.Action cur := snapshot.Current curPVW, curLoadW := cur.PVW, cur.LoadW - curPriceOre, curSpotOre, curCostOre, curConfidence := cur.PriceOre, cur.SpotOre, cur.CostOre, cur.Confidence + curPriceOre, curSpotOre, curCostOre := cur.PriceOre, cur.SpotOre, cur.CostOre curReason, curEMSMode := cur.Reason, cur.EMSMode b.publishString("plan_action", currentAction) @@ -1172,12 +1171,11 @@ func (b *Bridge) publishPlan() { // Price sensor: standalone value + rich attributes for HA energy dashboard. b.publishValue("price_ore", curPriceOre) priceAttrs := map[string]any{ - "price_ore": curPriceOre, - "spot_ore": curSpotOre, - "cost_ore": curCostOre, - "confidence": curConfidence, - "reason": curReason, - "ems_mode": curEMSMode, + "price_ore": curPriceOre, + "spot_ore": curSpotOre, + "cost_ore": curCostOre, + "reason": curReason, + "ems_mode": curEMSMode, } if d, err := json.Marshal(priceAttrs); err == nil { b.publish(b.stateTopic("price_json"), d, false) diff --git a/go/internal/ha/plan_payload.go b/go/internal/ha/plan_payload.go index 4e2def581..93d4f516d 100644 --- a/go/internal/ha/plan_payload.go +++ b/go/internal/ha/plan_payload.go @@ -20,33 +20,31 @@ const planAttributesBudget = 14 * 1024 // dashboard or automation needs per slot, rounded to one decimal. The full // slot detail goes to plan_schedule_json. type planSlot struct { - Start string `json:"start"` - Action string `json:"action"` - BatteryW float64 `json:"battery_w"` - GridW float64 `json:"grid_w"` - SoCPct float64 `json:"soc_pct"` - PriceOre float64 `json:"price_ore"` - Confidence float64 `json:"confidence"` + Start string `json:"start"` + Action string `json:"action"` + BatteryW float64 `json:"battery_w"` + GridW float64 `json:"grid_w"` + SoCPct float64 `json:"soc_pct"` + PriceOre float64 `json:"price_ore"` } // planScheduleSlot is one entry of plan_schedule_json, the full schedule for // MQTT consumers. It is not attached to a Home Assistant entity because it // can exceed the recorder limit. type planScheduleSlot struct { - Start string `json:"start"` - End string `json:"end"` - Action string `json:"action"` - BatteryW float64 `json:"battery_w"` - GridW float64 `json:"grid_w"` - SoCPct float64 `json:"soc_pct"` - PVW float64 `json:"pv_w,omitempty"` - LoadW float64 `json:"load_w,omitempty"` - PriceOre float64 `json:"price_ore,omitempty"` - SpotOre float64 `json:"spot_ore,omitempty"` - CostOre float64 `json:"cost_ore,omitempty"` - Confidence float64 `json:"confidence,omitempty"` - Reason string `json:"reason,omitempty"` - EMSMode string `json:"ems_mode,omitempty"` + Start string `json:"start"` + End string `json:"end"` + Action string `json:"action"` + BatteryW float64 `json:"battery_w"` + GridW float64 `json:"grid_w"` + SoCPct float64 `json:"soc_pct"` + PVW float64 `json:"pv_w,omitempty"` + LoadW float64 `json:"load_w,omitempty"` + PriceOre float64 `json:"price_ore,omitempty"` + SpotOre float64 `json:"spot_ore,omitempty"` + CostOre float64 `json:"cost_ore,omitempty"` + Reason string `json:"reason,omitempty"` + EMSMode string `json:"ems_mode,omitempty"` } // planSnapshot is everything publishPlan publishes about the plan. @@ -86,12 +84,12 @@ func buildPlanSnapshot(actions []PlanAction, now time.Time) planSnapshot { compact = append(compact, planSlot{ Start: start, Action: label, BatteryW: round1(a.BatteryW), GridW: round1(a.GridW), SoCPct: round1(a.SoCPct), - PriceOre: round1(a.PriceOre), Confidence: round2(a.Confidence), + PriceOre: round1(a.PriceOre), }) full = append(full, planScheduleSlot{ Start: start, End: end, Action: label, BatteryW: a.BatteryW, GridW: a.GridW, SoCPct: a.SoCPct, PVW: a.PVW, LoadW: a.LoadW, - PriceOre: a.PriceOre, SpotOre: a.SpotOre, CostOre: a.CostOre, Confidence: a.Confidence, + PriceOre: a.PriceOre, SpotOre: a.SpotOre, CostOre: a.CostOre, Reason: a.Reason, EMSMode: a.EMSMode, }) } @@ -107,7 +105,6 @@ func buildPlanSnapshot(actions []PlanAction, now time.Time) planSnapshot { "price_ore": cur.PriceOre, "spot_ore": cur.SpotOre, "cost_ore": cur.CostOre, - "confidence": cur.Confidence, "reason": cur.Reason, "ems_mode": cur.EMSMode, } @@ -143,4 +140,3 @@ func marshalPlanAttributes(attrs map[string]any, slots []planSlot, budget int) ( } func round1(v float64) float64 { return math.Round(v*10) / 10 } -func round2(v float64) float64 { return math.Round(v*100) / 100 } diff --git a/go/internal/ha/plan_payload_test.go b/go/internal/ha/plan_payload_test.go index c37c6d1e9..e1945da7f 100644 --- a/go/internal/ha/plan_payload_test.go +++ b/go/internal/ha/plan_payload_test.go @@ -19,7 +19,6 @@ func planFixture(now time.Time, slotMin, slots int) []PlanAction { PriceOre: 123.456789 + float64(i), SpotOre: 80.12345, CostOre: -12.3456, - Confidence: 0.87654321, Reason: "cheap hour: charge before the evening peak while PV output is still available", EMSMode: "planner_arbitrage", PVW: -2000.5, @@ -57,7 +56,7 @@ func TestPlanAttributesStayUnderHomeAssistantLimit(t *testing.T) { t.Fatalf("schedule entries = %d", len(schedule)) } first, _ := schedule[0].(map[string]any) - for _, key := range []string{"start", "action", "battery_w", "grid_w", "soc_pct", "price_ore", "confidence"} { + for _, key := range []string{"start", "action", "battery_w", "grid_w", "soc_pct", "price_ore"} { if _, ok := first[key]; !ok { t.Fatalf("compact slot missing %s: %v", key, first) } diff --git a/go/internal/mpc/charging_periods_test.go b/go/internal/mpc/charging_periods_test.go index 128d058f0..406f874d2 100644 --- a/go/internal/mpc/charging_periods_test.go +++ b/go/internal/mpc/charging_periods_test.go @@ -71,7 +71,7 @@ func TestNativeChargingPeriodsContinueMeasuredRun(t *testing.T) { start := time.Now().Add(time.Minute).Truncate(time.Minute) slots := make([]Slot, 12) for i := range slots { - slots[i] = Slot{StartMs: start.Add(time.Duration(i) * time.Minute).UnixMilli(), LenMin: 1, Confidence: 1, PriceOre: 100, Limits: PowerLimits{MaxImportW: 1000}} + slots[i] = Slot{StartMs: start.Add(time.Duration(i) * time.Minute).UnixMilli(), LenMin: 1, PriceOre: 100, Limits: PowerLimits{MaxImportW: 1000}} } p := Params{Mode: ModeArbitrage, CapacityWh: 10000, SoCMin: .1, SoCMax: .9, InitialSoC: .5, ChargeEfficiency: 1, DischargeEfficiency: 1, Loadpoint: &LoadpointSpec{ID: "car", CapacityWh: 1000, Levels: 11, SoCMax: .1, TargetSoC: .1, TargetSlotIdx: 11, PluggedIn: true, diff --git a/go/internal/mpc/demand_test.go b/go/internal/mpc/demand_test.go index 4adb64319..03232c9a4 100644 --- a/go/internal/mpc/demand_test.go +++ b/go/internal/mpc/demand_test.go @@ -33,11 +33,11 @@ func TestBindDemandChargesExpandsWeekdayHoursAndElapsed(t *testing.T) { now := time.Date(2026, 9, 9, 10, 7, 0, 0, loc) // Wednesday start := time.Date(2026, 9, 9, 10, 0, 0, 0, loc) slots := []Slot{ - {StartMs: start.UnixMilli(), ExecutionStartMs: now.UnixMilli(), LenMin: 15, PriceOre: 100, Confidence: 1}, - {StartMs: start.Add(15 * time.Minute).UnixMilli(), LenMin: 15, PriceOre: 100, Confidence: 1}, - {StartMs: start.Add(30 * time.Minute).UnixMilli(), LenMin: 15, PriceOre: 100, Confidence: 1}, - {StartMs: start.Add(45 * time.Minute).UnixMilli(), LenMin: 15, PriceOre: 100, Confidence: 1}, - {StartMs: start.Add(time.Hour).UnixMilli(), LenMin: 60, PriceOre: 80, Confidence: 1}, + {StartMs: start.UnixMilli(), ExecutionStartMs: now.UnixMilli(), LenMin: 15, PriceOre: 100}, + {StartMs: start.Add(15 * time.Minute).UnixMilli(), LenMin: 15, PriceOre: 100}, + {StartMs: start.Add(30 * time.Minute).UnixMilli(), LenMin: 15, PriceOre: 100}, + {StartMs: start.Add(45 * time.Minute).UnixMilli(), LenMin: 15, PriceOre: 100}, + {StartMs: start.Add(time.Hour).UnixMilli(), LenMin: 60, PriceOre: 80}, } importWh := func(intervals [][2]int64) ([]float64, []int64) { wh := make([]float64, len(intervals)) @@ -91,9 +91,9 @@ func TestBindDemandChargesGroupsHoursByLocalDay(t *testing.T) { } now := time.Date(2026, 9, 9, 6, 0, 0, 0, loc) slots := []Slot{ - {StartMs: now.UnixMilli(), LenMin: 60, PriceOre: 100, Confidence: 1}, - {StartMs: now.Add(time.Hour).UnixMilli(), LenMin: 60, PriceOre: 100, Confidence: 1}, - {StartMs: time.Date(2026, 9, 10, 6, 0, 0, 0, loc).UnixMilli(), LenMin: 60, PriceOre: 100, Confidence: 1}, + {StartMs: now.UnixMilli(), LenMin: 60, PriceOre: 100}, + {StartMs: now.Add(time.Hour).UnixMilli(), LenMin: 60, PriceOre: 100}, + {StartMs: time.Date(2026, 9, 10, 6, 0, 0, 0, loc).UnixMilli(), LenMin: 60, PriceOre: 100}, } got := bindDemandCharges(slots, 7000, 3, 0, 0, loc, now, nil) if len(got) != 1 || len(got[0].Hours) < 3 { @@ -118,9 +118,9 @@ func TestEllevioNightWeightIncludesWeekendsAndHalvesNightHours(t *testing.T) { } now := time.Date(2026, 9, 5, 10, 0, 0, 0, loc) // Saturday slots := []Slot{ - {StartMs: now.UnixMilli(), LenMin: 60, PriceOre: 100, Confidence: 1}, - {StartMs: time.Date(2026, 9, 5, 22, 0, 0, 0, loc).UnixMilli(), LenMin: 60, PriceOre: 80, Confidence: 1}, - {StartMs: time.Date(2026, 9, 5, 23, 0, 0, 0, loc).UnixMilli(), LenMin: 60, PriceOre: 80, Confidence: 1}, + {StartMs: now.UnixMilli(), LenMin: 60, PriceOre: 100}, + {StartMs: time.Date(2026, 9, 5, 22, 0, 0, 0, loc).UnixMilli(), LenMin: 60, PriceOre: 80}, + {StartMs: time.Date(2026, 9, 5, 23, 0, 0, 0, loc).UnixMilli(), LenMin: 60, PriceOre: 80}, } without := bindDemandCharges(slots, 7000, 3, 0, 0, loc, now, nil) if len(without) != 0 { @@ -161,9 +161,9 @@ func TestBindDemandChargesSkipsUnalignedCurrentHour(t *testing.T) { now := time.Date(2026, 9, 9, 10, 17, 0, 0, loc) start := time.Date(2026, 9, 9, 10, 15, 0, 0, loc) slots := []Slot{ - {StartMs: start.UnixMilli(), ExecutionStartMs: now.UnixMilli(), LenMin: 15, Confidence: 1}, - {StartMs: start.Add(15 * time.Minute).UnixMilli(), LenMin: 45, Confidence: 1}, - {StartMs: start.Add(time.Hour).UnixMilli(), LenMin: 60, Confidence: 1}, + {StartMs: start.UnixMilli(), ExecutionStartMs: now.UnixMilli(), LenMin: 15}, + {StartMs: start.Add(15 * time.Minute).UnixMilli(), LenMin: 45}, + {StartMs: start.Add(time.Hour).UnixMilli(), LenMin: 60}, } got := bindDemandCharges(slots, 7000, 1, 0, 0, loc, now, nil) if len(got) != 1 || len(got[0].Hours) == 0 { @@ -203,9 +203,9 @@ func TestNativeDemandChargesOnWeekdayHour(t *testing.T) { defer o.Close() start := time.Date(2026, 9, 9, 10, 0, 0, 0, time.UTC) slots := []Slot{ - {StartMs: start.UnixMilli(), LenMin: 60, PriceOre: 100, SpotOre: 50, Confidence: 1, LoadW: 500, + {StartMs: start.UnixMilli(), LenMin: 60, PriceOre: 100, SpotOre: 50, LoadW: 500, Limits: PowerLimits{MaxImportW: 8000, MaxExportW: 8000}}, - {StartMs: start.Add(time.Hour).UnixMilli(), LenMin: 60, PriceOre: 300, SpotOre: 240, Confidence: 1, LoadW: 500, + {StartMs: start.Add(time.Hour).UnixMilli(), LenMin: 60, PriceOre: 300, SpotOre: 240, LoadW: 500, Limits: PowerLimits{MaxImportW: 8000, MaxExportW: 8000}}, } p := Params{ diff --git a/go/internal/mpc/diagnose.go b/go/internal/mpc/diagnose.go index 25e1e7791..d1653d02f 100644 --- a/go/internal/mpc/diagnose.go +++ b/go/internal/mpc/diagnose.go @@ -19,11 +19,10 @@ type DiagnosticSlot struct { LenMin int `json:"len_min"` // Inputs - PriceOre float64 `json:"price_ore"` // consumer total (spot + tariff + VAT) - SpotOre float64 `json:"spot_ore"` // raw spot — used for export revenue - Confidence float64 `json:"confidence"` // 1.0 = day-ahead, 0.6 = forecast - PVW float64 `json:"pv_w"` // site-signed (≤ 0 when producing) - LoadW float64 `json:"load_w"` + PriceOre float64 `json:"price_ore"` // consumer total (spot + tariff + VAT) + SpotOre float64 `json:"spot_ore"` // raw spot — used for export revenue + PVW float64 `json:"pv_w"` // site-signed (≤ 0 when producing) + LoadW float64 `json:"load_w"` // Source and local availability time for the rows consulted to build this // slot. Synthetic price rows use their creation time. WeatherRow is not full @@ -130,9 +129,7 @@ type Diagnostic struct { // The shape matches what the UI renders in the planner inspector so // operators can audit each slot: "what did the DP see, what did it // decide, and why". The per-slot `Reason` string already explains the -// decision class; the adjacent inputs show whether the decision was -// grounded in a real day-ahead price (`confidence == 1.0`) or a -// forecasted one (`confidence == 0.6`). +// decision class; the adjacent inputs show what it was based on. func (s *Service) Diagnose() *Diagnostic { if s == nil { return nil @@ -178,7 +175,6 @@ func buildDiagnostic(plan *Plan, slots []Slot, p Params, zone string, ExecutionStartMs: slot.ExecutionStartMs, PriceOre: slot.PriceOre, SpotOre: slot.SpotOre, - Confidence: slot.Confidence, PVW: slot.PVW, LoadW: slot.LoadW, PriceInputSource: slot.PriceInputSource, @@ -391,7 +387,6 @@ func planFromDiagnostic(d *Diagnostic) (*Plan, []Slot, Params, time.Time, bool) SpotOre: ds.SpotOre, PVW: ds.PVW, LoadW: ds.LoadW, - Confidence: ds.Confidence, InputProvenanceSchema: d.InputProvenanceSchema, PriceInputSource: ds.PriceInputSource, PriceInputAvailableAtMs: ds.PriceInputAvailableAtMs, @@ -410,7 +405,6 @@ func planFromDiagnostic(d *Diagnostic) (*Plan, []Slot, Params, time.Time, bool) GridW: ds.GridW, SoC: ds.SoC, CostOre: ds.CostOre, - Confidence: ds.Confidence, Reason: ds.Reason, EMSMode: ds.EMSMode, PVLimitW: ds.PVLimitW, diff --git a/go/internal/mpc/diagnose_test.go b/go/internal/mpc/diagnose_test.go index ca92a7b49..63a2f7c47 100644 --- a/go/internal/mpc/diagnose_test.go +++ b/go/internal/mpc/diagnose_test.go @@ -19,7 +19,7 @@ func TestDiagnoseNilBeforeReplan(t *testing.T) { // TestDiagnoseJoinsSlotsAndActions is the core contract: the per-slot // output row must carry BOTH the input context the DP saw (price, PV, -// load, confidence) and the decision it made (battery, grid, SoC, +// load, provenance) and the decision it made (battery, grid, SoC, // reason). Without the join, operators can't audit decisions. func TestDiagnoseJoinsSlotsAndActions(t *testing.T) { start := time.Date(2026, 4, 17, 12, 0, 0, 0, time.UTC).UnixMilli() @@ -28,14 +28,13 @@ func TestDiagnoseJoinsSlotsAndActions(t *testing.T) { // we're testing the join shape. slots := []Slot{ {StartMs: start, LenMin: 15, PriceOre: 100, SpotOre: 50, - PVW: -200, LoadW: 400, Confidence: 1.0, - InputProvenanceSchema: inputProvenanceSchemaVersion, + PVW: -200, LoadW: 400, InputProvenanceSchema: inputProvenanceSchemaVersion, PriceInputSource: "entsoe", PriceInputAvailableAtMs: 111, WeatherRowSource: "met.no", WeatherRowAvailableAtMs: 222}, {StartMs: start + 15*60*1000, LenMin: 15, PriceOre: 150, - SpotOre: 80, PVW: -100, LoadW: 500, Confidence: 0.6, + SpotOre: 80, PVW: -100, LoadW: 500, InputProvenanceSchema: inputProvenanceSchemaVersion, - PriceInputSource: "forecast", PriceInputAvailableAtMs: 333, + PriceInputSource: "elprisetjustnu", PriceInputAvailableAtMs: 333, WeatherRowSource: "open-meteo", WeatherRowAvailableAtMs: 444}, } p := Params{ @@ -93,9 +92,6 @@ func TestDiagnoseJoinsSlotsAndActions(t *testing.T) { if row.SpotOre != 50 { t.Errorf("row0 SpotOre: got %.1f want 50", row.SpotOre) } - if row.Confidence != 1.0 { - t.Errorf("row0 Confidence: got %.2f want 1.0", row.Confidence) - } if row.PVW != -200 { t.Errorf("row0 PVW: got %.1f want -200", row.PVW) } @@ -112,11 +108,7 @@ func TestDiagnoseJoinsSlotsAndActions(t *testing.T) { if row.Reason == "" { t.Error("row0 Reason should be populated by the DP") } - // Row 1 should carry the forecast confidence. - if d.Slots[1].Confidence != 0.6 { - t.Errorf("row1 Confidence: got %.2f want 0.6", d.Slots[1].Confidence) - } - if row := d.Slots[1]; row.PriceInputSource != "forecast" || row.PriceInputAvailableAtMs != 333 || + if row := d.Slots[1]; row.PriceInputSource != "elprisetjustnu" || row.PriceInputAvailableAtMs != 333 || row.WeatherRowSource != "open-meteo" || row.WeatherRowAvailableAtMs != 444 { t.Errorf("row1 input provenance: %+v", row) } @@ -166,8 +158,8 @@ func TestDiagnosticProvenanceSchemaMarksCurrentMissingRows(t *testing.T) { // into lastSlots in service code paths that could diverge). func TestDiagnoseHandlesLengthMismatch(t *testing.T) { slots := []Slot{ - {StartMs: 1000, LenMin: 15, PriceOre: 100, Confidence: 1.0}, - {StartMs: 2000, LenMin: 15, PriceOre: 110, Confidence: 1.0}, + {StartMs: 1000, LenMin: 15, PriceOre: 100}, + {StartMs: 2000, LenMin: 15, PriceOre: 110}, } plan := Plan{ GeneratedAtMs: 123, @@ -221,7 +213,6 @@ func TestRestoreDiagnosticRehydratesActivePlan(t *testing.T) { LenMin: 15, PriceOre: 120, SpotOre: 80, - Confidence: 1, PVW: -4500, LoadW: 900, BatteryW: 0, @@ -241,7 +232,6 @@ func TestRestoreDiagnosticRehydratesActivePlan(t *testing.T) { LenMin: 15, PriceOre: -10, SpotOre: -20, - Confidence: 1, PVW: -5000, LoadW: 900, BatteryW: 1200, @@ -465,7 +455,7 @@ func TestRestoreDiagnosticMergesNewerDefaultsForMissingFields(t *testing.T) { Slots: []DiagnosticSlot{{ Idx: 0, SlotStartMs: start.UnixMilli(), SlotEndMs: start.Add(15 * time.Minute).UnixMilli(), - LenMin: 15, PriceOre: 100, Confidence: 1, PVW: -3000, LoadW: 500, + LenMin: 15, PriceOre: 100, PVW: -3000, LoadW: 500, BatteryW: 0, GridW: -2500, SoC: 0.08, EMSMode: "self_consumption", }}, @@ -555,7 +545,7 @@ func TestRestoreDiagnosticPreservesExplicitSnapshotValues(t *testing.T) { Slots: []DiagnosticSlot{{ Idx: 0, SlotStartMs: start.UnixMilli(), SlotEndMs: start.Add(15 * time.Minute).UnixMilli(), - LenMin: 15, PriceOre: 100, Confidence: 1, PVW: -3000, LoadW: 500, + LenMin: 15, PriceOre: 100, PVW: -3000, LoadW: 500, BatteryW: 0, GridW: -2500, SoC: 0.3, EMSMode: "self_consumption", }}, @@ -592,13 +582,13 @@ func TestDiagnoseCarriesLoadpointFields(t *testing.T) { start := time.Date(2026, 4, 22, 0, 0, 0, 0, time.UTC).UnixMilli() slots := []Slot{ {StartMs: start, LenMin: 60, PriceOre: 30, SpotOre: 15, - LoadW: 400, Confidence: 1.0}, + LoadW: 400}, {StartMs: start + 3600_000, LenMin: 60, PriceOre: 20, SpotOre: 10, - LoadW: 400, Confidence: 1.0}, + LoadW: 400}, {StartMs: start + 7200_000, LenMin: 60, PriceOre: 25, SpotOre: 12, - LoadW: 400, Confidence: 1.0}, + LoadW: 400}, {StartMs: start + 10800_000, LenMin: 60, PriceOre: 40, SpotOre: 20, - LoadW: 400, Confidence: 1.0}, + LoadW: 400}, } p := Params{ Mode: ModeCheapCharge, diff --git a/go/internal/mpc/energyplan_test.go b/go/internal/mpc/energyplan_test.go index 4005c53ec..2995bfe80 100644 --- a/go/internal/mpc/energyplan_test.go +++ b/go/internal/mpc/energyplan_test.go @@ -16,7 +16,7 @@ import ( ) func TestValidatePlanRejectsEVOverCapacity(t *testing.T) { - slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 100, Confidence: 1}} + slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 100}} p := baseParams(ModeArbitrage) p.MaxChargeW, p.MaxDischargeW = 0, 0 p.Loadpoint = &LoadpointSpec{ID: "ev", Levels: 11, CapacityWh: 10000, InitialSoC: .95, SoCMax: 1, @@ -29,7 +29,7 @@ func TestValidatePlanRejectsEVOverCapacity(t *testing.T) { } func TestCoreDPNearCeilingReplaysPower(t *testing.T) { - slots := []Slot{{StartMs: 1, LenMin: 15, Confidence: 1}} + slots := []Slot{{StartMs: 1, LenMin: 15}} p := Params{Mode: ModeArbitrage, InitialSoC: .947, CapacityWh: 20000, SoCMin: .1, SoCMax: .95, ChargeEfficiency: .95, DischargeEfficiency: .95, MaxChargeW: 9000, MaxDischargeW: 9000, TerminalSoCPrice: 160, SoCLevels: 201, ActionLevels: 401} @@ -60,9 +60,9 @@ func TestNativeEVPulsePricesImportAboveSolarSurplus(t *testing.T) { worker := nativeWorker(t, 500*time.Millisecond) t.Cleanup(func() { _ = worker.Close() }) slots := []Slot{ - {StartMs: 0, LenMin: 60, Confidence: 1, PVW: -2000, PriceOre: 100, SpotOre: 5}, - {StartMs: 3600000, LenMin: 60, Confidence: 1, PriceOre: 50, SpotOre: 5}, - {StartMs: 7200000, LenMin: 60, Confidence: 1, PriceOre: 200, SpotOre: 5}, + {StartMs: 0, LenMin: 60, PVW: -2000, PriceOre: 100, SpotOre: 5}, + {StartMs: 3600000, LenMin: 60, PriceOre: 50, SpotOre: 5}, + {StartMs: 7200000, LenMin: 60, PriceOre: 200, SpotOre: 5}, } // A fixed battery leaves the charger as the only choice. Its 11 kW // pulse imports 9 kW in the solar slot even when mean EV power is 2 kW. diff --git a/go/internal/mpc/ev_reserve.go b/go/internal/mpc/ev_reserve.go index 1ed8e5d64..cdbfcfdf7 100644 --- a/go/internal/mpc/ev_reserve.go +++ b/go/internal/mpc/ev_reserve.go @@ -95,7 +95,7 @@ func coreReservePlan(ctx context.Context, slots []Slot, p Params) Plan { average := min(peak, need/(eff*hours)) evEnergy += average * eff * hours energy += loadpoint.BatteryEnergyDeltaWh(battery, hours, chargeEff, dischargeEff) - a := Action{ExecutionStartMs: slot.ExecutionStartMs, SlotStartMs: slot.StartMs, SlotLenMin: slot.LenMin, PriceOre: slot.PriceOre, SpotOre: slot.SpotOre, PVW: slot.PVW, LoadW: slot.LoadW, BatteryW: battery, GridW: house + battery + average, SoC: energy / p.CapacityWh, Confidence: slot.Confidence, + a := Action{ExecutionStartMs: slot.ExecutionStartMs, SlotStartMs: slot.StartMs, SlotLenMin: slot.LenMin, PriceOre: slot.PriceOre, SpotOre: slot.SpotOre, PVW: slot.PVW, LoadW: slot.LoadW, BatteryW: battery, GridW: house + battery + average, SoC: energy / p.CapacityWh, LoadpointW: average, LoadpointSoC: evEnergy / lp.CapacityWh, LoadpointPowerW: map[string]float64{lp.ID: average}, LoadpointSoCByID: map[string]float64{lp.ID: evEnergy / lp.CapacityWh}, LoadpointMaxPowerW: map[string]float64{lp.ID: peak}, Reason: "EV deadline reserve"} a.CostOre = reserveSlotCost(slot, p, a) plan.TotalCostOre += a.CostOre diff --git a/go/internal/mpc/ev_reserve_test.go b/go/internal/mpc/ev_reserve_test.go index f7e56e47a..88ea5cd84 100644 --- a/go/internal/mpc/ev_reserve_test.go +++ b/go/internal/mpc/ev_reserve_test.go @@ -13,7 +13,7 @@ func nearTargetReserveFixture() ([]Slot, Params) { slots := make([]Slot, 193) start := time.Now().UTC().Truncate(15 * time.Minute) for i := range slots { - slots[i] = Slot{StartMs: start.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(), LenMin: 15, PriceOre: 100, SpotOre: 10, LoadW: 500, Limits: PowerLimits{MaxImportW: 11040, MaxExportW: 11040}, Confidence: 1} + slots[i] = Slot{StartMs: start.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(), LenMin: 15, PriceOre: 100, SpotOre: 10, LoadW: 500, Limits: PowerLimits{MaxImportW: 11040, MaxExportW: 11040}} } return slots, p } diff --git a/go/internal/mpc/execution_time_test.go b/go/internal/mpc/execution_time_test.go index 7de0ce1ac..38f0c8b33 100644 --- a/go/internal/mpc/execution_time_test.go +++ b/go/internal/mpc/execution_time_test.go @@ -22,7 +22,7 @@ func TestPartialSlotCoreAndWorkerReplay(t *testing.T) { for _, elapsed := range []time.Duration{0, 450 * time.Second, 750 * time.Second, 748939 * time.Millisecond, 899 * time.Second} { for _, ev := range []bool{false, true} { start := time.Date(2026, 9, 8, 4, 45, 0, 0, time.UTC) - slots := []Slot{{StartMs: start.UnixMilli(), LenMin: 15, PriceOre: 100, SpotOre: 50, Confidence: 1, LoadW: 794.2886383422652}} + slots := []Slot{{StartMs: start.UnixMilli(), LenMin: 15, PriceOre: 100, SpotOre: 50, LoadW: 794.2886383422652}} if !trimFirstExecutionSlot(slots, start.Add(elapsed)) { t.Fatal("trim failed") } @@ -177,7 +177,7 @@ func (*partialClockOptimizer) Close() error { return nil } func Test0457PlanRejectsFullQuarterEnergy(t *testing.T) { start := time.Date(2026, 9, 8, 4, 45, 0, 0, time.UTC) - slots := []Slot{{StartMs: start.UnixMilli(), LenMin: 15, ExecutionStartMs: start.Add(748939 * time.Millisecond).UnixMilli(), PriceOre: 162.6900225, SpotOre: 60.152018, LoadW: 794.2886383422652, Confidence: 1}} + slots := []Slot{{StartMs: start.UnixMilli(), LenMin: 15, ExecutionStartMs: start.Add(748939 * time.Millisecond).UnixMilli(), PriceOre: 162.6900225, SpotOre: 60.152018, LoadW: 794.2886383422652}} p := baseParams(ModeArbitrage) p.InitialSoC = .875 p.CapacityWh = 9600 diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index a98ebb4e2..1dc1a805c 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -248,7 +248,7 @@ type externalSlot struct { LenMin int `json:"len_min"` PriceOre float64 `json:"price_per_kwh"` SpotOre float64 `json:"spot_per_kwh"` - Confidence float64 `json:"confidence"` + Confidence float64 `json:"confidence"` // required by Energyplan; always 1, every price is published PVW float64 `json:"pv_w"` LoadW float64 `json:"load_w"` MaxImportW float64 `json:"max_import_w"` @@ -466,7 +466,7 @@ func (o *ExternalOptimizer) buildRequest(slots []Slot, p Params) externalRequest req.Slots[i] = externalSlot{ StartMs: slot.StartMs, LenMin: slot.LenMin, ExecutionStartMs: slot.ExecutionStartMs, PriceOre: slot.PriceOre, SpotOre: slot.SpotOre, - Confidence: slot.Confidence, PVW: slot.PVW, LoadW: slot.LoadW, + Confidence: 1, PVW: slot.PVW, LoadW: slot.LoadW, MaxImportW: slot.Limits.MaxImportW, MaxExportW: slot.Limits.MaxExportW, } } @@ -592,7 +592,7 @@ func (r externalResponse) toPlan(slots []Slot, p Params) Plan { action := Action{ SlotStartMs: candidate.SlotStartMs, SlotLenMin: candidate.SlotLenMin, ExecutionStartMs: candidate.ExecutionStartMs, PriceOre: slot.PriceOre, SpotOre: slot.SpotOre, - PVW: slot.PVW, LoadW: slot.LoadW, Confidence: slot.Confidence, + PVW: slot.PVW, LoadW: slot.LoadW, BatteryW: candidate.BatteryW, GridW: candidate.GridW, SoC: candidate.SoCPct / 100, CostOre: candidate.CostOre, PVLimitW: candidate.PVLimitW, diff --git a/go/internal/mpc/external_optimizer_test.go b/go/internal/mpc/external_optimizer_test.go index 012cc36af..b82a78446 100644 --- a/go/internal/mpc/external_optimizer_test.go +++ b/go/internal/mpc/external_optimizer_test.go @@ -28,8 +28,8 @@ func (t *externalOptimizerTransportStub) Close() error { return nil } func externalTestFixture() ([]Slot, Params) { slots := []Slot{ - {StartMs: 1, LenMin: 60, PriceOre: 20, SpotOre: 10, Confidence: 1, LoadW: 500, Limits: PowerLimits{MaxImportW: 8000, MaxExportW: 8000}}, - {StartMs: 3600001, LenMin: 60, PriceOre: 300, SpotOre: 240, Confidence: 1, LoadW: 2500, Limits: PowerLimits{MaxImportW: 8000, MaxExportW: 8000}}, + {StartMs: 1, LenMin: 60, PriceOre: 20, SpotOre: 10, LoadW: 500, Limits: PowerLimits{MaxImportW: 8000, MaxExportW: 8000}}, + {StartMs: 3600001, LenMin: 60, PriceOre: 300, SpotOre: 240, LoadW: 2500, Limits: PowerLimits{MaxImportW: 8000, MaxExportW: 8000}}, } p := Params{ Mode: ModeArbitrage, CapacityWh: 10000, @@ -133,7 +133,7 @@ func TestValidatePlanRejectsBrokenGridBalance(t *testing.T) { } func TestValidatePlanAcceptsSubWattSolverResidueInPassiveMode(t *testing.T) { - slots := []Slot{{StartMs: 1, LenMin: 15, PriceOre: 100, Confidence: 1, LoadW: 0}} + slots := []Slot{{StartMs: 1, LenMin: 15, PriceOre: 100, LoadW: 0}} p := Params{ Mode: ModePassiveArbitrage, CapacityWh: 10000, SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, @@ -170,8 +170,7 @@ func TestValidatePlanGridLimitAllowsOnlySubWattSolverResidue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { slot := Slot{ - StartMs: 1, LenMin: 15, PriceOre: 100, SpotOre: 50, Confidence: 1, - Limits: PowerLimits{MaxImportW: limitW, MaxExportW: limitW}, + StartMs: 1, LenMin: 15, PriceOre: 100, SpotOre: 50, Limits: PowerLimits{MaxImportW: limitW, MaxExportW: limitW}, } if tc.gridW > 0 { slot.LoadW = tc.gridW @@ -192,7 +191,7 @@ func TestValidatePlanGridLimitAllowsOnlySubWattSolverResidue(t *testing.T) { } func TestValidatePlanModeErrorIncludesPowerValues(t *testing.T) { - slots := []Slot{{StartMs: 1, LenMin: 15, PriceOre: 100, Confidence: 1, LoadW: 0}} + slots := []Slot{{StartMs: 1, LenMin: 15, PriceOre: 100, LoadW: 0}} p := Params{ Mode: ModePassiveArbitrage, CapacityWh: 10000, SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, @@ -216,8 +215,7 @@ func TestValidatePlanModeErrorIncludesPowerValues(t *testing.T) { // all reconcile, so only the grid-limit check can reject it. func gridLimitFixture(gridW float64) ([]Slot, Params, Plan) { slot := Slot{ - StartMs: 1, LenMin: 15, PriceOre: 100, SpotOre: 80, Confidence: 1, - Limits: PowerLimits{MaxImportW: 11040, MaxExportW: 8000}, + StartMs: 1, LenMin: 15, PriceOre: 100, SpotOre: 80, Limits: PowerLimits{MaxImportW: 11040, MaxExportW: 8000}, } if gridW >= 0 { slot.LoadW = gridW @@ -316,7 +314,7 @@ func TestExternalOptimizerStopsWorkerAfterIdleTimeout(t *testing.T) { } func TestValidatePlanAllowsButDoesNotWorsenInitialSoCBelowMinimum(t *testing.T) { - slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 100, SpotOre: 50, Confidence: 1, LoadW: 500}} + slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 100, SpotOre: 50, LoadW: 500}} p := Params{ Mode: ModeArbitrage, CapacityWh: 10000, SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.05, @@ -340,7 +338,7 @@ func TestValidatePlanAllowsButDoesNotWorsenInitialSoCBelowMinimum(t *testing.T) } func TestValidatePlanRejectsBatteryFedSurplusLoadpoint(t *testing.T) { - slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 100, SpotOre: 70, Confidence: 1, LoadW: 500}} + slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 100, SpotOre: 70, LoadW: 500}} p := Params{ Mode: ModeArbitrage, CapacityWh: 10000, SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, @@ -366,7 +364,7 @@ func TestValidatePlanRejectsBatteryFedSurplusLoadpoint(t *testing.T) { } func TestValidatePlanAllowsGridChargeWithIdleSurplusOnlyEV(t *testing.T) { - slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 30, SpotOre: 10, Confidence: 1, LoadW: 500}} + slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 30, SpotOre: 10, LoadW: 500}} p := Params{ Mode: ModeArbitrage, CapacityWh: 10000, SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.2, @@ -392,7 +390,7 @@ func TestValidatePlanAllowsGridChargeWithIdleSurplusOnlyEV(t *testing.T) { } func TestValidatePlanAllowsEVPVWithBatteryGridCharge(t *testing.T) { - slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 20, SpotOre: 10, Confidence: 1, LoadW: 500, PVW: -6500}} + slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 20, SpotOre: 10, LoadW: 500, PVW: -6500}} p := Params{ Mode: ModeArbitrage, CapacityWh: 10000, SoCMin: 0.10, SoCMax: 0.95, InitialSoC: 0.20, @@ -420,7 +418,7 @@ func TestValidatePlanAllowsEVPVWithBatteryGridCharge(t *testing.T) { } func TestValidatePlanRejectsSurplusOnlyEVAboveLeftoverPV(t *testing.T) { - slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 20, SpotOre: 10, Confidence: 1, LoadW: 500, PVW: -6500}} + slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 20, SpotOre: 10, LoadW: 500, PVW: -6500}} p := Params{ Mode: ModeArbitrage, CapacityWh: 10000, SoCMin: 0.10, SoCMax: 0.95, InitialSoC: 0.20, @@ -457,8 +455,7 @@ func TestBuildRequestScenarioSpreadIsPerSlotWhenRelativeIsLearned(t *testing.T) for i, g := range gen { slots[i] = Slot{ StartMs: start + int64(i)*15*60*1000, LenMin: 15, - PriceOre: 100, SpotOre: 50, LoadW: 400, PVW: -g, Confidence: 1, - } + PriceOre: 100, SpotOre: 50, LoadW: 400, PVW: -g} } p := Params{ Mode: ModeArbitrage, SoCMin: 0.1, SoCMax: 0.95, SoCLevels: 11, @@ -500,9 +497,9 @@ func TestBuildRequestScenarioSpreadStaysFlatWhenRelativeIsUnlearned(t *testing.T start := time.Date(2026, 8, 30, 10, 0, 0, 0, time.UTC).UnixMilli() slots := []Slot{ {StartMs: start, LenMin: 15, PriceOre: 100, SpotOre: 50, - LoadW: 400, PVW: -6000, Confidence: 1}, + LoadW: 400, PVW: -6000}, {StartMs: start + 15*60*1000, LenMin: 15, PriceOre: 100, SpotOre: 50, - LoadW: 400, PVW: -500, Confidence: 1}, + LoadW: 400, PVW: -500}, } p := Params{ Mode: ModeArbitrage, SoCMin: 0.1, SoCMax: 0.95, SoCLevels: 11, diff --git a/go/internal/mpc/loadpoint_service_test.go b/go/internal/mpc/loadpoint_service_test.go index 1ca419f0b..bd3a701c6 100644 --- a/go/internal/mpc/loadpoint_service_test.go +++ b/go/internal/mpc/loadpoint_service_test.go @@ -21,12 +21,11 @@ func TestSlotDirectiveCarriesLoadpointEnergyWh(t *testing.T) { slots := make([]Slot, 4) for i := range slots { slots[i] = Slot{ - StartMs: start.Add(time.Duration(i) * time.Hour).UnixMilli(), - LenMin: 60, - PriceOre: 40, - SpotOre: 20, - LoadW: 400, - Confidence: 1.0, + StartMs: start.Add(time.Duration(i) * time.Hour).UnixMilli(), + LenMin: 60, + PriceOre: 40, + SpotOre: 20, + LoadW: 400, } } p := Params{ @@ -102,7 +101,7 @@ func TestSlotDirectiveEmptyWhenNoLoadpoint(t *testing.T) { start := now.Truncate(15 * time.Minute) slots := []Slot{ {StartMs: start.UnixMilli(), LenMin: 15, PriceOre: 50, - LoadW: 500, Confidence: 1.0}, + LoadW: 500}, } plan := Optimize(slots, Params{ Mode: ModeSelfConsumption, SoCLevels: 11, CapacityWh: 10000, @@ -136,22 +135,20 @@ func TestNoBatteryToEVForbidsBatteryFeedingEV(t *testing.T) { // it, battery must stay ≤ house_residual = max(0, 1000 - 1000) = 0. slots := []Slot{ { - StartMs: start.UnixMilli(), - LenMin: 60, - PriceOre: 500, - SpotOre: 500, - LoadW: 1000, - PVW: -1000, - Confidence: 1.0, + StartMs: start.UnixMilli(), + LenMin: 60, + PriceOre: 500, + SpotOre: 500, + LoadW: 1000, + PVW: -1000, }, { - StartMs: start.Add(time.Hour).UnixMilli(), - LenMin: 60, - PriceOre: 500, - SpotOre: 500, - LoadW: 1000, - PVW: -1000, - Confidence: 1.0, + StartMs: start.Add(time.Hour).UnixMilli(), + LenMin: 60, + PriceOre: 500, + SpotOre: 500, + LoadW: 1000, + PVW: -1000, }, } mkParams := func(noBatToEV bool) Params { @@ -216,22 +213,20 @@ func TestSurplusOnlyForbidsBatteryFeedingEVEvenWhenCoverEVEnabled(t *testing.T) slots := []Slot{ { - StartMs: start.UnixMilli(), - LenMin: 60, - PriceOre: 500, - SpotOre: 500, - LoadW: 1000, - PVW: -1000, - Confidence: 1.0, + StartMs: start.UnixMilli(), + LenMin: 60, + PriceOre: 500, + SpotOre: 500, + LoadW: 1000, + PVW: -1000, }, { - StartMs: start.Add(time.Hour).UnixMilli(), - LenMin: 60, - PriceOre: 500, - SpotOre: 500, - LoadW: 1000, - PVW: -1000, - Confidence: 1.0, + StartMs: start.Add(time.Hour).UnixMilli(), + LenMin: 60, + PriceOre: 500, + SpotOre: 500, + LoadW: 1000, + PVW: -1000, }, } @@ -297,8 +292,8 @@ func TestSurplusOnlyForbidsBatteryFeedingEVEvenWhenCoverEVEnabled(t *testing.T) // was plugged in, which silenced grid-charge for the whole connection. func TestArbitrageGridChargesWhileSurplusOnlyEVIsConnected(t *testing.T) { slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 30, SpotOre: 10, LoadW: 500, PVW: 0, Confidence: 1}, - {StartMs: 3600_000, LenMin: 60, PriceOre: 300, SpotOre: 250, LoadW: 500, PVW: 0, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 30, SpotOre: 10, LoadW: 500, PVW: 0}, + {StartMs: 3600_000, LenMin: 60, PriceOre: 300, SpotOre: 250, LoadW: 500, PVW: 0}, } plan := Optimize(slots, Params{ Mode: ModeArbitrage, @@ -346,8 +341,8 @@ func TestArbitrageGridChargesWhileSurplusOnlyEVIsConnected(t *testing.T) { func TestPassiveArbitrageGridChargesWhileSurplusOnlyEVIsConnected(t *testing.T) { slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 30, SpotOre: 10, LoadW: 500, Confidence: 1}, - {StartMs: 3600_000, LenMin: 60, PriceOre: 300, SpotOre: 250, LoadW: 5000, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 30, SpotOre: 10, LoadW: 500}, + {StartMs: 3600_000, LenMin: 60, PriceOre: 300, SpotOre: 250, LoadW: 5000}, } plan := Optimize(slots, Params{ Mode: ModePassiveArbitrage, @@ -392,7 +387,7 @@ func TestPassiveArbitrageGridChargesWhileSurplusOnlyEVIsConnected(t *testing.T) func TestSurplusOnlyEVCannotImportEvenWithDeadline(t *testing.T) { slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 40, SpotOre: 10, LoadW: 500, PVW: 0, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 40, SpotOre: 10, LoadW: 500, PVW: 0}, } plan := Optimize(slots, Params{ Mode: ModeArbitrage, @@ -437,8 +432,8 @@ func TestArbitrageChargesSurplusOnlyEVFromPVWhileBatteryGridCharges(t *testing.T // the grid in the same slot. The old feasibility rule rejected any // (evW>0 AND gridW>50) pair and forced "car sits / Pixii never buys". slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 20, SpotOre: 10, LoadW: 500, PVW: -6500, Confidence: 1}, - {StartMs: 3600_000, LenMin: 60, PriceOre: 300, SpotOre: 240, LoadW: 2500, PVW: 0, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 20, SpotOre: 10, LoadW: 500, PVW: -6500}, + {StartMs: 3600_000, LenMin: 60, PriceOre: 300, SpotOre: 240, LoadW: 2500, PVW: 0}, } plan := Optimize(slots, Params{ Mode: ModeArbitrage, diff --git a/go/internal/mpc/loadpoint_spec_test.go b/go/internal/mpc/loadpoint_spec_test.go index 742026e48..a2b29e7a0 100644 --- a/go/internal/mpc/loadpoint_spec_test.go +++ b/go/internal/mpc/loadpoint_spec_test.go @@ -73,13 +73,13 @@ func TestOptimizePrefersCheapSlotsForEV(t *testing.T) { // inactive-ish (small capacity). slots := []Slot{ {StartMs: 0, LenMin: 60, PriceOre: 150, SpotOre: 100, - LoadW: 500, Confidence: 1.0}, + LoadW: 500}, {StartMs: 3600_000, LenMin: 60, PriceOre: 20, SpotOre: 10, - LoadW: 500, Confidence: 1.0}, + LoadW: 500}, {StartMs: 7200_000, LenMin: 60, PriceOre: 180, SpotOre: 140, - LoadW: 500, Confidence: 1.0}, + LoadW: 500}, {StartMs: 10800_000, LenMin: 60, PriceOre: 30, SpotOre: 20, - LoadW: 500, Confidence: 1.0}, + LoadW: 500}, } p := Params{ Mode: ModeCheapCharge, @@ -139,9 +139,9 @@ func TestOptimizePrefersCheapSlotsForEV(t *testing.T) { func TestOptimizeNilLoadpointUnchanged(t *testing.T) { slots := []Slot{ {StartMs: 0, LenMin: 60, PriceOre: 150, SpotOre: 100, - LoadW: 500, Confidence: 1.0}, + LoadW: 500}, {StartMs: 3600_000, LenMin: 60, PriceOre: 20, SpotOre: 10, - LoadW: 500, Confidence: 1.0}, + LoadW: 500}, } p := Params{ Mode: ModeCheapCharge, diff --git a/go/internal/mpc/mpc.go b/go/internal/mpc/mpc.go index da47c8cee..3a51d064b 100644 --- a/go/internal/mpc/mpc.go +++ b/go/internal/mpc/mpc.go @@ -128,13 +128,6 @@ type Slot struct { WeatherRowSource string WeatherRowAvailableAtMs int64 - // Confidence in [0, 1]. 1.0 = real day-ahead price; < 1.0 = ML- - // forecasted price where we're less sure of both level and shape. - // The DP blends low-confidence prices toward the horizon mean so - // the planner doesn't over-commit to uncertain spikes. Defaults to - // 1.0 when callers leave it zero. - Confidence float64 - // Limits caps grid flow for this slot. Zero value = unlimited. // See power_limits.go for use cases (peak-tariff capacity, DSO // curtailment, service-entrance current limit). @@ -304,13 +297,12 @@ type Action struct { ForecastPVW *float64 `json:"forecast_pv_w,omitempty"` ForecastLoadW *float64 `json:"forecast_load_w,omitempty"` LoadW float64 `json:"load_w"` - BatteryW float64 `json:"battery_w"` // decision (site sign, AC terminals) - GridW float64 `json:"grid_w"` // resulting grid power - SoC float64 `json:"soc"` // 0–1 at END of slot - CostOre float64 `json:"cost_ore"` // this slot's cost (öre). Negative = revenue. - Confidence float64 `json:"confidence"` // 1.0 real, <1.0 forecasted (UI uses this to style) - Reason string `json:"reason"` // short human-readable explanation - EMSMode string `json:"ems_mode"` // effective EMS mode for this slot (set by SlotAt post-processing) + BatteryW float64 `json:"battery_w"` // decision (site sign, AC terminals) + GridW float64 `json:"grid_w"` // resulting grid power + SoC float64 `json:"soc"` // 0–1 at END of slot + CostOre float64 `json:"cost_ore"` // this slot's cost (öre). Negative = revenue. + Reason string `json:"reason"` // short human-readable explanation + EMSMode string `json:"ems_mode"` // effective EMS mode for this slot (set by SlotAt post-processing) // PVLimitW is the recommended cap on PV inverter output (W, positive). // When PVCurtailActive is false, 0 means no cap (a dispatch hint may @@ -501,8 +493,7 @@ type SolverInfo struct { // p.ExportFloorOreKwh explicitly floors it. // // Shared by plan reporting, baselines, diagnostics reconstruction, and -// curtailment. The DP decision path applies confidence blending on top of -// this same raw export-price model. +// curtailment. The DP decides with this same export-price model. func SlotGridCostOre(slot Slot, gridKWh float64, p Params) float64 { return gridcost.GridCostOre(slot.PriceOre, slot.SpotOre, gridKWh, exportPricingFromParams(p)) } @@ -590,11 +581,6 @@ func sanitizeOptimizeSlots(slots []Slot) []Slot { if !finite(s.LoadW) || s.LoadW < 0 { s.LoadW = 0 } - if !finite(s.Confidence) || s.Confidence <= 0 { - s.Confidence = 1.0 - } else if s.Confidence > 1 { - s.Confidence = 1.0 - } if !finite(s.Limits.MaxImportW) { s.Limits.MaxImportW = 0 } @@ -654,28 +640,8 @@ func OptimizeContext(ctx context.Context, slots []Slot, p Params) (Plan, error) socStep := (p.SoCMax - p.SoCMin) / float64(S-1) socAt := func(i int) float64 { return p.SoCMin + float64(i)*socStep } - // Default any missing confidence to 1.0 (treat caller-unaware slots - // as "real") before anything reads it. - for i := range slots { - if slots[i].Confidence <= 0 { - slots[i].Confidence = 1.0 - } - } - // Confidence handling: blend low-confidence prices toward the - // horizon mean (real + forecast). - meanPrice, meanExport := horizonMeans(slots, p) - // effPrice(slot) = c × raw + (1 − c) × mean. c=1 → raw; c<1 pulls - // toward horizon mean, dampening arbitrage the DP sees on shaky - // forecasted slots without hiding them entirely. - effPrice := func(s Slot) float64 { - return s.Confidence*s.PriceOre + (1-s.Confidence)*meanPrice - } - // Export decision lens mirrors import price confidence handling, but - // starts from the same raw per-slot export model used everywhere else. - effExportOre := func(s Slot) float64 { - raw := SlotExportPriceOre(s, p) - return s.Confidence*raw + (1-s.Confidence)*meanExport - } + // The mean price scales the EV deadline penalty and labels reasons. + meanPrice := horizonMeanPrice(slots) // Action grid spans −MaxDischargeW … +MaxChargeW and always contains // 0 W. With asymmetric charge/discharge limits, an odd number of evenly @@ -897,9 +863,9 @@ func OptimizeContext(ctx context.Context, slots []Slot, p Params) (Plan, error) gridKWh := gridW * dtH / 1000.0 var cost float64 if gridKWh > 0 { - cost = effPrice(slot) * gridKWh + cost = slot.PriceOre * gridKWh } else { - cost = -effExportOre(slot) * (-gridKWh) + cost = -SlotExportPriceOre(slot, p) * (-gridKWh) } // Strict self-consumption bias. When the mode @@ -939,7 +905,7 @@ func OptimizeContext(ctx context.Context, slots []Slot, p Params) (Plan, error) houseGridW := slot.LoadW + slot.PVW + battW if houseGridW > 0 { houseKWh := houseGridW * dtH / 1000.0 - cost += strictSCBiasOre(effPrice(slot), houseKWh) + cost += strictSCBiasOre(slot.PriceOre, houseKWh) } } @@ -1137,7 +1103,6 @@ func OptimizeContext(ctx context.Context, slots []Slot, p Params) (Plan, error) ExecutionStartMs: slot.ExecutionStartMs, PriceOre: slot.PriceOre, SpotOre: slot.SpotOre, - Confidence: slot.Confidence, PVW: slot.PVW, LoadW: slot.LoadW, BatteryW: actW, @@ -1163,33 +1128,29 @@ func OptimizeContext(ctx context.Context, slots []Slot, p Params) (Plan, error) return plan, ctx.Err() } -// horizonMeans returns the horizon's mean import price and mean export -// price, both LENGTH-WEIGHTED by Slot.LenMin: with mixed 15/60-minute -// slots an unweighted mean over-counts the short slots, skewing the -// confidence blend and the EV deadline penalty (parity fix, #1020 — -// Baselines already weighted by LenMin; the DP did not). Falls back to -// the unweighted mean when no slot carries a length. Never mutates -// slots. -func horizonMeans(slots []Slot, p Params) (meanPriceOre, meanExportOre float64) { +// horizonMeanPrice returns the horizon's mean import price, LENGTH-WEIGHTED +// by Slot.LenMin: with mixed 15/60-minute slots an unweighted mean +// over-counts the short slots, skewing the EV deadline penalty (parity fix, +// #1020 — Baselines already weighted by LenMin; the DP did not). Falls back +// to the unweighted mean when no slot carries a length. Never mutates slots. +func horizonMeanPrice(slots []Slot) float64 { if len(slots) == 0 { - return 0, 0 + return 0 } - var sumPrice, sumExport, sumLenMin float64 + var sumPrice, sumLenMin float64 for _, s := range slots { w := s.DurationHours() * 60 sumPrice += s.PriceOre * w - sumExport += SlotExportPriceOre(s, p) * w sumLenMin += w } if sumLenMin <= 0 { - sumPrice, sumExport = 0, 0 + sumPrice = 0 for _, s := range slots { sumPrice += s.PriceOre - sumExport += SlotExportPriceOre(s, p) } sumLenMin = float64(len(slots)) } - return sumPrice / sumLenMin, sumExport / sumLenMin + return sumPrice / sumLenMin } // strictSCBiasOre is the strict self-consumption decision bias: house @@ -1228,13 +1189,12 @@ func annotateCurtailment(plan *Plan, p Params) { continue // importing, not exporting } slot := Slot{ - StartMs: a.SlotStartMs, - LenMin: a.SlotLenMin, - PriceOre: a.PriceOre, - SpotOre: a.SpotOre, - PVW: a.PVW, - LoadW: a.LoadW, - Confidence: a.Confidence, + StartMs: a.SlotStartMs, + LenMin: a.SlotLenMin, + PriceOre: a.PriceOre, + SpotOre: a.SpotOre, + PVW: a.PVW, + LoadW: a.LoadW, } if SlotExportPriceOre(slot, p) > 0 { continue // profitable export; curtailing would discard revenue @@ -1337,10 +1297,6 @@ func reasonFor(s Slot, batteryW, gridW, meanPrice float64) string { baseline := s.LoadW + s.PVW // what grid would see with no battery const chargeThresh = IdleGateThresholdW const gridThresh = 100.0 - priceTag := "" - if s.Confidence < 1.0 { - priceTag = " (predicted)" - } // Classify the resulting grid direction — this is what the // operator sees on the meter, and what matters for the label. gridExports := gridW < -gridThresh @@ -1358,36 +1314,36 @@ func reasonFor(s Slot, batteryW, gridW, meanPrice float64) string { // battery act as a sink for solar energy. Only flip to the // "charge — import" branches when the battery's appetite // exceeds PV output and drags grid into actual import. - return "absorb PV surplus" + priceTag + return "absorb PV surplus" case batteryW > chargeThresh && gridImports && priceBelow: - return "charge from cheap grid" + priceTag + return "charge from cheap grid" case batteryW > chargeThresh && gridImports: - return "charge — import" + priceTag + return "charge — import" case batteryW > chargeThresh: - return "charge" + priceTag + return "charge" // --- discharging branches --- case batteryW < -chargeThresh && gridExports && priceAbove: - return "discharge — export at peak" + priceTag + return "discharge — export at peak" case batteryW < -chargeThresh && gridExports: - return "discharge — export" + priceTag + return "discharge — export" case batteryW < -chargeThresh && priceAbove: // Reducing import during a high-price slot — even if it // doesn't push grid negative, the motive is peak-shaving. - return "discharge — price above horizon mean" + priceTag + return "discharge — price above horizon mean" case batteryW < -chargeThresh && baseline > chargeThresh: - return "discharge — cover local load" + priceTag + return "discharge — cover local load" case batteryW < -chargeThresh: - return "discharge" + priceTag + return "discharge" // --- idle branches --- default: if gridImports { - return "idle — import to cover load" + priceTag + return "idle — import to cover load" } if gridExports { - return "idle — export PV surplus" + priceTag + return "idle — export PV surplus" } - return "idle" + priceTag + return "idle" } } diff --git a/go/internal/mpc/mpc_test.go b/go/internal/mpc/mpc_test.go index a3a6ce93b..984a6d664 100644 --- a/go/internal/mpc/mpc_test.go +++ b/go/internal/mpc/mpc_test.go @@ -104,7 +104,6 @@ func TestPassiveArbitragePVChargeBonusPrefersPVOverExport(t *testing.T) { SpotOre: 20, LoadW: 500, PVW: -5000, - Confidence: 1, }} pNoBonus := baseParams(ModePassiveArbitrage) pNoBonus.InitialSoC = 0.6 @@ -154,7 +153,6 @@ func TestPassiveArbitragePVChargeBonusDoesNotMotivateGridCharge(t *testing.T) { SpotOre: 5, LoadW: 500, PVW: 0, // no PV - Confidence: 1, } } p := baseParams(ModePassiveArbitrage) @@ -203,7 +201,6 @@ func TestPassiveArbitrageChargesFromPVWhenSurplusAvailable(t *testing.T) { SpotOre: 10, LoadW: 500, PVW: -3000, // PV >> load - Confidence: 1, } } p := baseParams(ModePassiveArbitrage) @@ -241,13 +238,11 @@ func TestPassiveArbitrageGridChargesAtCheapHours(t *testing.T) { if i < 4 { slots[i] = Slot{ StartMs: int64(i) * 60 * 60 * 1000, LenMin: 60, - PriceOre: 30, SpotOre: 5, LoadW: 500, PVW: 0, Confidence: 1, - } + PriceOre: 30, SpotOre: 5, LoadW: 500, PVW: 0, } } else { slots[i] = Slot{ StartMs: int64(i) * 60 * 60 * 1000, LenMin: 60, - PriceOre: 250, SpotOre: 200, LoadW: 500, PVW: 0, Confidence: 1, - } + PriceOre: 250, SpotOre: 200, LoadW: 500, PVW: 0, } } } p := baseParams(ModePassiveArbitrage) @@ -284,7 +279,7 @@ func TestPassiveArbitrageNeverExportsFromBattery(t *testing.T) { // would tempt DP to discharge into grid for arbitrage. Mode must // refuse. slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 100, SpotOre: 300, LoadW: 100, PVW: 0, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 100, SpotOre: 300, LoadW: 100, PVW: 0}, } p := baseParams(ModePassiveArbitrage) p.InitialSoC = 0.8 // plenty of stored energy @@ -326,7 +321,6 @@ func TestDownsidePVKeepsReserveAgainstUncertainLatePV(t *testing.T) { SpotOre: spot, LoadW: 0, PVW: -pv, - Confidence: 1, } } return s @@ -382,8 +376,7 @@ func TestDownsidePVReserveIncreasesWithK(t *testing.T) { } s[i] = Slot{ StartMs: int64(i) * 15 * 60 * 1000, LenMin: 15, - PriceOre: price, SpotOre: spot, LoadW: 0, PVW: -pv, Confidence: 1, - } + PriceOre: price, SpotOre: spot, LoadW: 0, PVW: -pv, } } return s } @@ -417,8 +410,7 @@ func TestDownsidePVWinterNoReserveForced(t *testing.T) { } s[i] = Slot{ StartMs: int64(i) * 15 * 60 * 1000, LenMin: 15, - PriceOre: price, SpotOre: price * 0.5, LoadW: 1000, PVW: 0, Confidence: 1, - } + PriceOre: price, SpotOre: price * 0.5, LoadW: 1000, PVW: 0, } } return s } @@ -440,11 +432,11 @@ func TestSelfConsumptionDefersPVStorageWhenCheaperSurplusAhead(t *testing.T) { // battery headroom and export the early PV instead of filling the battery // before the negative-price window. slots := []Slot{ - {StartMs: 0, LenMin: 15, PriceOre: 129, SpotOre: 33, LoadW: 1000, PVW: -4000, Confidence: 1}, - {StartMs: 15 * 60 * 1000, LenMin: 15, PriceOre: 75, SpotOre: -15, LoadW: 1000, PVW: -7000, Confidence: 1}, - {StartMs: 30 * 60 * 1000, LenMin: 15, PriceOre: 75, SpotOre: -15, LoadW: 1000, PVW: -7000, Confidence: 1}, - {StartMs: 45 * 60 * 1000, LenMin: 15, PriceOre: 220, SpotOre: 100, LoadW: 2500, PVW: 0, Confidence: 1}, - {StartMs: 60 * 60 * 1000, LenMin: 15, PriceOre: 220, SpotOre: 100, LoadW: 2500, PVW: 0, Confidence: 1}, + {StartMs: 0, LenMin: 15, PriceOre: 129, SpotOre: 33, LoadW: 1000, PVW: -4000}, + {StartMs: 15 * 60 * 1000, LenMin: 15, PriceOre: 75, SpotOre: -15, LoadW: 1000, PVW: -7000}, + {StartMs: 30 * 60 * 1000, LenMin: 15, PriceOre: 75, SpotOre: -15, LoadW: 1000, PVW: -7000}, + {StartMs: 45 * 60 * 1000, LenMin: 15, PriceOre: 220, SpotOre: 100, LoadW: 2500, PVW: 0}, + {StartMs: 60 * 60 * 1000, LenMin: 15, PriceOre: 220, SpotOre: 100, LoadW: 2500, PVW: 0}, } p := Params{ Mode: ModeSelfConsumption, @@ -490,7 +482,6 @@ func TestSmartSelfConsumptionExportsMorningPVAndChargesNegativeMidday(t *testing SpotOre: 100, LoadW: 1000, PVW: -4000, - Confidence: 1, }) } for h := 10; h < 14; h++ { @@ -501,7 +492,6 @@ func TestSmartSelfConsumptionExportsMorningPVAndChargesNegativeMidday(t *testing SpotOre: -20, LoadW: 1000, PVW: -6000, - Confidence: 1, }) } for h := 18; h < 22; h++ { @@ -512,7 +502,6 @@ func TestSmartSelfConsumptionExportsMorningPVAndChargesNegativeMidday(t *testing SpotOre: 100, LoadW: 2000, PVW: 0, - Confidence: 1, }) } @@ -636,8 +625,8 @@ func TestArbitrageNoEVChargeWhileBatteryExporting(t *testing.T) { // EV could too), slot 1 expensive (battery should discharge to // export). With the constraint, slot 1 must NOT also charge the EV. slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 50, SpotOre: 50, LoadW: 500, PVW: -3000, Confidence: 1}, - {StartMs: 3600_000, LenMin: 60, PriceOre: 800, SpotOre: 800, LoadW: 500, PVW: 0, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 50, SpotOre: 50, LoadW: 500, PVW: -3000}, + {StartMs: 3600_000, LenMin: 60, PriceOre: 800, SpotOre: 800, LoadW: 500, PVW: 0}, } p := baseParams(ModeArbitrage) p.InitialSoC = 0.8 // headroom for big discharge @@ -688,7 +677,6 @@ func TestArbitrageDoesNotDischargeAtNegativeSpot(t *testing.T) { SpotOre: -5.0, // wholesale spot pays you to consume LoadW: 500, PVW: -2000, // 2 kW solar - Confidence: 1.0, } } p := baseParams(ModeArbitrage) @@ -717,7 +705,7 @@ func TestSlotGridCostOreCostsNegativeExport(t *testing.T) { func TestOptimizeReportedCostUsesNegativeExportPrice(t *testing.T) { slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 80, SpotOre: -5, LoadW: 0, PVW: -1000, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 80, SpotOre: -5, LoadW: 0, PVW: -1000}, } p := baseParams(ModeArbitrage) p.MaxChargeW = 0 @@ -746,14 +734,14 @@ func TestArbitrageNegativeSpotWithExportFloorClampsAtZero(t *testing.T) { zero := 0.0 slots := []Slot{ {StartMs: 0, LenMin: 60, PriceOre: 80, SpotOre: -5.0, - LoadW: 500, PVW: -2000, Confidence: 1.0}, + LoadW: 500, PVW: -2000}, // Second slot identical so the planner has multiple // indistinguishable options to pick from. With the floor at 0 // the cost of discharging vs idling is exactly equal — but // neither should exhibit a *positive* cost (i.e. v < 0 must // have been clamped out). {StartMs: 60 * 60 * 1000, LenMin: 60, PriceOre: 80, SpotOre: -5.0, - LoadW: 500, PVW: -2000, Confidence: 1.0}, + LoadW: 500, PVW: -2000}, } p := baseParams(ModeArbitrage) p.InitialSoC = 0.6 @@ -952,7 +940,6 @@ func TestExportWhenMorningIsHighStoreWhenMiddayIsLow(t *testing.T) { SpotOre: price * 0.7, // rough: strip tariff + VAT for export PVW: -pvW, LoadW: 500, - Confidence: 1.0, } } @@ -1058,7 +1045,7 @@ func TestCurtailmentSkipsWhenExportProfitable(t *testing.T) { func TestCurtailmentSkipsPositiveSpotExport(t *testing.T) { slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 100, SpotOre: 80, LoadW: 500, PVW: -8000, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 100, SpotOre: 80, LoadW: 500, PVW: -8000}, } p := baseParams(ModeArbitrage) p.InitialSoC = 0.95 diff --git a/go/internal/mpc/mpc_threshold_test.go b/go/internal/mpc/mpc_threshold_test.go index bc4e69b55..c0557ae9e 100644 --- a/go/internal/mpc/mpc_threshold_test.go +++ b/go/internal/mpc/mpc_threshold_test.go @@ -11,8 +11,8 @@ import ( // initial SoC. This is the fixture for the threshold tests below. func arbitrageCycleSlots() []Slot { return []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 50, SpotOre: 50, Confidence: 1}, - {StartMs: 3600000, LenMin: 60, PriceOre: 70, SpotOre: 70, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 50, SpotOre: 50}, + {StartMs: 3600000, LenMin: 60, PriceOre: 70, SpotOre: 70}, } } @@ -51,8 +51,8 @@ func TestArbitrageThresholdSuppressesMarginalCycle(t *testing.T) { // cycles. func TestArbitrageThresholdDoesNotBlockWideSpread(t *testing.T) { slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 50, SpotOre: 50, Confidence: 1}, - {StartMs: 3600000, LenMin: 60, PriceOre: 200, SpotOre: 200, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 50, SpotOre: 50}, + {StartMs: 3600000, LenMin: 60, PriceOre: 200, SpotOre: 200}, } p := arbitrageCycleParams() p.MinArbitrageSpreadOreKwh = 25 // spread ~144 öre/kWh >> 25 @@ -67,7 +67,7 @@ func TestArbitrageThresholdDoesNotBlockWideSpread(t *testing.T) { // suppressed by an öre-level threshold, even in an arbitrage mode. func TestThresholdDoesNotSuppressCoverLoadDischarge(t *testing.T) { slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 250, SpotOre: 200, LoadW: 2000, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 250, SpotOre: 200, LoadW: 2000}, } p := baseParams(ModePassiveArbitrage) p.InitialSoC = 0.8 @@ -84,8 +84,8 @@ func TestThresholdDoesNotSuppressCoverLoadDischarge(t *testing.T) { // identical — the threshold biases the DP decision, never the accounting. func TestThresholdDoesNotAffectReportedCost(t *testing.T) { slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 50, SpotOre: 50, Confidence: 1}, - {StartMs: 3600000, LenMin: 60, PriceOre: 200, SpotOre: 200, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 50, SpotOre: 50}, + {StartMs: 3600000, LenMin: 60, PriceOre: 200, SpotOre: 200}, } base := arbitrageCycleParams() plan0 := Optimize(slots, base) @@ -108,7 +108,7 @@ func TestThresholdDoesNotAffectReportedCost(t *testing.T) { // self_consumption cover-load discharge is identical with and without it. func TestThresholdGatedOutOfSelfConsumption(t *testing.T) { slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 250, SpotOre: 200, LoadW: 2000, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 250, SpotOre: 200, LoadW: 2000}, } base := baseParams(ModeSelfConsumption) base.InitialSoC = 0.8 diff --git a/go/internal/mpc/native_bench_test.go b/go/internal/mpc/native_bench_test.go index 21be97260..93960837b 100644 --- a/go/internal/mpc/native_bench_test.go +++ b/go/internal/mpc/native_bench_test.go @@ -26,7 +26,7 @@ func nativeBenchmarkFixture(ev bool) ([]Slot, Params) { spot = 35 + 20*math.Sin(math.Pi*(h-5)/14) + 75*math.Exp(-(h-18)*(h-18)/3) } load := 450 + 900*math.Exp(-(h-7)*(h-7)/1.5) + 1400*math.Exp(-(h-19)*(h-19)/2) - slots[i] = Slot{StartMs: int64(i) * 900000, LenMin: 15, Confidence: 1, PVW: pv, LoadW: load, PriceOre: spot*1.25 + 95, SpotOre: spot, Limits: PowerLimits{MaxImportW: 11040, MaxExportW: 11040}} + slots[i] = Slot{StartMs: int64(i) * 900000, LenMin: 15, PVW: pv, LoadW: load, PriceOre: spot*1.25 + 95, SpotOre: spot, Limits: PowerLimits{MaxImportW: 11040, MaxExportW: 11040}} } if ev { p.Loadpoint = &LoadpointSpec{ID: "garage", CapacityWh: 60000, Levels: 11, InitialSoC: .35, SoCMax: 1, PluggedIn: true, TargetSoC: .8, TargetSlotIdx: 64, MaxChargeW: 11000, ChargeEfficiency: .9, AllowedStepsW: []float64{0, 4140, 6900, 11000}, NoBatteryToEV: true} diff --git a/go/internal/mpc/native_beta4_regression_test.go b/go/internal/mpc/native_beta4_regression_test.go index a2d3caf32..6aae81166 100644 --- a/go/internal/mpc/native_beta4_regression_test.go +++ b/go/internal/mpc/native_beta4_regression_test.go @@ -70,7 +70,7 @@ func TestNativeCapturedSiteReplay(t *testing.T) { } var slots []Slot for _, s := range q.Slots { - slots = append(slots, Slot{StartMs: s.StartMs, ExecutionStartMs: s.ExecutionStartMs, LenMin: s.LenMin, PriceOre: s.PriceOre, SpotOre: s.SpotOre, Confidence: s.Confidence, LoadW: s.LoadW, PVW: s.PVW, Limits: PowerLimits{MaxImportW: s.MaxImportW, MaxExportW: s.MaxExportW}}) + slots = append(slots, Slot{StartMs: s.StartMs, ExecutionStartMs: s.ExecutionStartMs, LenMin: s.LenMin, PriceOre: s.PriceOre, SpotOre: s.SpotOre, LoadW: s.LoadW, PVW: s.PVW, Limits: PowerLimits{MaxImportW: s.MaxImportW, MaxExportW: s.MaxExportW}}) } var response []byte if path := os.Getenv("FTW_NATIVE_CAPTURE_RESPONSE"); path != "" { diff --git a/go/internal/mpc/native_optimizer_test.go b/go/internal/mpc/native_optimizer_test.go index d6ae43365..fc1fa58b6 100644 --- a/go/internal/mpc/native_optimizer_test.go +++ b/go/internal/mpc/native_optimizer_test.go @@ -45,7 +45,6 @@ func TestNativeProcessCoreContract(t *testing.T) { floor := -5.0 p.ExportBonusOreKwh, p.ExportFeeOreKwh, p.ExportFloorOreKwh = 12, 3, &floor p.PVChargeBonusOreKwh, p.MinArbitrageSpreadOreKwh = 30, 20 - slots[0].Confidence, slots[1].Confidence = .4, .9 slots[0].PVW = -4500 for _, mode := range []Mode{ModeArbitrage, ModeSelfConsumption, ModePassiveArbitrage, ModeCheapCharge} { p.Mode = mode diff --git a/go/internal/mpc/params_validation.go b/go/internal/mpc/params_validation.go index 96a0c88d4..fa273801c 100644 --- a/go/internal/mpc/params_validation.go +++ b/go/internal/mpc/params_validation.go @@ -19,7 +19,6 @@ func validatePlanningSlots(slots []Slot) error { {"spot_ore", slot.SpotOre}, {"pv_w", slot.PVW}, {"load_w", slot.LoadW}, - {"confidence", slot.Confidence}, {"max_import_w", slot.Limits.MaxImportW}, {"max_export_w", slot.Limits.MaxExportW}, } { @@ -33,9 +32,6 @@ func validatePlanningSlots(slots []Slot) error { if slot.LoadW < 0 { return fmt.Errorf("%s.load_w must be non-negative in the site sign convention", field) } - if slot.Confidence <= 0 || slot.Confidence > 1 { - return fmt.Errorf("%s.confidence must be within (0, 1]", field) - } if slot.Limits.MaxImportW < 0 || slot.Limits.MaxExportW < 0 { return fmt.Errorf("%s grid limits must be non-negative", field) } diff --git a/go/internal/mpc/params_validation_test.go b/go/internal/mpc/params_validation_test.go index 89b97d58b..c9fa5646b 100644 --- a/go/internal/mpc/params_validation_test.go +++ b/go/internal/mpc/params_validation_test.go @@ -354,7 +354,7 @@ func TestValidatePlanningParamsRejectsInvalidLoadpointPhysics(t *testing.T) { } func TestValidatePlanningSlotsAndFleet(t *testing.T) { - validSlot := Slot{StartMs: 1, LenMin: 15, PriceOre: -100, SpotOre: -200, PVW: -500, LoadW: 1000, Confidence: 1} + validSlot := Slot{StartMs: 1, LenMin: 15, PriceOre: -100, SpotOre: -200, PVW: -500, LoadW: 1000} if err := validatePlanningSlots([]Slot{validSlot}); err != nil { t.Fatalf("valid slot rejected: %v", err) } @@ -373,8 +373,6 @@ func TestValidatePlanningSlotsAndFleet(t *testing.T) { {"infinite spot", "spot_ore", func(s *Slot) { s.SpotOre = math.Inf(1) }}, {"positive pv", "pv_w", func(s *Slot) { s.PVW = 1 }}, {"negative load", "load_w", func(s *Slot) { s.LoadW = -1 }}, - {"zero confidence", "confidence", func(s *Slot) { s.Confidence = 0 }}, - {"high confidence", "confidence", func(s *Slot) { s.Confidence = 1.1 }}, {"negative import limit", "grid limits", func(s *Slot) { s.Limits.MaxImportW = -1 }}, {"nan export limit", "max_export_w", func(s *Slot) { s.Limits.MaxExportW = math.NaN() }}, } @@ -443,7 +441,7 @@ func (o *physicsGateRecoveryOptimizer) Optimize(_ context.Context, slots []Slot, plan.Actions[i] = Action{ SlotStartMs: slot.StartMs, SlotLenMin: slot.LenMin, ExecutionStartMs: slot.ExecutionStartMs, PriceOre: slot.PriceOre, SpotOre: slot.SpotOre, - PVW: slot.PVW, LoadW: slot.LoadW, Confidence: slot.Confidence, + PVW: slot.PVW, LoadW: slot.LoadW, GridW: gridW, SoC: p.InitialSoC, CostOre: cost, } if len(p.Storages) > 0 { diff --git a/go/internal/mpc/parity_fixes_test.go b/go/internal/mpc/parity_fixes_test.go index c44c2935d..98b7cb9f2 100644 --- a/go/internal/mpc/parity_fixes_test.go +++ b/go/internal/mpc/parity_fixes_test.go @@ -25,48 +25,34 @@ func TestStrictSCBiasClampsNegativePrices(t *testing.T) { } } -// TestHorizonMeansAreLengthWeighted: with mixed 15/60-minute slots the -// unweighted mean over-counts the short ones. The mean feeds the -// confidence blend and the EV deadline penalty, so the error moves real -// decisions. -func TestHorizonMeansAreLengthWeighted(t *testing.T) { - p := Params{ExportBonusOreKwh: 10} +// TestHorizonMeanPriceIsLengthWeighted: with mixed 15/60-minute slots the +// unweighted mean over-counts the short ones. The mean feeds the EV +// deadline penalty, so the error moves real decisions. +func TestHorizonMeanPriceIsLengthWeighted(t *testing.T) { slots := []Slot{ {StartMs: 0, LenMin: 15, PriceOre: 100, SpotOre: 20}, {StartMs: 15 * 60 * 1000, LenMin: 60, PriceOre: 300, SpotOre: 60}, } - meanPrice, meanExport := horizonMeans(slots, p) + meanPrice := horizonMeanPrice(slots) const wantPrice = (100*15 + 300*60) / 75.0 // 260, not the unweighted 200 if math.Abs(meanPrice-wantPrice) > 1e-9 { t.Errorf("meanPrice = %v, want %v (unweighted would be 200)", meanPrice, wantPrice) } - wantExport := (SlotExportPriceOre(slots[0], p)*15 + SlotExportPriceOre(slots[1], p)*60) / 75.0 - if math.Abs(meanExport-wantExport) > 1e-9 { - t.Errorf("meanExport = %v, want %v", meanExport, wantExport) - } - unweightedExport := (SlotExportPriceOre(slots[0], p) + SlotExportPriceOre(slots[1], p)) / 2.0 - if math.Abs(wantExport-unweightedExport) < 1e-9 { - t.Fatal("fixture is degenerate: weighted and unweighted export means coincide") - } // Pure: the caller's slots come back untouched. - if slots[0].Confidence != 0 || slots[0].PriceOre != 100 || slots[1].LenMin != 60 { - t.Errorf("horizonMeans mutated its input: %+v", slots) + if slots[0].PriceOre != 100 || slots[1].LenMin != 60 { + t.Errorf("horizonMeanPrice mutated its input: %+v", slots) } } -// TestHorizonMeansFallBackToUnweighted covers the degenerate horizon -// where nothing carries a length — the means must still be finite. -func TestHorizonMeansFallBackToUnweighted(t *testing.T) { +// TestHorizonMeanPriceFallsBackToUnweighted covers the degenerate horizon +// where nothing carries a length — the mean must still be finite. +func TestHorizonMeanPriceFallsBackToUnweighted(t *testing.T) { slots := []Slot{{PriceOre: 100, SpotOre: 10}, {PriceOre: 300, SpotOre: 30}} - meanPrice, meanExport := horizonMeans(slots, Params{}) - if meanPrice != 200 { + if meanPrice := horizonMeanPrice(slots); meanPrice != 200 { t.Errorf("meanPrice = %v, want 200", meanPrice) } - if meanExport != 20 { - t.Errorf("meanExport = %v, want 20", meanExport) - } } // TestForwardSimStartsAtRealInitialSoC: the policy is looked up on the @@ -92,7 +78,7 @@ func TestForwardSimStartsAtRealInitialSoC(t *testing.T) { TerminalSoCPrice: 100, // == slot price → idle is optimal } slots := []Slot{{StartMs: 0, LenMin: 60, PriceOre: 100, SpotOre: 0, - LoadW: 0, PVW: 0, Confidence: 1}} + LoadW: 0, PVW: 0}} plan := Optimize(slots, p) if len(plan.Actions) != 1 { @@ -117,7 +103,7 @@ func TestPVBonusAppliesInEveryMode(t *testing.T) { // earns the 100 öre/kWh terminal credit on 0.95 of what goes in. // Exporting therefore wins on price alone — until the bonus. slots := []Slot{{StartMs: 0, LenMin: 60, PriceOre: 150, SpotOre: 110, - LoadW: 500, PVW: -3000, Confidence: 1}} + LoadW: 500, PVW: -3000}} base := Params{ Mode: ModeSelfConsumption, SoCMin: 0.1, @@ -167,9 +153,9 @@ func TestDiagnosticPersistsReplayEconomics(t *testing.T) { start := time.Date(2026, 8, 30, 10, 0, 0, 0, time.UTC).UnixMilli() slots := []Slot{ {StartMs: start, LenMin: 15, PriceOre: 100, SpotOre: 50, - PVW: -200, LoadW: 400, Confidence: 1.0}, + PVW: -200, LoadW: 400}, {StartMs: start + 15*60*1000, LenMin: 15, PriceOre: 150, SpotOre: 80, - PVW: -100, LoadW: 500, Confidence: 1.0}, + PVW: -100, LoadW: 500}, } p := Params{ Mode: ModeArbitrage, diff --git a/go/internal/mpc/power_limits_test.go b/go/internal/mpc/power_limits_test.go index ee3072d08..a25961f54 100644 --- a/go/internal/mpc/power_limits_test.go +++ b/go/internal/mpc/power_limits_test.go @@ -73,14 +73,13 @@ func TestOptimizeRespectsImportCap(t *testing.T) { // slot 1 should force the DP to stay under 2000 W net import. slots := []Slot{ {StartMs: 0, LenMin: 60, PriceOre: 10, SpotOre: 10, - LoadW: 500, Confidence: 1.0}, + LoadW: 500}, {StartMs: 3600_000, LenMin: 60, PriceOre: 10, SpotOre: 10, - LoadW: 500, Confidence: 1.0, - Limits: PowerLimits{MaxImportW: 2000}}, + LoadW: 500, Limits: PowerLimits{MaxImportW: 2000}}, {StartMs: 7200_000, LenMin: 60, PriceOre: 10, SpotOre: 10, - LoadW: 500, Confidence: 1.0}, + LoadW: 500}, {StartMs: 10800_000, LenMin: 60, PriceOre: 10, SpotOre: 10, - LoadW: 500, Confidence: 1.0}, + LoadW: 500}, } p := Params{ Mode: ModeCheapCharge, @@ -129,8 +128,7 @@ func TestOptimizeInfeasibleStatePicksNearIdle(t *testing.T) { // action grid). slots := []Slot{ {StartMs: 0, LenMin: 60, PriceOre: 50, SpotOre: 20, - LoadW: 500, Confidence: 1.0, - Limits: PowerLimits{MaxImportW: 1, MaxExportW: 1}}, + LoadW: 500, Limits: PowerLimits{MaxImportW: 1, MaxExportW: 1}}, } p := Params{ Mode: ModeSelfConsumption, @@ -160,8 +158,7 @@ func TestOptimizeInfeasibleStatePicksNearIdle(t *testing.T) { func TestOptimizeInfeasibleStatePicksIdleWithAsymmetricLimits(t *testing.T) { slots := []Slot{ {StartMs: 0, LenMin: 60, PriceOre: 50, SpotOre: 20, - LoadW: 500, Confidence: 1.0, - Limits: PowerLimits{MaxImportW: 1, MaxExportW: 1}}, + LoadW: 500, Limits: PowerLimits{MaxImportW: 1, MaxExportW: 1}}, } p := Params{ Mode: ModeSelfConsumption, @@ -193,12 +190,11 @@ func TestOptimizeRespectsExportCap(t *testing.T) { // decision in that slot must not exceed the cap. slots := []Slot{ {StartMs: 0, LenMin: 60, PriceOre: 100, SpotOre: 80, - PVW: -4000, LoadW: 500, Confidence: 1.0}, + PVW: -4000, LoadW: 500}, {StartMs: 3600_000, LenMin: 60, PriceOre: 100, SpotOre: 80, - PVW: -4000, LoadW: 500, Confidence: 1.0, - Limits: PowerLimits{MaxExportW: 500}}, + PVW: -4000, LoadW: 500, Limits: PowerLimits{MaxExportW: 500}}, {StartMs: 7200_000, LenMin: 60, PriceOre: 100, SpotOre: 80, - PVW: -4000, LoadW: 500, Confidence: 1.0}, + PVW: -4000, LoadW: 500}, } p := Params{ Mode: ModeArbitrage, @@ -233,8 +229,7 @@ func TestOptimizeRespectsFuseExportCap(t *testing.T) { // discharge that brings grid down to ≈ -11 kW, not -14 kW. slots := []Slot{ {StartMs: 0, LenMin: 60, PriceOre: 345, SpotOre: 156, - PVW: -6000, LoadW: 700, Confidence: 1.0, - Limits: PowerLimits{MaxImportW: 11000, MaxExportW: 11000}}, + PVW: -6000, LoadW: 700, Limits: PowerLimits{MaxImportW: 11000, MaxExportW: 11000}}, } p := Params{ Mode: ModeArbitrage, @@ -267,9 +262,8 @@ func TestOptimizeRespectsFuseExportCap(t *testing.T) { // side was silently uncapped. func TestFuseMaxWPopulatesBothDirections(t *testing.T) { slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 100, SpotOre: 50, Confidence: 1.0}, - {StartMs: 3600_000, LenMin: 60, PriceOre: 100, SpotOre: 50, Confidence: 1.0, - Limits: PowerLimits{MaxImportW: 5000, MaxExportW: 7000}}, + {StartMs: 0, LenMin: 60, PriceOre: 100, SpotOre: 50}, + {StartMs: 3600_000, LenMin: 60, PriceOre: 100, SpotOre: 50, Limits: PowerLimits{MaxImportW: 5000, MaxExportW: 7000}}, } const fuseW = 11000 // Inline the plumbing under test (mirrors service.go:560-573). diff --git a/go/internal/mpc/reason_test.go b/go/internal/mpc/reason_test.go index 230f67b94..ca3c00d82 100644 --- a/go/internal/mpc/reason_test.go +++ b/go/internal/mpc/reason_test.go @@ -96,7 +96,7 @@ func TestReasonForLabelsBranchOnGridW(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - s := Slot{LoadW: tc.loadW, PVW: tc.pvW, PriceOre: tc.priceOre, Confidence: 1.0} + s := Slot{LoadW: tc.loadW, PVW: tc.pvW, PriceOre: tc.priceOre} got := reasonFor(s, tc.batteryW, tc.gridW, tc.meanPrice) if !strings.Contains(got, tc.wantContain) { t.Errorf("reason = %q, want substring %q", got, tc.wantContain) diff --git a/go/internal/mpc/replay_bench_test.go b/go/internal/mpc/replay_bench_test.go index f753680fc..dc8e9fa99 100644 --- a/go/internal/mpc/replay_bench_test.go +++ b/go/internal/mpc/replay_bench_test.go @@ -189,7 +189,6 @@ func benchFixtureDiagnostic() *Diagnostic { LenMin: 15, PriceOre: price, SpotOre: price / 2, - Confidence: 1, LoadW: 1000, BatteryW: 0, GridW: 1000, diff --git a/go/internal/mpc/resolution_test.go b/go/internal/mpc/resolution_test.go index 0c9680379..7dfdf3ade 100644 --- a/go/internal/mpc/resolution_test.go +++ b/go/internal/mpc/resolution_test.go @@ -64,12 +64,11 @@ func TestFineGridStillAbsorbsBorderlinePV(t *testing.T) { pv = 0 } slots[i] = Slot{ - LenMin: 15, - PriceOre: price, - SpotOre: spot, - PVW: pv, - LoadW: load, - Confidence: 1.0, + LenMin: 15, + PriceOre: price, + SpotOre: spot, + PVW: pv, + LoadW: load, } } // Slot 0 nets 2300 − 693 = 1607 W of PV surplus; the regression is @@ -130,13 +129,12 @@ func resolutionBenchSlots() []Slot { load := 450 + 900*math.Exp(-(h-7)*(h-7)/1.5) + 1400*math.Exp(-(h-19)*(h-19)/2) slots[i] = Slot{ - StartMs: int64(i) * 15 * 60 * 1000, - LenMin: 15, - PriceOre: spot*1.25 + 95, // spot + grid fee + VAT - SpotOre: spot, - PVW: pv, - LoadW: load, - Confidence: 1.0, + StartMs: int64(i) * 15 * 60 * 1000, + LenMin: 15, + PriceOre: spot*1.25 + 95, // spot + grid fee + VAT + SpotOre: spot, + PVW: pv, + LoadW: load, } } return slots diff --git a/go/internal/mpc/self_consumption_horizon_test.go b/go/internal/mpc/self_consumption_horizon_test.go index fa44b1cf2..18d025ec0 100644 --- a/go/internal/mpc/self_consumption_horizon_test.go +++ b/go/internal/mpc/self_consumption_horizon_test.go @@ -51,7 +51,6 @@ func TestSelfConsumptionAbsorbsCheapPVOver48hHorizon(t *testing.T) { SpotOre: spot, PVW: pv, LoadW: load, - Confidence: 1.0, } } diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index c56d9a0c4..23207f0d4 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -39,12 +39,6 @@ type PVResidualCorrector func(now, tTarget time.Time, basePrediction float64) fl // *loadmodel.Service.Predict. Leave nil to fall back to Service.BaseLoad. type LoadPredictor func(t time.Time) float64 -// PricePredictor fills in spot price for future slots that the day-ahead -// source hasn't published yet. Implemented by -// *priceforecast.Service.Predict. Returns ÖRE/kWh spot (no tariff/VAT). -// Leave nil to cap the plan horizon at what's been published. -type PricePredictor func(zone string, t time.Time) float64 - // plannerWeatherLookback keeps the hourly weather row that can overlap the // first price slot in a plan or twin-drift comparison. Production forecast // writers store 60-minute rows. @@ -118,7 +112,6 @@ type Service struct { // forecast PV minus k·σ. 0 = raw forecast (no hedge). main.go defaults the // unset config to 1.0. PVForecastSafetyK float64 - Price PricePredictor // optional — fills in future slots when day-ahead isn't published yet Loadpoint LoadpointProbe // optional — when non-nil, the DP extends its state with EV dimensions Loadpoints LoadpointsProbe @@ -224,11 +217,8 @@ type Service struct { // no clamp, real spot pass-through (default). ExportFloorOreKwh *float64 - // GridTariffOreKwh and VATPercent let the MPC turn forecast spot - // prices into consumer-total prices when back-filling future slots - // using s.Price. Mirrors prices.Applier semantics. - GridTariffOreKwh float64 - VATPercent float64 + // VATPercent applies to the demand charge, like the slot prices. + VATPercent float64 // DemandPricePerKW is the weekday 06–20 peak-power tariff in the same // minor units as slot prices, excluding VAT. Zero disables it. DemandPricePerKW float64 @@ -335,7 +325,7 @@ func New(st *state.Store, tl *telemetry.Store, zone string, p Params) *Service { Tele: tl, Zone: zone, Defaults: p, - Horizon: 48 * time.Hour, // always plan 48h — forecaster fills beyond day-ahead + Horizon: 48 * time.Hour, // an upper bound: the plan covers the published prices Interval: 15 * time.Minute, ReactiveInterval: 10 * time.Second, // Tightened 2026-05: lower thresholds + shorter half-life + shorter @@ -1466,15 +1456,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { if request.wasCanceledByService() { return s.canceledReplan(request, "load-prices") } - // Extend prices into the horizon using the learned forecast when - // the day-ahead source hasn't published that far yet. Otherwise - // the plan silently truncates the moment we pass the published - // cutoff — operators lose overnight planning exactly when they'd - // most want it. - if s.Price != nil { - prices = extendPricesWithForecast(prices, s.Zone, s.Price, - now.UnixMilli(), untilMs, s.GridTariffOreKwh, s.VATPercent) - } + // The plan covers the published prices and nothing beyond them. if len(prices) == 0 { slog.Info("mpc: no prices available yet") return nil @@ -1953,23 +1935,18 @@ func (s *Service) runReplan(request replanRequest) *Plan { } // Horizon statistics — surfaced in logs so operators can // reconstruct "what did the DP know?" without pulling the full - // Diagnostic JSON. Captures the three factors most likely to - // explain a surprising decision: mean price level, mean data - // confidence (how much of the horizon is forecast vs day-ahead), - // and the capacity envelope. - var sumPrice, sumConf float64 + // Diagnostic JSON: the mean price level, how far the published + // prices reach, and the capacity envelope. + var sumPrice float64 for i := range slots { sumPrice += slots[i].PriceOre - c := slots[i].Confidence - if c <= 0 { - c = 1.0 - } - sumConf += c } - var meanPrice, meanConf float64 + var meanPrice float64 + var pricesUntil time.Time if n := len(slots); n > 0 { meanPrice = sumPrice / float64(n) - meanConf = sumConf / float64(n) + last := slots[n-1] + pricesUntil = time.UnixMilli(last.StartMs + int64(last.LenMin)*60*1000) } slog.Info("mpc: replanned", "decision_id", plan.DecisionID, @@ -1978,7 +1955,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { "cost_ore", plan.TotalCostOre, "reason", reason, "mean_price_ore", meanPrice, - "mean_confidence", meanConf, + "prices_until", pricesUntil, "terminal_soc_price_ore", p.TerminalSoCPrice, "capacity_wh", p.CapacityWh, "max_charge_w", p.MaxChargeW, @@ -2085,77 +2062,6 @@ func (s *Service) LastReplanInfo() (time.Time, string) { return s.lastReplanAt, s.lastReason } -// extendPricesWithForecast appends synthesized price rows for slots between -// the last published price and `untilMs`. -// -// The hour-of-week climatology is a typical day, not tomorrow. Jumping -// straight to it at the day-ahead cut-off produces a fake overnight -// crash (200+ öre at 23:00 → 60–80 öre after midnight) that tells -// active arbitrage to wait and skip charging. Blend from the last -// published spot toward climatology with a 6 h e-folding so the first -// unpublished hours follow the curve the operator just saw. Synthesized -// rows are tagged `source="forecast"` so the UI can distinguish them. -const forecastPersistTauH = 6.0 - -func extendPricesWithForecast(prices []state.PricePoint, zone string, pricer PricePredictor, nowMs, untilMs int64, gridTariff, vatPct float64) []state.PricePoint { - // Find the latest published slot end and its spot. - var latestEndMs int64 - var lastSpot float64 - haveLast := false - slotLen := 60 - for _, p := range prices { - sl := p.SlotLenMin - if sl <= 0 { - sl = 60 - } - end := p.SlotTsMs + int64(sl)*60*1000 - if end > latestEndMs { - latestEndMs = end - lastSpot = p.SpotOreKwh - haveLast = true - } - if sl > 0 { - slotLen = sl - } - } - // If published already covers the horizon, nothing to do. - if latestEndMs >= untilMs { - return prices - } - // Start synthesizing from the later of (latestEndMs, nowMs). - start := latestEndMs - if start < nowMs { - start = nowMs - } - // Round down to the slotLen grid. - mod := start % (int64(slotLen) * 60 * 1000) - start -= mod - for ts := start; ts < untilMs; ts += int64(slotLen) * 60 * 1000 { - t := time.UnixMilli(ts).UTC() - climatology := pricer(zone, t) - spot := climatology - if haveLast { - hoursAhead := float64(ts-latestEndMs) / float64(time.Hour.Milliseconds()) - if hoursAhead < 0 { - hoursAhead = 0 - } - w := math.Exp(-hoursAhead / forecastPersistTauH) - spot = w*lastSpot + (1-w)*climatology - } - total := (spot + gridTariff) * (1 + vatPct/100.0) - prices = append(prices, state.PricePoint{ - Zone: zone, - SlotTsMs: ts, - SlotLenMin: slotLen, - SpotOreKwh: spot, - TotalOreKwh: total, - Source: "forecast", - FetchedAtMs: nowMs, - }) - } - return prices -} - // buildSlots joins price rows with forecast rows by start time. Prices drive // slot count + duration; forecast PV is interpolated forward (last valid // value carries) because forecast is usually hourly while prices are 15-min. @@ -2222,13 +2128,6 @@ func buildSlots(prices []state.PricePoint, forecasts []state.ForecastPoint, base if load != nil { loadW = load(slotT) } - // Confidence from the price source: real day-ahead → 1.0, - // ML-forecasted → 0.6 (user-tunable hook for later). Anything - // else (seed data, Sourceful, ENTSOE, elprisetjustnu) → 1.0 too. - conf := 1.0 - if pr.Source == "forecast" { - conf = 0.6 - } slot := Slot{ StartMs: pr.SlotTsMs, LenMin: slotLen, @@ -2236,7 +2135,6 @@ func buildSlots(prices []state.PricePoint, forecasts []state.ForecastPoint, base SpotOre: pr.SpotOreKwh, PVW: -math.Abs(pvW), LoadW: loadW, - Confidence: conf, InputProvenanceSchema: inputProvenanceSchemaVersion, PriceInputSource: pr.Source, PriceInputAvailableAtMs: pr.FetchedAtMs, diff --git a/go/internal/mpc/service_test.go b/go/internal/mpc/service_test.go index 438932bbe..54ec1a497 100644 --- a/go/internal/mpc/service_test.go +++ b/go/internal/mpc/service_test.go @@ -83,7 +83,7 @@ func TestBuildSlotsCarriesInputProvenance(t *testing.T) { }, { SlotTsMs: start + time.Hour.Milliseconds(), SlotLenMin: 15, - SpotOreKwh: 70, TotalOreKwh: 130, Source: "forecast", FetchedAtMs: 222, + SpotOreKwh: 70, TotalOreKwh: 130, Source: "elprisetjustnu", FetchedAtMs: 222, }, } forecasts := []state.ForecastPoint{ @@ -103,14 +103,12 @@ func TestBuildSlotsCarriesInputProvenance(t *testing.T) { } if got := slots[0]; got.InputProvenanceSchema != inputProvenanceSchemaVersion || got.PriceInputSource != "entsoe" || got.PriceInputAvailableAtMs != 111 || - got.WeatherRowSource != "met.no" || got.WeatherRowAvailableAtMs != 333 || - got.Confidence != 1 { + got.WeatherRowSource != "met.no" || got.WeatherRowAvailableAtMs != 333 { t.Fatalf("first slot provenance = %+v", got) } if got := slots[1]; got.InputProvenanceSchema != inputProvenanceSchemaVersion || - got.PriceInputSource != "forecast" || got.PriceInputAvailableAtMs != 222 || - got.WeatherRowSource != "open-meteo" || got.WeatherRowAvailableAtMs != 444 || - got.Confidence != 0.6 { + got.PriceInputSource != "elprisetjustnu" || got.PriceInputAvailableAtMs != 222 || + got.WeatherRowSource != "open-meteo" || got.WeatherRowAvailableAtMs != 444 { t.Fatalf("second slot provenance = %+v", got) } @@ -125,92 +123,6 @@ func TestBuildSlotsCarriesInputProvenance(t *testing.T) { } } -func TestSynthesizedPriceCarriesCreationProvenance(t *testing.T) { - now := time.Date(2026, 4, 15, 10, 0, 0, 0, time.UTC) - prices := extendPricesWithForecast(nil, "SE3", - func(string, time.Time) float64 { return 42 }, - now.UnixMilli(), now.Add(time.Hour).UnixMilli(), 0, 0) - if len(prices) != 1 { - t.Fatalf("extendPricesWithForecast returned %d rows, want 1", len(prices)) - } - slots := buildSlots(prices, nil, 500, now.UnixMilli(), nil, nil, nil) - if len(slots) != 1 { - t.Fatalf("buildSlots returned %d slots, want 1", len(slots)) - } - if got := slots[0]; got.InputProvenanceSchema != inputProvenanceSchemaVersion || - got.PriceInputSource != "forecast" || - got.PriceInputAvailableAtMs != now.UnixMilli() || got.Confidence != 0.6 { - t.Fatalf("synthesized price provenance = %+v", got) - } -} - -func TestForecastPricePersistsLastKnownInsteadOfClimatologyCliff(t *testing.T) { - now := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC) - last := state.PricePoint{ - Zone: "SE3", SlotTsMs: now.UnixMilli(), SlotLenMin: 60, - SpotOreKwh: 200, TotalOreKwh: 280, Source: "entsoe", - } - prices := extendPricesWithForecast( - []state.PricePoint{last}, - "SE3", - func(string, time.Time) float64 { return 70 }, - now.UnixMilli(), - now.Add(2*time.Hour).UnixMilli(), - 0, 0, - ) - if len(prices) < 2 { - t.Fatalf("got %d prices, want published + forecast", len(prices)) - } - var forecast []state.PricePoint - for _, p := range prices { - if p.Source == "forecast" { - forecast = append(forecast, p) - } - } - if len(forecast) == 0 { - t.Fatal("no forecast rows") - } - first := forecast[0] - if first.SpotOreKwh < 150 { - t.Errorf("first unpublished hour jumped to climatology: got %.1f, want near last-known 200 (not 70)", first.SpotOreKwh) - } - if first.SpotOreKwh > 201 { - t.Errorf("first unpublished hour overshot last-known: got %.1f", first.SpotOreKwh) - } -} - -func TestForecastPriceFadesTowardClimatologyOverHours(t *testing.T) { - now := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC) - last := state.PricePoint{ - Zone: "SE3", SlotTsMs: now.UnixMilli(), SlotLenMin: 60, - SpotOreKwh: 200, TotalOreKwh: 280, Source: "entsoe", - } - prices := extendPricesWithForecast( - []state.PricePoint{last}, - "SE3", - func(string, time.Time) float64 { return 70 }, - now.UnixMilli(), - now.Add(13*time.Hour).UnixMilli(), - 0, 0, - ) - var forecast []state.PricePoint - for _, p := range prices { - if p.Source == "forecast" { - forecast = append(forecast, p) - } - } - if len(forecast) < 12 { - t.Fatalf("got %d forecast rows, want >= 12", len(forecast)) - } - late := forecast[len(forecast)-1] - if late.SpotOreKwh > 120 { - t.Errorf("12 h out should have faded toward climatology 70, got %.1f", late.SpotOreKwh) - } - if late.SpotOreKwh >= forecast[0].SpotOreKwh { - t.Errorf("later forecast %.1f should be below first-hour persist %.1f", late.SpotOreKwh, forecast[0].SpotOreKwh) - } -} - func TestBuildSlotsWeatherProvenanceFollowsTwinCloudInput(t *testing.T) { weatherStart := time.Date(2026, 4, 15, 10, 0, 0, 0, time.UTC) priceStart := weatherStart.Add(45 * time.Minute) @@ -734,10 +646,10 @@ func TestSelfConsumptionTerminalPriceEmpty(t *testing.T) { func TestOptimizeSelfConsumptionDischargesWithSpreadTerminalPrice(t *testing.T) { // 4-slot horizon, PV < load in every slot so battery has work to do. slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500, Confidence: 1}, - {StartMs: 3600 * 1000, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500, Confidence: 1}, - {StartMs: 7200 * 1000, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500, Confidence: 1}, - {StartMs: 10800 * 1000, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500}, + {StartMs: 3600 * 1000, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500}, + {StartMs: 7200 * 1000, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500}, + {StartMs: 10800 * 1000, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500}, } // Build PricePoints identical to the slots and compute the @@ -997,8 +909,8 @@ func TestSelectPlannerPVWForecastCapInactiveWhenTwinNearZero(t *testing.T) { // the mode name implies. func TestOptimizeSelfConsumptionDischargesDespiteHighTerminal(t *testing.T) { slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500, Confidence: 1}, - {StartMs: 3600 * 1000, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500, Confidence: 1}, + {StartMs: 0, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500}, + {StartMs: 3600 * 1000, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500}, } p := baseParams(ModeSelfConsumption) p.InitialSoC = 0.8 diff --git a/go/internal/mpc/strict_sc_test.go b/go/internal/mpc/strict_sc_test.go index 342fe0a6c..1642ae1f9 100644 --- a/go/internal/mpc/strict_sc_test.go +++ b/go/internal/mpc/strict_sc_test.go @@ -14,9 +14,9 @@ import ( func TestStrictSelfConsumptionDischargesWhenSoCHealthy(t *testing.T) { slots := []Slot{ {StartMs: 0, LenMin: 60, PriceOre: 166, SpotOre: 63, - LoadW: 3480, PVW: -1390, Confidence: 1.0}, + LoadW: 3480, PVW: -1390}, {StartMs: 3600 * 1000, LenMin: 60, PriceOre: 165, SpotOre: 63, - LoadW: 3480, PVW: -1985, Confidence: 1.0}, + LoadW: 3480, PVW: -1985}, } p := baseParams(ModeSelfConsumption) p.InitialSoC = 0.5 @@ -60,11 +60,11 @@ func TestStrictSelfConsumptionDischargesWhenSoCHealthy(t *testing.T) { func TestStrictSelfConsumptionDoesNotStarveEVDeadline(t *testing.T) { slots := []Slot{ {StartMs: 0, LenMin: 60, PriceOre: 400, SpotOre: 200, - LoadW: 500, PVW: -200, Confidence: 1.0}, + LoadW: 500, PVW: -200}, {StartMs: 3600 * 1000, LenMin: 60, PriceOre: 100, SpotOre: 40, - LoadW: 500, PVW: -200, Confidence: 1.0}, + LoadW: 500, PVW: -200}, {StartMs: 2 * 3600 * 1000, LenMin: 60, PriceOre: 100, SpotOre: 40, - LoadW: 500, PVW: -200, Confidence: 1.0}, + LoadW: 500, PVW: -200}, } p := baseParams(ModeSelfConsumption) p.InitialSoC = 0.8 // well above floor+20 @@ -152,7 +152,7 @@ func TestUpdatePlannerScalarsPropagatesToDefaults(t *testing.T) { // but makes the test assert nothing useful about the bias. func TestStrictSelfConsumptionRespectsFloor(t *testing.T) { slots := []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 166, LoadW: 3480, PVW: -1390, Confidence: 1.0}, + {StartMs: 0, LenMin: 60, PriceOre: 166, LoadW: 3480, PVW: -1390}, } p := baseParams(ModeSelfConsumption) p.InitialSoC = 0.22 // comfortably above min (10) but well below the old min+20 buffer @@ -182,7 +182,7 @@ func TestStrictSelfConsumptionRespectsFloor(t *testing.T) { func TestStrictSelfConsumptionDischargesBelowOldBufferAtHighPrice(t *testing.T) { slots := []Slot{ {StartMs: 0, LenMin: 60, PriceOre: 206, SpotOre: 80, - LoadW: 5300, PVW: 0, Confidence: 1.0}, + LoadW: 5300, PVW: 0}, } p := baseParams(ModeSelfConsumption) p.InitialSoC = 0.28 // just below the old floor+20 threshold (10+20) diff --git a/go/internal/mpc/validate_dp_test.go b/go/internal/mpc/validate_dp_test.go index 8f7d88da9..34325dd6f 100644 --- a/go/internal/mpc/validate_dp_test.go +++ b/go/internal/mpc/validate_dp_test.go @@ -23,7 +23,7 @@ func TestOptimizePlansPassValidatePlan(t *testing.T) { { name: "self_consumption pv surplus", slots: []Slot{ - {StartMs: 0, LenMin: 60, PriceOre: 100, Confidence: 1, LoadW: 2000, PVW: -3500}, + {StartMs: 0, LenMin: 60, PriceOre: 100, LoadW: 2000, PVW: -3500}, }, p: baseParams(ModeSelfConsumption), }, @@ -53,8 +53,7 @@ func TestOptimizePlansPassValidatePlan(t *testing.T) { func TestValidatePlanAcceptsGoDPCurtailHint(t *testing.T) { slots := []Slot{{ - StartMs: 1, LenMin: 60, PriceOre: 100, SpotOre: -50, Confidence: 1, - LoadW: 500, PVW: -5000, + StartMs: 1, LenMin: 60, PriceOre: 100, SpotOre: -50, LoadW: 500, PVW: -5000, }} p := baseParams(ModeSelfConsumption) p.InitialSoC = 0.90 @@ -76,8 +75,7 @@ func TestValidatePlanAcceptsGoDPCurtailHint(t *testing.T) { func TestValidatePlanRejectsFuseViolatingIdle(t *testing.T) { slots := []Slot{{ - StartMs: 1, LenMin: 60, PriceOre: 100, Confidence: 1, - LoadW: 0, PVW: -8000, + StartMs: 1, LenMin: 60, PriceOre: 100, LoadW: 0, PVW: -8000, Limits: PowerLimits{MaxExportW: 100}, }} p := baseParams(ModeSelfConsumption) @@ -96,8 +94,7 @@ func TestValidatePlanRejectsFuseViolatingIdle(t *testing.T) { func TestValidatePlanRejectsUndispatchableZeroPVCap(t *testing.T) { slots := []Slot{{ - StartMs: 1, LenMin: 60, PriceOre: 100, SpotOre: -100, Confidence: 1, - LoadW: 0, PVW: -5000, + StartMs: 1, LenMin: 60, PriceOre: 100, SpotOre: -100, LoadW: 0, PVW: -5000, }} p := baseParams(ModeArbitrage) p.InitialSoC = 0.95 @@ -158,7 +155,7 @@ func TestOptimizeReplaysFromActualSoCBelowMinimum(t *testing.T) { func TestOptimizeDoesNotWorsenOperatingBoundNearFloor(t *testing.T) { slots := []Slot{{ - StartMs: 0, LenMin: 60, PriceOre: 300, Confidence: 1, LoadW: 2000, PVW: 0, + StartMs: 0, LenMin: 60, PriceOre: 300, LoadW: 2000, PVW: 0, }} p := baseParams(ModeSelfConsumption) p.InitialSoC = p.SoCMin + 0.001 diff --git a/go/internal/priceforecast/forecast.go b/go/internal/priceforecast/forecast.go deleted file mode 100644 index 34347d1b5..000000000 --- a/go/internal/priceforecast/forecast.go +++ /dev/null @@ -1,524 +0,0 @@ -// Package priceforecast estimates spot prices for future slots that -// the day-ahead source hasn't published yet. -// -// Day-ahead auctions typically publish tomorrow's prices around 13:00 -// CET. Before that, the MPC horizon is effectively capped at "end of -// today" — which means night-time planning of an overnight arbitrage -// run is impossible right when operators most want it. -// -// We learn a simple hour-of-week × month profile from the rolling -// price history we already store in SQLite. The model is pragmatic, -// not predictive of market shocks: it assumes tomorrow looks like a -// typical week-hour in this season. That's wrong during gas crises -// and cold snaps — but still closer to the truth than "no price at -// all", which causes the MPC to silently shorten its plan. -// -// Features (7): -// -// bucket(weekday, hour) — 168 EMA cells (spot öre/kWh) -// month_modifier — ratio: this month's mean / annual mean -// (computed lazily, not stored explicitly — folded into bucket) -// -// The model is zone-aware: each bidding zone trains independently -// because SE3 and SE4 behave very differently at peak hours. -// -// Counts and MAE are diagnostics for refit logs and Model() snapshots. -// Predict returns the blended climatology; the MPC blends toward it -// with a time e-folding, not via these fields. -package priceforecast - -import ( - "context" - "encoding/csv" - "encoding/json" - "fmt" - "io" - "log/slog" - "math" - "os" - "sort" - "strconv" - "strings" - "sync" - "time" - - // Embedded zoneinfo so hour-of-week buckets cannot silently fall - // back to UTC when the host has no tzdata (tests, stripped images). - _ "time/tzdata" - - "github.com/srcfl/ftw/go/internal/state" -) - -// Buckets is 7 days × 24 hours = 168. -const Buckets = 168 - -// MinTrustSamples — a bucket needs this many observations before we -// trust it fully. Below, we blend with the global mean. -const MinTrustSamples = 4 - -// MonthlyModifier: multiplicative seasonal factor per month (Jan..Dec). -// Derived from the ZoneModel at refit time — NOT persisted as separate -// state, recomputed from bucket data. -type ZoneModel struct { - Zone string `json:"zone"` - Bucket [Buckets]float64 `json:"bucket"` // EMA öre/kWh (raw spot) - Counts [Buckets]int64 `json:"counts"` - Month [12]float64 `json:"month"` // monthly multiplier (normalized) - Samples int64 `json:"samples"` - MAE float64 `json:"mae"` // EMA of |actual − predicted| - Alpha float64 `json:"alpha"` // EMA coefficient - FittedAt int64 `json:"fitted_at"` -} - -// bakedPrior returns the typical-Nordic hour-of-week prior shape for a -// zone. Used both as cold-start seed and as the Bayesian prior that -// FitFromHistory blends fitted values against — so sparse history -// doesn't wipe out the shape. -// -// - morning ramp 06:00–09:00 peaking around 08:00 -// - midday trough 11:00–14:00 (solar flood, industrial slack) -// - evening peak 17:00–20:00 peaking around 19:00 -// - overnight baseline 00:00–05:00 -// - weekend ~15% lower at peaks -func bakedPrior(zone string) ([Buckets]float64, [12]float64) { - var bucket [Buckets]float64 - base := 60.0 - switch zone { - case "SE3", "SE4", "DK1", "DK2", "DE": - base = 80 - case "NO2", "FI": - base = 70 - case "SE1", "SE2", "NO1", "NO3", "NO4": - base = 50 - } - for d := 0; d < 7; d++ { - isWeekend := d >= 5 - for h := 0; h < 24; h++ { - shape := 1.0 - switch { - case h >= 7 && h <= 9: - shape = 1.6 - case h >= 17 && h <= 20: - shape = 1.85 - case h >= 11 && h <= 14: - shape = 0.55 - case h >= 0 && h <= 5: - shape = 0.65 - case h == 6 || h == 10: - shape = 1.15 - case h == 15 || h == 16: - shape = 1.05 - case h >= 21 && h <= 23: - shape = 1.1 - } - if isWeekend { - shape = 0.85 + 0.15*(shape-0.85) - } - bucket[d*24+h] = base * shape - } - } - month := [12]float64{ - 1.35, 1.30, 1.10, 0.95, 0.85, 0.75, - 0.70, 0.75, 0.90, 1.05, 1.20, 1.40, - } - return bucket, month -} - -// NewZoneModel seeds with the baked prior so day-0 predictions already -// look like a typical Nordic day. -func NewZoneModel(zone string) *ZoneModel { - m := &ZoneModel{Zone: zone, Alpha: 0.15} - m.Bucket, m.Month = bakedPrior(zone) - for i := 0; i < Buckets; i++ { - m.Counts[i] = MinTrustSamples // so Predict() fully trusts the prior - } - return m -} - -// Predict returns the expected spot öre/kWh at t for zone. The bucket -// value is already prior-blended via FitFromHistory, so we just apply -// the monthly seasonality. -// -// Indexes hour-of-week and month on the Europe/Stockholm civil clock -// so the baked Nordic prior (evening 17–20 local) lines up with CET/CEST -// peaks. FitFromHistory uses the same conversion, and the same instant -// always hits the same bucket regardless of t's attached Location. -func (m ZoneModel) Predict(t time.Time) float64 { - c := civil(t) - return m.Bucket[hourOfWeek(t)] * m.Month[int(c.Month())-1] -} - -// overallMean across buckets weighted by counts. -func (m ZoneModel) overallMean() float64 { - var sumW, sumWX float64 - for i := 0; i < Buckets; i++ { - w := float64(m.Counts[i]) - if w == 0 { - w = 1 // include prior evenly - } - sumW += w - sumWX += m.Bucket[i] * w - } - if sumW == 0 { - return 80 - } - return sumWX / sumW -} - -// PriorWeight is the "virtual sample count" for the baked prior when -// blending with fitted data. Higher = prior persists longer before -// real data wins. With priorWeight=8, two real samples give 80% prior -// + 20% fitted; after 40 samples we're 17% prior + 83% fitted. -const PriorWeight = 8.0 - -// FitFromHistory rebuilds the model from stored prices for this zone. -// Uses a Bayesian blend with the baked prior so sparse history doesn't -// collapse the learned shape. Call periodically (e.g. every 6h). -func (m *ZoneModel) FitFromHistory(pts []state.PricePoint) { - if len(pts) == 0 { - return - } - // Re-derive the baked prior so we can blend correctly every time - // (previous fitted values aren't "prior" in the Bayesian sense — - // they already include data, so double-counting would bias). - prior, priorMonth := bakedPrior(m.Zone) - - // Group observed prices by hour-of-week. - var sum [Buckets]float64 - var cnt [Buckets]int64 - var monthSum [12]float64 - var monthCnt [12]int64 - for _, p := range pts { - t := civil(time.UnixMilli(p.SlotTsMs)) - idx := hourOfWeek(t) - sum[idx] += p.SpotOreKwh - cnt[idx]++ - mi := int(t.Month()) - 1 - monthSum[mi] += p.SpotOreKwh - monthCnt[mi]++ - } - - // Bayesian blend per bucket: - // posterior = (priorValue × priorWeight + data_sum) / (priorWeight + data_count) - // Count clamp: Counts[i] = min(cnt[i] + PriorWeight, MinTrustSamples) - // so Predict() still sees "trust = 1" from the prior floor. - for i := 0; i < Buckets; i++ { - numer := prior[i]*PriorWeight + sum[i] - denom := PriorWeight + float64(cnt[i]) - m.Bucket[i] = numer / denom - // Counts reflects REAL samples only — used by the tests + UI - // to show confidence, but trust in Predict() uses a floor of - // MinTrustSamples so prior-only buckets still return the prior. - m.Counts[i] = cnt[i] + int64(PriorWeight) - } - - // Month multipliers: same blend. Normalize month to give ratios - // vs. overall mean. - overall := m.overallMean() - if overall > 0 { - for mi := 0; mi < 12; mi++ { - priorM := priorMonth[mi] - if monthCnt[mi] > 0 { - observedRatio := (monthSum[mi] / float64(monthCnt[mi])) / overall - // Blend ratios, not absolute means. - numer := priorM*PriorWeight + observedRatio*float64(monthCnt[mi]) - denom := PriorWeight + float64(monthCnt[mi]) - m.Month[mi] = numer / denom - } else { - m.Month[mi] = priorM - } - } - } - - // MAE: fit quality on history itself. - var abserr float64 - for _, p := range pts { - t := time.UnixMilli(p.SlotTsMs) - abserr += math.Abs(p.SpotOreKwh - m.Predict(t)) - } - m.MAE = abserr / float64(len(pts)) - m.Samples = int64(len(pts)) - m.FittedAt = time.Now().UnixMilli() -} - -// bucketTZ is the civil clock for hour-of-week and month buckets. -// The baked prior is a Nordic local-hour shape; UTC indexing put CEST -// evening peaks two hours late (19:00 CEST = 17:00 UTC). Stockholm is -// CET/CEST, matching SE1–SE4 / DK / NO / DE. -var bucketTZ = mustLoadTZ("Europe/Stockholm") - -func mustLoadTZ(name string) *time.Location { - loc, err := time.LoadLocation(name) - if err != nil { - return time.UTC - } - return loc -} - -func civil(t time.Time) time.Time { return t.In(bucketTZ) } - -// hourOfWeek: Mon=0..Sun=6 × 24 on the Europe/Stockholm civil clock. -// 19:00 CET and 19:00 CEST share a bucket, matching the baked evening -// peak. The same instant presented as UTC or local still agrees. -func hourOfWeek(t time.Time) int { - c := civil(t) - wd := (int(c.Weekday()) + 6) % 7 - return wd*24 + c.Hour() -} - -// ---- Service ---- - -const stateKey = "pricefc/state" - -// RefitInterval is how often we recompute the model from stored history. -const RefitInterval = 6 * time.Hour - -// Service manages per-zone models. Refits in the background. -type Service struct { - Store *state.Store - Zones []string - - mu sync.RWMutex - models map[string]*ZoneModel - - stop chan struct{} - done chan struct{} -} - -// NewService creates a service covering the given zones. -func NewService(st *state.Store, zones []string) *Service { - s := &Service{ - Store: st, - Zones: zones, - models: map[string]*ZoneModel{}, - stop: make(chan struct{}), - done: make(chan struct{}), - } - // Restore from state if available. - if st != nil { - if js, ok := st.LoadConfig(stateKey); ok && js != "" { - var restored map[string]*ZoneModel - if err := json.Unmarshal([]byte(js), &restored); err == nil { - s.models = restored - slog.Info("priceforecast restored", - "zones", len(restored)) - } - } - } - // Ensure all configured zones have a model. - for _, z := range zones { - if _, ok := s.models[z]; !ok { - s.models[z] = NewZoneModel(z) - } - } - return s -} - -// Predict returns the spot price forecast öre/kWh for zone at time t. -// Falls back to 80 öre if the zone is unknown. -func (s *Service) Predict(zone string, t time.Time) float64 { - if s == nil { - return 80 - } - s.mu.RLock() - defer s.mu.RUnlock() - m, ok := s.models[zone] - if !ok { - return 80 - } - return m.Predict(t) -} - -// Model returns a snapshot of the zone model (nil if unknown). -func (s *Service) Model(zone string) *ZoneModel { - if s == nil { - return nil - } - s.mu.RLock() - defer s.mu.RUnlock() - m, ok := s.models[zone] - if !ok { - return nil - } - // Return a copy so caller can't mutate under the lock. - cp := *m - return &cp -} - -// Start begins the periodic refit loop. -func (s *Service) Start(ctx context.Context) { - if s == nil { - return - } - go s.loop(ctx) -} - -// Stop terminates the refit loop. -func (s *Service) Stop() { - if s == nil { - return - } - close(s.stop) - <-s.done -} - -func (s *Service) loop(ctx context.Context) { - defer close(s.done) - s.refit() - t := time.NewTicker(RefitInterval) - defer t.Stop() - for { - select { - case <-s.stop: - s.persist() - return - case <-ctx.Done(): - s.persist() - return - case <-t.C: - s.refit() - } - } -} - -func (s *Service) refit() { - if s.Store == nil { - return - } - // Pull the last ~90 days of prices for each zone. - since := time.Now().AddDate(0, 0, -90).UnixMilli() - until := time.Now().Add(30 * 24 * time.Hour).UnixMilli() - for _, z := range s.Zones { - pts, err := s.Store.LoadPrices(z, since, until) - if err != nil { - slog.Warn("priceforecast: load", "zone", z, "err", err) - continue - } - if len(pts) < 24 { - continue // not enough history to fit - } - sort.Slice(pts, func(i, j int) bool { return pts[i].SlotTsMs < pts[j].SlotTsMs }) - s.mu.Lock() - m, ok := s.models[z] - if !ok { - m = NewZoneModel(z) - s.models[z] = m - } - m.FitFromHistory(pts) - s.mu.Unlock() - slog.Info("priceforecast: refit", - "zone", z, - "samples", len(pts), - "mae_ore", m.MAE) - } - s.persist() -} - -func (s *Service) persist() { - if s.Store == nil { - return - } - s.mu.RLock() - js, err := json.Marshal(s.models) - s.mu.RUnlock() - if err != nil { - return - } - _ = s.Store.SaveConfig(stateKey, string(js)) -} - -// SeedFromCSV cold-starts the model by importing historical prices -// from a CSV file into the state DB and then refitting. Expected -// format (header row required): -// -// zone,slot_ts_ms,slot_len_min,spot_ore_kwh[,currency] -// -// Prices already in öre/kWh — caller is responsible for any EUR→SEK -// conversion. Rows for unknown zones are silently skipped. -// -// Idempotent: SQLite UPSERTs on (zone, slot_ts_ms), so re-running with -// the same CSV won't duplicate data. Safe to call on every boot; it -// becomes a no-op once the data is already in the store. -func (s *Service) SeedFromCSV(path string) (int, error) { - if s == nil || s.Store == nil { - return 0, fmt.Errorf("service not initialized") - } - f, err := os.Open(path) - if err != nil { - return 0, err - } - defer f.Close() - return s.ingestCSV(f) -} - -func (s *Service) ingestCSV(r io.Reader) (int, error) { - reader := csv.NewReader(r) - reader.TrimLeadingSpace = true - header, err := reader.Read() - if err != nil { - return 0, fmt.Errorf("read header: %w", err) - } - col := map[string]int{} - for i, h := range header { - col[strings.ToLower(strings.TrimSpace(h))] = i - } - required := []string{"zone", "slot_ts_ms", "spot_ore_kwh"} - for _, r := range required { - if _, ok := col[r]; !ok { - return 0, fmt.Errorf("missing column %q (want %v)", r, required) - } - } - var batch []state.PricePoint - const flushAt = 5000 - total := 0 - nowMs := time.Now().UnixMilli() - for { - rec, err := reader.Read() - if err == io.EOF { - break - } - if err != nil { - return total, fmt.Errorf("read row: %w", err) - } - zone := strings.ToUpper(strings.TrimSpace(rec[col["zone"]])) - tsMs, err := strconv.ParseInt(rec[col["slot_ts_ms"]], 10, 64) - if err != nil { - continue - } - spot, err := strconv.ParseFloat(rec[col["spot_ore_kwh"]], 64) - if err != nil { - continue - } - slotLen := 60 - if i, ok := col["slot_len_min"]; ok && i < len(rec) { - if v, err := strconv.Atoi(rec[i]); err == nil && v > 0 { - slotLen = v - } - } - batch = append(batch, state.PricePoint{ - Zone: zone, - SlotTsMs: tsMs, - SlotLenMin: slotLen, - SpotOreKwh: spot, - TotalOreKwh: spot, // no tariff/VAT info in seed; forecaster only uses spot anyway - Source: "seed", - FetchedAtMs: nowMs, - }) - if len(batch) >= flushAt { - if err := s.Store.SavePrices(batch); err != nil { - return total, fmt.Errorf("save batch: %w", err) - } - total += len(batch) - batch = batch[:0] - } - } - if len(batch) > 0 { - if err := s.Store.SavePrices(batch); err != nil { - return total, err - } - total += len(batch) - } - // Kick a refit with the new data. - s.refit() - return total, nil -} diff --git a/go/internal/priceforecast/forecast_test.go b/go/internal/priceforecast/forecast_test.go deleted file mode 100644 index f19872e39..000000000 --- a/go/internal/priceforecast/forecast_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package priceforecast - -import ( - "math" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/srcfl/ftw/go/internal/state" -) - -func stockholm(t *testing.T) *time.Location { - t.Helper() - loc, err := time.LoadLocation("Europe/Stockholm") - if err != nil { - t.Fatalf("Europe/Stockholm tzdata unavailable: %v", err) - } - return loc -} - -func TestFreshModelHasSensibleCurve(t *testing.T) { - // Untrained model returns baked-in typical Nordic pattern: - // midday trough, morning + evening peaks. Tests shape, not exact values. - // Civil times are Europe/Stockholm — the clock the prior is drawn in. - loc := stockholm(t) - m := NewZoneModel("SE3") - midday := time.Date(2026, 6, 15, 13, 0, 0, 0, loc) - evening := time.Date(2026, 6, 15, 19, 0, 0, 0, loc) - overnight := time.Date(2026, 6, 15, 3, 0, 0, 0, loc) - pm := m.Predict(midday) - pe := m.Predict(evening) - po := m.Predict(overnight) - if !(pe > pm) { - t.Errorf("evening (%.1f) should exceed midday (%.1f)", pe, pm) - } - if !(pm < po) { - t.Errorf("midday (%.1f) should be below overnight (%.1f) due to solar flood", pm, po) - } - // Winter vs summer seasonality - wintr := time.Date(2026, 1, 15, 19, 0, 0, 0, loc) - smrEv := time.Date(2026, 7, 15, 19, 0, 0, 0, loc) - if !(m.Predict(wintr) > m.Predict(smrEv)) { - t.Errorf("winter (%.1f) should exceed summer (%.1f)", m.Predict(wintr), m.Predict(smrEv)) - } -} - -func TestFitsHourOfWeekPattern(t *testing.T) { - // Synthetic: SE3 prices with morning peak 150, midday trough 30, - // evening peak 200, keyed to Stockholm local hours. Two years of - // data so the Bayesian prior (weight ≈ 8) is swamped by ~100 - // samples per hour-of-week bucket. - loc := stockholm(t) - var pts []state.PricePoint - start := time.Date(2024, 1, 1, 0, 0, 0, 0, loc) - for d := 0; d < 730; d++ { // 2 years - day := start.AddDate(0, 0, d) - for h := 0; h < 24; h++ { - ts := time.Date(day.Year(), day.Month(), day.Day(), h, 0, 0, 0, loc) - var price float64 - switch { - case h >= 7 && h <= 9: - price = 150 - case h >= 11 && h <= 14: - price = 30 - case h >= 17 && h <= 20: - price = 200 - default: - price = 80 - } - pts = append(pts, state.PricePoint{ - Zone: "SE3", - SlotTsMs: ts.UnixMilli(), - SlotLenMin: 60, - SpotOreKwh: price, - }) - } - } - m := NewZoneModel("SE3") - m.FitFromHistory(pts) - - // With ~100+ samples per bucket, fit should be very close to data. - // Tolerance generous because month multipliers still apply some - // seasonal scaling. - mornMon := time.Date(2026, 3, 2, 8, 0, 0, 0, loc) - if got := m.Predict(mornMon); math.Abs(got-150) > 20 { - t.Errorf("Mon 08:00 peak: got %f, want ~150 (±20)", got) - } - trough := time.Date(2026, 3, 4, 13, 0, 0, 0, loc) - if got := m.Predict(trough); math.Abs(got-30) > 20 { - t.Errorf("Wed 13:00 trough: got %f, want ~30 (±20)", got) - } - eve := time.Date(2026, 3, 6, 19, 0, 0, 0, loc) - if got := m.Predict(eve); math.Abs(got-200) > 20 { - t.Errorf("Fri 19:00 peak: got %f, want ~200 (±20)", got) - } -} - -func TestSparseHistoryFallsBackToPriorShape(t *testing.T) { - // Only 3 days of data. The Bayesian prior (weight 8) dominates, - // so the predictions should still show the baked hour-of-week - // shape — morning + evening peaks, midday trough — even if the - // short training sample happened to be uniform. - loc := stockholm(t) - var pts []state.PricePoint - start := time.Date(2026, 1, 5, 0, 0, 0, 0, loc) - for d := 0; d < 3; d++ { - day := start.AddDate(0, 0, d) - for h := 0; h < 24; h++ { - ts := time.Date(day.Year(), day.Month(), day.Day(), h, 0, 0, 0, loc) - pts = append(pts, state.PricePoint{ - Zone: "SE3", SlotTsMs: ts.UnixMilli(), - SlotLenMin: 60, SpotOreKwh: 100, // totally flat — unusual - }) - } - } - m := NewZoneModel("SE3") - m.FitFromHistory(pts) - - // Even though training data was flat, shape persists from prior. - morn := time.Date(2026, 3, 2, 8, 0, 0, 0, loc) - midday := time.Date(2026, 3, 2, 13, 0, 0, 0, loc) - eve := time.Date(2026, 3, 2, 19, 0, 0, 0, loc) - if !(m.Predict(morn) > m.Predict(midday)) { - t.Errorf("morning (%f) should beat midday (%f) — prior shape lost", - m.Predict(morn), m.Predict(midday)) - } - if !(m.Predict(eve) > m.Predict(midday)) { - t.Errorf("evening (%f) should beat midday (%f) — prior shape lost", - m.Predict(eve), m.Predict(midday)) - } -} - -func TestSeedFromCSVIngestsAndFits(t *testing.T) { - st, err := state.Open(filepath.Join(t.TempDir(), "t.db")) - if err != nil { - t.Fatal(err) - } - defer st.Close() - - csv := `zone,slot_ts_ms,slot_len_min,spot_ore_kwh -SE3,1735689600000,60,50.0 -SE3,1735693200000,60,60.0 -SE3,1735696800000,60,70.0 -SE3,1735700400000,60,80.0 -SE4,1735689600000,60,90.0 -` - // Write to a tempfile so SeedFromCSV sees a real path. - s := NewService(st, []string{"SE3", "SE4"}) - n, err := s.ingestCSV(strings.NewReader(csv)) - if err != nil { - t.Fatalf("ingest: %v", err) - } - if n != 5 { - t.Errorf("want 5 rows imported, got %d", n) - } - // Verify SE3 data landed in the store. - rows, err := st.LoadPrices("SE3", 0, 3000000000000) - if err != nil { - t.Fatal(err) - } - if len(rows) != 4 { - t.Errorf("SE3 rows: got %d want 4", len(rows)) - } -} - -// TestPredictStableAcrossDST ensures Predict returns the same value for -// the same absolute instant regardless of the timezone the caller has -// attached to the time.Time struct. hourOfWeek converts to Stockholm -// first, so UTC and local presentations of one instant agree. -func TestPredictStableAcrossDST(t *testing.T) { - loc := stockholm(t) - m := NewZoneModel("SE3") - // Several points over the year — including both DST transitions. - cases := []struct { - name string - inst time.Time - }{ - // Winter (CET = UTC+1): 19:00 local = 18:00 UTC - {"winter evening", time.Date(2026, 1, 15, 18, 0, 0, 0, time.UTC)}, - // Spring-forward day: 2026-03-29 01:00 UTC = 03:00 CEST (02:00 local skipped) - {"spring forward 01UTC", time.Date(2026, 3, 29, 1, 0, 0, 0, time.UTC)}, - {"spring forward 10UTC", time.Date(2026, 3, 29, 10, 0, 0, 0, time.UTC)}, - // Summer (CEST = UTC+2): 19:00 local = 17:00 UTC - {"summer evening", time.Date(2026, 7, 15, 17, 0, 0, 0, time.UTC)}, - // Fall-back day: 2026-10-25 00:00 UTC = 02:00 CEST; 01:00 UTC = 02:00 CET (second time) - {"fall back 00UTC", time.Date(2026, 10, 25, 0, 0, 0, 0, time.UTC)}, - {"fall back 01UTC", time.Date(2026, 10, 25, 1, 0, 0, 0, time.UTC)}, - // Erik's scenario: ~21:00 local (19:00-20:00 UTC depending on season) - {"winter 21 local", time.Date(2026, 12, 10, 20, 0, 0, 0, time.UTC)}, - {"summer 21 local", time.Date(2026, 7, 10, 19, 0, 0, 0, time.UTC)}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - utc := tc.inst - local := utc.In(loc) - if !utc.Equal(local) { - t.Fatalf("instants must be equal — test bug") - } - pUTC := m.Predict(utc) - pLocal := m.Predict(local) - if pUTC != pLocal { - t.Errorf("Predict diverged across timezones for same instant: "+ - "utc=%v -> %.4f, local=%v -> %.4f", - utc, pUTC, local, pLocal) - } - }) - } -} - -// TestHourOfWeekStableAcrossDST is the lower-level regression: the -// bucket index itself must not change when the same instant is -// represented in a different timezone. -func TestHourOfWeekStableAcrossDST(t *testing.T) { - loc := stockholm(t) - // Pick a few instants across DST boundaries. - instants := []time.Time{ - time.Date(2026, 3, 29, 1, 0, 0, 0, time.UTC), // spring forward - time.Date(2026, 10, 25, 1, 0, 0, 0, time.UTC), // fall back - time.Date(2026, 7, 15, 17, 0, 0, 0, time.UTC), // summer - time.Date(2026, 12, 15, 20, 0, 0, 0, time.UTC), // winter - } - for _, inst := range instants { - utc := inst - local := inst.In(loc) - if hourOfWeek(utc) != hourOfWeek(local) { - t.Errorf("hourOfWeek differs: utc=%d local=%d (inst=%v)", - hourOfWeek(utc), hourOfWeek(local), inst) - } - } -} - -// TestSE3CESTEveningPeakLandsInEveningPrior is the #1161 regression: -// 19:00 CEST must use the baked 17–20 local evening bucket, not the -// UTC hour (17) that the same instant occupies. -func TestSE3CESTEveningPeakLandsInEveningPrior(t *testing.T) { - loc := stockholm(t) - m := NewZoneModel("SE3") - - // Wednesday 15 Jul 2026 19:00 CEST = 17:00 UTC. - evening := time.Date(2026, 7, 15, 19, 0, 0, 0, loc) - if evening.UTC().Hour() != 17 { - t.Fatalf("test bug: 19:00 CEST should be 17:00 UTC, got %02d", evening.UTC().Hour()) - } - - idx := hourOfWeek(evening) - want := 2*24 + 19 // Wed 19:00 local - utcIdx := 2*24 + 17 - if idx != want { - t.Errorf("19:00 CEST bucket = %d, want %d (UTC 17:00 would be %d)", idx, want, utcIdx) - } - - peak := m.Predict(evening) - after := m.Predict(time.Date(2026, 7, 15, 21, 0, 0, 0, loc)) - if !(peak > after) { - t.Errorf("19:00 CEST (%.1f) should exceed 21:00 CEST (%.1f) — peak must follow local evening, not UTC", - peak, after) - } - - // Passing the instant as UTC must still hit the local-19 bucket. - asUTC := time.Date(2026, 7, 15, 17, 0, 0, 0, time.UTC) - if hourOfWeek(asUTC) != want { - t.Errorf("17:00 UTC (19:00 CEST) bucket = %d, want %d", hourOfWeek(asUTC), want) - } - if m.Predict(asUTC) != peak { - t.Errorf("Predict(17:00 UTC)=%.1f, Predict(19:00 CEST)=%.1f — same instant", m.Predict(asUTC), peak) - } -} - -func TestSeedFromCSVRejectsMissingColumns(t *testing.T) { - st, _ := state.Open(filepath.Join(t.TempDir(), "t.db")) - defer st.Close() - s := NewService(st, []string{"SE3"}) - _, err := s.ingestCSV(strings.NewReader("zone,timestamp\nSE3,1000\n")) - if err == nil { - t.Error("expected error for missing spot_ore_kwh column") - } -} diff --git a/go/internal/state/store.go b/go/internal/state/store.go index 4690a0682..416350303 100644 --- a/go/internal/state/store.go +++ b/go/internal/state/store.go @@ -1116,6 +1116,11 @@ func (s *Store) migrate() error { // The assistant_threads table went the same way when Ask why was // removed. Its rows are old conversations that nothing reads. Do not // reuse the name. + // + // The price forecast kept its model in the config key pricefc/state + // until the planner moved to published prices only. Nothing reads it + // now, and an older release restores its model from it after a + // rollback. Do not reuse the key. } for _, stmt := range stmts { diff --git a/web/diagnose.js b/web/diagnose.js index 49296e831..0c83ce89b 100644 --- a/web/diagnose.js +++ b/web/diagnose.js @@ -342,7 +342,7 @@ #Time - PriceSpotConf + PriceSpot PVLoad BatteryGridSoC end ${lpActive ? 'EV WEV SoC' : ''} @@ -443,8 +443,6 @@ } function slotRow(sl, i, lpActive) { - const conf = sl.confidence != null ? sl.confidence.toFixed(2) : '—'; - const confCls = sl.confidence < 0.9 ? 'conf-low' : ''; const gridCls = sl.grid_w > 0 ? 'val-import' : (sl.grid_w < 0 ? 'val-export' : 'val-neutral'); const batCls = sl.battery_w > 0 ? 'val-charging' : (sl.battery_w < 0 ? 'val-discharging' : 'val-neutral'); const socPct = socPercent(socValue(sl)); @@ -454,7 +452,6 @@ ${fmtHHMM(sl.slot_start_ms)} ${fmt1(sl.price_ore)} ${fmt1(sl.spot_ore)} - ${conf} ${fmtW(sl.pv_w)} ${fmtW(sl.load_w)} ${fmtW(sl.battery_w)} @@ -525,11 +522,10 @@ const x = pad.l + i * barW; const h = (s.price_ore / maxPrice) * priceH; const y = priceY0 + priceH - h; - // Cheap slots (below mean) in green, expensive in red, low-confidence dimmed - const alpha = s.confidence < 0.9 ? 0.35 : 0.75; + // Cheap slots (below mean) in green, expensive in red ctx.fillStyle = s.price_ore < priceMean - ? `rgba(34,197,94,${alpha})` - : `rgba(239,68,68,${alpha})`; + ? 'rgba(34,197,94,0.75)' + : 'rgba(239,68,68,0.75)'; ctx.fillRect(x, y, barW, h); }); diff --git a/web/index.html b/web/index.html index b5f94ba8b..07e2665d2 100644 --- a/web/index.html +++ b/web/index.html @@ -596,8 +596,8 @@

Plan

- Forecast-driven schedule for the home battery and cars over the next 48 h, updated every few minutes. - Each bar is a 15-minute slot. Prices after the day-ahead publication window are filled in by the ML price twin. + Schedule for the home battery and cars, as far ahead as electricity prices are published, updated every few minutes. + Tomorrow’s prices usually arrive around 13:00. Each bar is a 15-minute slot. Hover any slot for the full breakdown. The dashed vertical line marks “now”.

@@ -608,7 +608,6 @@

Plan

Spot (expensive) Grid tariff VAT - Predicted (ML) PV forecast Load forecast diff --git a/web/mobile-information-architecture.test.mjs b/web/mobile-information-architecture.test.mjs index f2d916311..5ef01fdbc 100644 --- a/web/mobile-information-architecture.test.mjs +++ b/web/mobile-information-architecture.test.mjs @@ -84,7 +84,7 @@ describe("plain-language plan briefing", () => { assert.match(plan, /derivePlanBrief/); assert.match(planBrief, /Fallback active/); assert.match(planBrief, /No active safety adjustment/); - assert.match(planBrief, /forecast after that/); + assert.match(planBrief, /Plan until /); assert.match(planBrief, /at the end of the plan/); }); }); diff --git a/web/plan-brief.js b/web/plan-brief.js index a5531a4aa..e5a0ffcd6 100644 --- a/web/plan-brief.js +++ b/web/plan-brief.js @@ -6,6 +6,14 @@ function formatClock(tsMs) { return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; } +// A plan that ends at midnight ends with that day: "Sat 24:00", not "Sun 00:00". +function formatPlanEnd(tsMs) { + const date = new Date(tsMs); + const weekday = (ms) => new Date(ms).toLocaleDateString(undefined, { weekday: "short" }); + if (date.getHours() === 0 && date.getMinutes() === 0) return `${weekday(tsMs - 1)} 24:00`; + return `${weekday(tsMs)} ${formatClock(tsMs)}`; +} + function readableReason(reason) { if (!reason) return "Balancing expected energy use and supply"; const known = { @@ -244,25 +252,14 @@ export function derivePlanBrief({ constraint = "The schedule is old, so FTW is using safe live balancing"; } - const uncertain = actions.filter((action) => ( - action.confidence != null && action.confidence < 0.999 - )); - const forecast = uncertain.length - ? { - label: ( - uncertain.reduce((sum, action) => sum + action.confidence, 0) / - uncertain.length - ) >= 0.75 - ? "Some modeled inputs" - : "Higher uncertainty later", - detail: `Observed market data to ${formatClock(uncertain[0].slot_start_ms)}; forecast after that`, - } - : { - label: "Current published inputs", - detail: "No modeled price period in this plan", - }; - const finalAction = actions[actions.length - 1]; + // The plan reaches as far as the published prices, and says how far. + const forecast = { + label: "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", + }; const nextSocPct = next ? socPercent(next.soc) : null; const finalSocPct = finalAction ? socPercent(finalAction.soc) : null; const soc = hasBattery diff --git a/web/plan-brief.test.mjs b/web/plan-brief.test.mjs index 072ca13f0..bfe771ced 100644 --- a/web/plan-brief.test.mjs +++ b/web/plan-brief.test.mjs @@ -11,7 +11,6 @@ const slot = (offsetMinutes, overrides = {}) => ({ loadpoint_w: 0, pv_limit_w: 0, reason: "scheduled", - confidence: 1, ...overrides, }); @@ -157,13 +156,13 @@ describe("plan brief normalization", () => { assert.equal(brief.planner.detail, "Worker unavailable"); }); - it("surfaces active safety clamps and modeled forecast periods", () => { + it("surfaces active safety clamps and how far the published prices reach", () => { const brief = derivePlanBrief({ enabled: true, plan: { actions: [ - slot(8, { confidence: 0.8 }), - slot(23, { confidence: 0.7 }), + slot(8), + slot(23), ], solver: {}, }, @@ -175,8 +174,20 @@ describe("plan brief normalization", () => { }); assert.match(brief.constraint, /Safety adjusted battery to 1.8 kW/); - assert.equal(brief.forecast.label, "Some modeled inputs"); - assert.match(brief.forecast.detail, /forecast after that/); + assert.equal(brief.forecast.label, "Published prices"); + assert.match(brief.forecast.detail, /^Plan until \S+ 10:45$/); + }); + + it("names a plan that ends at midnight by the day it ends", () => { + // The last slot starts at 23:45, so the published prices run to midnight. + const brief = derivePlanBrief({ + enabled: true, + plan: { actions: [slot(8), slot(818)], solver: {} }, + status: { mode: "planner_arbitrage" }, + now, + }); + const today = new Date(now).toLocaleDateString(undefined, { weekday: "short" }); + assert.equal(brief.forecast.detail, `Plan until ${today} 24:00`); }); it("does not tell a user who already picked a planner mode to pick a strategy", () => { diff --git a/web/plan.js b/web/plan.js index f23dcaa22..40c3651c6 100644 --- a/web/plan.js +++ b/web/plan.js @@ -113,8 +113,22 @@ import { if (horizon === "tomorrow") { return { tMin: localMidnight(1), tMax: localMidnight(2) }; } - // "all" — current default: now-30 min through next 48 h. - return { tMin: now - 30 * 60 * 1000, tMax: now + 48 * 60 * 60 * 1000 }; + // "all" — current default: now-30 min through the end of the published + // prices the plan runs on, at most 48 h ahead. + return { tMin: now - 30 * 60 * 1000, tMax: Math.min(pricesEndMs(now), now + 48 * 60 * 60 * 1000) }; + } + // Where the published prices end: the plan's last slot, or the last price + // before a plan exists. 48 h ahead when neither has arrived yet. + function pricesEndMs(now) { + const slots = (state.plan && state.plan.actions && state.plan.actions.length) + ? state.plan.actions + : (state.prices || []); + let end = 0; + for (const s of slots) { + const start = s.slot_start_ms ?? s.slot_ts_ms; + end = Math.max(end, start + (s.slot_len_min || 15) * 60 * 1000); + } + return end > now ? end : now + 48 * 60 * 60 * 1000; } function chartTickStepMs(tMin, tMax) { const span = Math.max(1, tMax - tMin); @@ -367,14 +381,9 @@ import { // Price range. // - // The bars come from the plan's actions whenever a plan exists, because - // those cover the whole horizon — including slots whose day-ahead price - // hasn't published yet and is filled in by the ML price twin. The scale - // and the tercile thresholds must be derived from the SAME set. Deriving - // them from state.prices (published slots only) put every predicted slot - // above the known maximum: its bar was drawn past the top of the price - // band and over the mode strip, and it always landed above p75 so the - // entire forecast period read as "expensive". + // The bars come from the plan's actions whenever a plan exists, and the + // scale and the tercile thresholds are derived from the SAME set, so no + // bar is drawn past the top of the price band. const prices = (state.prices || []).filter(p => p.slot_ts_ms >= tMin && p.slot_ts_ms <= tMax); const barSource = (plan && plan.actions && plan.actions.length) ? plan.actions : prices; const priceBars = barSource.filter(b => { @@ -410,8 +419,8 @@ import { // Power band in middle — covers battery + grid. // `plan` is aliased at the top of render() because the price scale - // needs it too; several later sections ("Plan battery bars", "Load - // forecast", predicted-zone shade) reference it directly. + // needs it too; later sections ("Plan battery bars", "Load forecast") + // reference it directly. renderPlanBrief(plan); renderCarPlans(); renderOptimizerFallbackAlert(plan); @@ -461,39 +470,6 @@ import { ctx.setLineDash([]); } - // ---- Predicted-zone shade + boundary ---- - // Find the first ML-forecasted action. Everything at or past that - // point gets a translucent band and a "predicted" label, so the - // uncertain portion reads as visually different — not just dimmer - // bars but a whole different region. - if (plan && plan.actions && plan.actions.length) { - const firstPred = plan.actions.find(a => a.confidence != null && a.confidence < 1.0); - if (firstPred) { - const xPred = Math.max(xScale(firstPred.slot_start_ms), pad.l); - const xEnd = pad.l + plotW; - if (xPred < xEnd) { - // Shaded band behind everything in the plot area — strong - // enough to read as "this zone is different". - ctx.fillStyle = 'rgba(251,191,36,0.10)'; - ctx.fillRect(xPred, pad.t, xEnd - xPred, plotH); - // Boundary line - ctx.strokeStyle = 'rgba(251,191,36,0.65)'; - ctx.lineWidth = 1.2; - ctx.setLineDash([4, 4]); - ctx.beginPath(); - ctx.moveTo(xPred, pad.t); - ctx.lineTo(xPred, pad.t + plotH); - ctx.stroke(); - ctx.setLineDash([]); - // Label "predicted →" - ctx.fillStyle = 'rgba(251,191,36,0.9)'; - ctx.font = '10px system-ui, sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText('predicted →', xPred + 4, pad.t + 10); - } - } - } - // ---- Price bars ---- // Stacked: spot (bottom, tercile-colored) + grid tariff (middle, // neutral slate) + VAT (top, lighter slate). Reads grid tariff + @@ -519,8 +495,6 @@ import { const priceVal = bar.total_ore_kwh ?? bar.price_ore; const x0 = xScale(ts); const x1 = xScale(ts + len * 60 * 1000); - const zero = priceY(Math.max(0, priceMin)); - const isPredicted = bar.confidence != null && bar.confidence < 1.0; // Component breakdown. When we have spot_ore AND at least one // of the fixed portions is non-zero, stack three segments so // the bar reads as a breakdown. Otherwise render a single flat @@ -553,35 +527,16 @@ import { // we re-project each segment's top edge through priceY so the // stacked bar lines up pixel-perfect with the axis grid. let runningOre = 0; - const topY = priceY(priceVal); for (const part of parts) { if (part.ore <= 0) continue; const segBottomY = priceY(runningOre); const segTopY = priceY(runningOre + part.ore); const segY = Math.min(segBottomY, segTopY); const segH = Math.abs(segBottomY - segTopY); - const alpha = isPredicted ? part.alpha * 0.45 : part.alpha; - ctx.fillStyle = `rgba(${part.rgb},${alpha})`; + ctx.fillStyle = `rgba(${part.rgb},${part.alpha})`; ctx.fillRect(rectX, segY, rectW, segH); runningOre += part.ore; } - if (isPredicted) { - // Predicted slots are marked by a cap on top of the bar, not by an - // outline around it. The outline was written for hourly slots; at - // the 15-minute resolution NordPool publishes, ~96 dashed frames a - // day merge into a solid hatched wall that hides the prices behind - // it. The cap survives any bar width, and the shaded band plus the - // "predicted →" label already mark the zone. - ctx.fillStyle = `rgba(${parts[0].rgb},0.9)`; - ctx.fillRect(rectX, Math.min(topY, zero), rectW, 1.5); - if (rectW >= 6) { - ctx.strokeStyle = `rgba(${parts[0].rgb},0.55)`; - ctx.lineWidth = 1; - ctx.setLineDash([3, 3]); - ctx.strokeRect(rectX + 0.5, Math.min(topY, zero) + 0.5, rectW - 1, Math.abs(topY - zero) - 1); - ctx.setLineDash([]); - } - } // Track for hover hit-test. state.priceBarBounds.push({ x0: x0, x1: x1, @@ -984,7 +939,6 @@ import { const d = new Date(found.ts); const hh = d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0'); const dayStr = d.toLocaleDateString(undefined, { weekday: 'short' }); - const predicted = a.confidence != null && a.confidence < 1.0; const price = a.total_ore_kwh ?? a.price_ore; // PV is site-signed internally (generation = negative). Flip it for // display so the tooltip reads as a positive production number — @@ -992,7 +946,7 @@ import { const u = unitFor(state.currency); const inUnit = (v, d) => toDisplay(v, state.currency).toFixed(d == null ? u.decimals : d); const lines = [ - `
${dayStr} ${hh}${predicted ? ' predicted' : ''}
`, + `
${dayStr} ${hh}
`, `
Price${inUnit(price)} ${u.perKwh}
`, ]; // Price breakdown: show where the consumer total comes from. @@ -1052,7 +1006,7 @@ import { else if (a.battery_w < -100) { action = 'Discharging'; actionHint = 'battery covers load (and may export)'; } else { action = 'Idle'; actionHint = 'battery neither charges nor discharges'; } lines.push(`
Plan${action}
`); - lines.push(`
${a.reason ? escapeHTML(a.reason) : `${action.toLowerCase()} — ${actionHint}${predicted ? ' (predicted)' : ''}`}
`); + lines.push(`
${a.reason ? escapeHTML(a.reason) : `${action.toLowerCase()} — ${actionHint}`}
`); } else if (a.reason) { lines.push(`
${escapeHTML(a.reason)}
`); } diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js index 0f025dd50..62c354254 100644 --- a/web/settings/tabs/planner.js +++ b/web/settings/tabs/planner.js @@ -130,7 +130,7 @@ '' + '' + '

' + - 'The planner requires working price + weather forecasts. When disabled the system runs in the manual mode set on the Control page.' + + 'The planner plans as far ahead as electricity prices are published and uses the weather forecast for solar. When disabled the system runs in the manual mode set on the Control page.' + '

'; }, after: function (ctx) { diff --git a/web/style.css b/web/style.css index 4a88171ef..a99fc61b4 100644 --- a/web/style.css +++ b/web/style.css @@ -490,7 +490,6 @@ header h1 { } .diag-table tbody tr.diag-row-hidden { display: none; } .diag-reason-cell { color: var(--text-dim); white-space: nowrap; max-width: 320px; overflow: hidden; text-overflow: ellipsis; } -.conf-low { color: #fde68a; } @media (max-width: 900px) { .diagnose-body { grid-template-columns: 1fr; } @@ -1637,15 +1636,6 @@ footer { display: inline-block; vertical-align: middle; } -.legend-ghost { - background: rgba(148,163,184,0.10) !important; - border: 1px dashed rgba(148,163,184,0.75); - width: 14px; - height: 10px; - display: inline-block; - vertical-align: middle; - box-sizing: border-box; -} .control-strategy { min-width: 320px; } /* Strategy block when embedded inside the Plan card. Sits between the @@ -1961,7 +1951,6 @@ footer { box-shadow: 0 4px 12px rgba(0,0,0,0.4); } .plan-tip .tip-head { font-weight: 600; margin-bottom: 4px; color: #fff; } -.plan-tip .tip-pred { font-size: 0.7rem; color: #fbbf24; font-weight: normal; } .plan-tip .tip-row { display: flex; justify-content: space-between; padding: 1px 0; } .plan-tip .tip-row span { color: var(--text-dim, #a0a0a0); } .plan-tip .tip-breakdown {