From 3ad316bb8151f53fdb1c417559a15f398560b94c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 8 Sep 2026 11:30:37 +0200 Subject: [PATCH] fix(mpc): score remaining slot time in rain-check and Energyplan budget The load rain-check still integrated the first price slot as LenMin after #1130 trimmed it to remaining time. Energyplan's worker budget was model-size only, so a late-in-slot fleet replan could spend 5s on a result that firstSlotExpired then discarded. Use DurationHours() for rain-check energy. Cap TimeLimitS and the process wait by remaining first-slot time minus a short publish margin, and fail fast when remaining time is below the small-model budget. Closes #1144 Closes #1148 Signed-off-by: Fredrik Ahlgren --- .changeset/remaining-slot-budget.md | 7 ++ go/internal/mpc/energyplan.go | 30 ++++- go/internal/mpc/energyplan_test.go | 165 ++++++++++++++++++++++++++++ go/internal/mpc/execution_time.go | 11 ++ go/internal/mpc/load_bounds.go | 14 +-- go/internal/mpc/load_bounds_test.go | 17 +++ 6 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 .changeset/remaining-slot-budget.md diff --git a/.changeset/remaining-slot-budget.md b/.changeset/remaining-slot-budget.md new file mode 100644 index 00000000..7b70b42b --- /dev/null +++ b/.changeset/remaining-slot-budget.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +Score remaining slot time in the load rain-check and cap the Energyplan +worker budget so a late-in-slot replan cannot burn a full solve that +cannot be published. diff --git a/go/internal/mpc/energyplan.go b/go/internal/mpc/energyplan.go index 035b0dec..534f5767 100644 --- a/go/internal/mpc/energyplan.go +++ b/go/internal/mpc/energyplan.go @@ -2,6 +2,7 @@ package mpc import ( "context" + "fmt" "path/filepath" "time" ) @@ -25,6 +26,13 @@ func NewEnergyplanOptimizer(binary string) (*EnergyplanOptimizer, error) { return &EnergyplanOptimizer{ExternalOptimizer: external}, nil } +const ( + energyplanSmallBudget = 500 * time.Millisecond + energyplanFleetBudget = 5 * time.Second + // Leave time for ValidatePlan and publication before firstSlotExpired. + energyplanPublishMargin = 50 * time.Millisecond +) + func energyplanTimeBudget(slots []Slot, p Params) time.Duration { batteries := len(p.Storages) if batteries == 0 && p.CapacityWh > 0 { @@ -33,16 +41,34 @@ func energyplanTimeBudget(slots []Slot, p Params) time.Duration { assets := 3*batteries + 2*len(p.activeLoadpoints()) // Core already adjusts PV to one downside horizon. That margin does not // add worker scenarios or change this model's size. + budget := energyplanSmallBudget if len(slots)*assets >= 193*6 || p.PVCurtailment.MinW > 0 { - return 5 * time.Second + budget = energyplanFleetBudget + } + remaining := remainingFirstSlot(slots) + if remaining < energyplanSmallBudget { + return 0 } - return 500 * time.Millisecond + available := remaining - energyplanPublishMargin + if available < budget { + return available + } + return budget } func (o *EnergyplanOptimizer) Optimize(ctx context.Context, slots []Slot, p Params) (Plan, error) { // Service has already applied the risk margin to these slots. Do not // construct a second scenario model for this deterministic solver. p.PVUncertaintyW, p.PVRelativeUncertainty, p.PVForecastSafetyK = 0, 0, 0 + budget := energyplanTimeBudget(slots, p) + if budget <= 0 { + return Plan{}, fmt.Errorf("remaining first-slot time %s is below the Energyplan budget", remainingFirstSlot(slots)) + } + if wait := remainingFirstSlot(slots) - energyplanPublishMargin; o.cfg.Timeout > 0 && wait > 0 && wait < o.cfg.Timeout { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, wait) + defer cancel() + } return o.ExternalOptimizer.Optimize(ctx, slots, p) } diff --git a/go/internal/mpc/energyplan_test.go b/go/internal/mpc/energyplan_test.go index 01c2a240..bd60d051 100644 --- a/go/internal/mpc/energyplan_test.go +++ b/go/internal/mpc/energyplan_test.go @@ -3,6 +3,7 @@ package mpc import ( "context" "encoding/json" + "errors" "fmt" "math" "strings" @@ -11,6 +12,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" ) func TestValidatePlanRejectsEVOverCapacity(t *testing.T) { @@ -287,3 +289,166 @@ func TestBatterylessEVBudgetDoesNotInventStorage(t *testing.T) { t.Fatalf("real aggregate battery budget=%v", got) } } + +func energyplanFleetHorizon() ([]Slot, Params) { + slots, params := topologyFixture(2, 2) + first := slots[0] + horizon := make([]Slot, 193) + for i := range horizon { + horizon[i] = first + horizon[i].StartMs = first.StartMs + int64(i*first.LenMin)*60000 + } + return horizon, params +} + +func trimFirstSlotRemaining(slots []Slot, remaining time.Duration) { + end := slots[0].StartMs + int64(slots[0].LenMin)*60000 + slots[0].ExecutionStartMs = end - remaining.Milliseconds() +} + +type countingTransport struct { + n int + last []byte + deadlineWait time.Duration + hasDeadline bool +} + +func (c *countingTransport) RoundTrip(ctx context.Context, payload []byte) ([]byte, error) { + c.n++ + c.last = append([]byte(nil), payload...) + if d, ok := ctx.Deadline(); ok { + c.hasDeadline = true + c.deadlineWait = time.Until(d) + } + return nil, errors.New("stop") +} +func (c *countingTransport) Health(context.Context) (OptimizerRuntimeInfo, error) { + return OptimizerRuntimeInfo{}, nil +} +func (c *countingTransport) Close() error { return nil } + +func TestEnergyplanTimeBudgetRespectsRemainingSlot(t *testing.T) { + t.Parallel() + slots, p := energyplanFleetHorizon() + if got := energyplanTimeBudget(slots, p); got != energyplanFleetBudget { + t.Fatalf("full-slot fleet budget=%v, want %v", got, energyplanFleetBudget) + } + trimFirstSlotRemaining(slots, 200*time.Millisecond) + if got := energyplanTimeBudget(slots, p); got != 0 { + t.Fatalf("200ms remaining fleet budget=%v, want fail-fast", got) + } + trimFirstSlotRemaining(slots, 2*time.Second) + got := energyplanTimeBudget(slots, p) + remaining := remainingFirstSlot(slots) + if got <= 0 || got > remaining || remaining-got != energyplanPublishMargin { + t.Fatalf("2s remaining fleet budget=%v remaining=%v", got, remaining) + } + small, smallParams := topologyFixture(1, 0) + trimFirstSlotRemaining(small, 2*time.Second) + if got := energyplanTimeBudget(small, smallParams); got != energyplanSmallBudget { + t.Fatalf("2s remaining small budget=%v, want %v", got, energyplanSmallBudget) + } +} + +func TestEnergyplanFailsFastWhenRemainingBelowSmallBudget(t *testing.T) { + t.Parallel() + capture := &countingTransport{} + engine := &EnergyplanOptimizer{ExternalOptimizer: &ExternalOptimizer{ + cfg: ExternalOptimizerConfig{Timeout: 7 * time.Second}, + transport: capture, + timeBudget: energyplanTimeBudget, + }} + slots, p := energyplanFleetHorizon() + trimFirstSlotRemaining(slots, 200*time.Millisecond) + _, err := engine.Optimize(context.Background(), slots, p) + if err == nil || !strings.Contains(err.Error(), "below the Energyplan budget") { + t.Fatalf("expected fail-fast, got %v", err) + } + if capture.n != 0 { + t.Fatalf("doomed worker started %d times", capture.n) + } +} + +func TestEnergyplanCapsWorkerBudgetAndWaitToRemaining(t *testing.T) { + t.Parallel() + capture := &countingTransport{} + engine := &EnergyplanOptimizer{ExternalOptimizer: &ExternalOptimizer{ + cfg: ExternalOptimizerConfig{Timeout: 7 * time.Second}, + transport: capture, + timeBudget: energyplanTimeBudget, + }} + slots, p := energyplanFleetHorizon() + trimFirstSlotRemaining(slots, 2*time.Second) + _, _ = engine.Optimize(context.Background(), slots, p) + if capture.n != 1 { + t.Fatalf("worker trips=%d", capture.n) + } + var request externalRequest + if err := json.Unmarshal(capture.last, &request); err != nil { + t.Fatal(err) + } + want := energyplanTimeBudget(slots, p).Seconds() + if request.Settings.TimeLimitS != want || request.Settings.TimeLimitS > 2 { + t.Fatalf("TimeLimitS=%g, want %g (≤ remaining)", request.Settings.TimeLimitS, want) + } + if !capture.hasDeadline || capture.deadlineWait > 2*time.Second || capture.deadlineWait < time.Second { + t.Fatalf("process wait %v, want ≤ remaining 2s", capture.deadlineWait) + } +} + +func TestEnergyplanLateFleetReplanDoesNotStartDoomedSolve(t *testing.T) { + capture := &countingTransport{} + engine := &EnergyplanOptimizer{ExternalOptimizer: &ExternalOptimizer{ + cfg: ExternalOptimizerConfig{Timeout: 7 * time.Second}, + transport: capture, + timeBudget: energyplanTimeBudget, + }} + start := time.Date(2026, 9, 8, 4, 45, 0, 0, time.UTC) + now := start.Add(15*time.Minute - 200*time.Millisecond) + st, err := state.Open(t.TempDir() + "/t.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + for i := 0; i < 16; i++ { + slot := start.Add(time.Duration(i) * 15 * time.Minute) + if err := st.SavePrices([]state.PricePoint{{ + Zone: "SE3", SlotTsMs: slot.UnixMilli(), SlotLenMin: 15, + SpotOreKwh: 50, TotalOreKwh: 100, Source: "test", FetchedAtMs: start.UnixMilli(), + }}); err != nil { + t.Fatal(err) + } + } + tele := telemetry.NewStore() + soc := 0.5 + svc := New(st, tele, "SE3", Params{ + Mode: ModeArbitrage, SoCLevels: 11, ActionLevels: 5, + CapacityWh: 10000, InitialSoC: 0.5, SoCMin: 0.1, SoCMax: 0.95, + MaxChargeW: 4000, MaxDischargeW: 4000, + ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, + }) + svc.now = func() time.Time { return now } + svc.Optimizer = engine + svc.Horizon = 4 * time.Hour + svc.BaseLoad = 500 + for _, id := range []string{"battery-0", "battery-1"} { + tele.Update(id, telemetry.DerBattery, 0, &soc, nil) + tele.DriverHealthMut(id).RecordSuccess() + } + svc.UpdateBatteryFleet([]BatteryFleetMember{ + {Driver: "battery-0", CapacityWh: 5000, MaxChargeW: 2000, MaxDischargeW: 2000}, + {Driver: "battery-1", CapacityWh: 5000, MaxChargeW: 2000, MaxDischargeW: 2000}, + }, 10000, 4000, 4000) + previous := Plan{DecisionID: "keep-me", GeneratedAtMs: now.UnixMilli(), Actions: []Action{{SlotStartMs: start.UnixMilli(), SlotLenMin: 15}}} + svc.InstallPlan(previous, svc.Defaults, "") + plan := svc.Replan(context.Background()) + if capture.n != 0 { + t.Fatalf("doomed worker started %d times", capture.n) + } + if svc.PlanSnapshot().Reason == "slot_elapsed" { + t.Fatal("queued slot_elapsed from a plan that could not land") + } + if plan == nil || plan.DecisionID != "keep-me" { + t.Fatalf("wanted previous plan, got %+v", plan) + } +} diff --git a/go/internal/mpc/execution_time.go b/go/internal/mpc/execution_time.go index 821da0b9..d6fc6192 100644 --- a/go/internal/mpc/execution_time.go +++ b/go/internal/mpc/execution_time.go @@ -15,6 +15,17 @@ func (s Slot) DurationHours() float64 { return float64(s.StartMs+int64(s.LenMin)*60000-s.ExecutionStart()) / 3600000 } +func remainingFirstSlot(slots []Slot) time.Duration { + if len(slots) == 0 { + return 0 + } + rem := slots[0].StartMs + int64(slots[0].LenMin)*60000 - slots[0].ExecutionStart() + if rem <= 0 { + return 0 + } + return time.Duration(rem) * time.Millisecond +} + func (a Action) ExecutionStart() int64 { if a.ExecutionStartMs != 0 { return a.ExecutionStartMs diff --git a/go/internal/mpc/load_bounds.go b/go/internal/mpc/load_bounds.go index dab35c10..42e196bd 100644 --- a/go/internal/mpc/load_bounds.go +++ b/go/internal/mpc/load_bounds.go @@ -11,10 +11,10 @@ import ( // what the house actually used on recent days. Days can differ a lot, so // this only lifts a collapsed forecast — it never force-fits the shape. const ( - loadRainCheckDays = 3 - loadRainCheckMinFraction = 0.2 // lift only when forecast Wh < 20% of recent mean - loadRainCheckMaxScale = 1.5 - loadRecentMeanFloorFrac = 0.2 // no slot below 20% of recent mean watts + loadRainCheckDays = 3 + loadRainCheckMinFraction = 0.2 // lift only when forecast Wh < 20% of recent mean + loadRainCheckMaxScale = 1.5 + loadRecentMeanFloorFrac = 0.2 // no slot below 20% of recent mean watts loadRecentDayMinIntervals = 50 loadRecentDayMinWh = 500 ) @@ -57,11 +57,7 @@ func capPlanLoad(plan *Plan, minW, maxW float64) { func forecastLoadWh(slots []Slot) float64 { var wh float64 for _, s := range slots { - min := s.LenMin - if min <= 0 { - min = 60 - } - wh += math.Max(0, s.LoadW) * float64(min) / 60.0 + wh += math.Max(0, s.LoadW) * math.Max(0, s.DurationHours()) } return wh } diff --git a/go/internal/mpc/load_bounds_test.go b/go/internal/mpc/load_bounds_test.go index d2968c1e..029b1cbb 100644 --- a/go/internal/mpc/load_bounds_test.go +++ b/go/internal/mpc/load_bounds_test.go @@ -101,6 +101,23 @@ func TestRainCheckLoadNoHistoryNoOp(t *testing.T) { } } +func TestForecastLoadWhUsesRemainingHours(t *testing.T) { + t.Parallel() + start := time.Date(2026, 9, 8, 4, 45, 0, 0, time.UTC) + slots := []Slot{{ + StartMs: start.UnixMilli(), LenMin: 15, + ExecutionStartMs: start.Add(10 * time.Minute).UnixMilli(), + LoadW: 1200, + }} + if got := forecastLoadWh(slots); math.Abs(got-100) > 1e-9 { + t.Fatalf("rain-check energy=%g Wh, want 100 (5 min of 1200 W), not 300", got) + } + slots[0].ExecutionStartMs = 0 + if got := forecastLoadWh(slots); math.Abs(got-300) > 1e-9 { + t.Fatalf("full slot energy=%g Wh, want 300", got) + } +} + func TestRecentDailyLoadWhSkipsEmptyDays(t *testing.T) { st, err := state.Open(t.TempDir() + "/t.db") if err != nil {