Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/latest-replan-wins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": patch
---

Keep the newest MPC replan when solves finish out of order. Each request now
keeps its mode and reason together, and an older result cannot replace a plan
started after it.
27 changes: 22 additions & 5 deletions go/internal/api/api_loadpoint_schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,8 @@ import (
// what the handlers do: PUT stores and rolls, DELETE clears, and both
// force a replan tagged with the schedule-change reason.

// newScheduleServer wires a manager and an MPC service whose store is
// an empty temp db: ReplanWithReason records its reason and then
// returns at "no prices available yet", which is all a replan
// assertion needs.
// newScheduleServer wires a manager and an MPC service with enough input for
// the route-triggered replan to publish its plan and reason together.
func newScheduleServer(t *testing.T) (*Server, *loadpoint.Manager, *mpc.Service) {
t.Helper()
mgr := loadpoint.NewManager()
Expand All @@ -31,7 +29,26 @@ func newScheduleServer(t *testing.T) (*Server, *loadpoint.Manager, *mpc.Service)
t.Fatalf("opening state store: %v", err)
}
t.Cleanup(func() { st.Close() })
svc := &mpc.Service{Store: st, Zone: "SE4"}
start := time.Now().UTC().Truncate(15 * time.Minute)
prices := make([]state.PricePoint, 4)
for i := range prices {
prices[i] = state.PricePoint{
Zone: "SE4", SlotTsMs: start.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(),
SlotLenMin: 15, SpotOreKwh: 50, TotalOreKwh: 100,
Source: "test", FetchedAtMs: start.UnixMilli(),
}
}
if err := st.SavePrices(prices); err != nil {
t.Fatalf("saving prices: %v", err)
}
svc := mpc.New(st, nil, "SE4", mpc.Params{
Mode: mpc.ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5,
CapacityWh: 10000, InitialSoCPct: 50, SoCMinPct: 10, SoCMaxPct: 95,
MaxChargeW: 3000, MaxDischargeW: 3000,
ChargeEfficiency: 0.95, DischargeEfficiency: 0.95,
})
svc.Horizon = time.Hour
svc.BaseLoad = 500
return New(&Deps{Loadpoints: mgr, MPC: svc}), mgr, svc
}

Expand Down
44 changes: 39 additions & 5 deletions go/internal/mpc/reactive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ import (
"github.com/srcfl/ftw/go/internal/telemetry"
)

func seedReactivePrices(t *testing.T, st *state.Store) {
t.Helper()
start := time.Now().UTC().Truncate(15 * time.Minute)
prices := make([]state.PricePoint, 4)
for i := range prices {
prices[i] = state.PricePoint{
Zone: "SE3", SlotTsMs: start.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(),
SlotLenMin: 15, SpotOreKwh: 50, TotalOreKwh: 100,
Source: "test", FetchedAtMs: start.UnixMilli(),
}
}
if err := st.SavePrices(prices); err != nil {
t.Fatalf("save prices: %v", err)
}
}

func reactiveTestParams() Params {
return Params{
Mode: ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5,
CapacityWh: 10000, InitialSoCPct: 50, SoCMinPct: 10, SoCMaxPct: 95,
MaxChargeW: 3000, MaxDischargeW: 3000,
ChargeEfficiency: 0.95, DischargeEfficiency: 0.95,
}
}

// buildTestService spins up a minimal Service with one cached plan
// covering the current time, so checkDivergence has something to
// compare against.
Expand All @@ -19,6 +44,7 @@ func buildTestService(t *testing.T, planPV, planLoad float64) (*Service, *teleme
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
seedReactivePrices(t, st)
tel := telemetry.NewStore()
tel.DriverHealthMut("site").RecordSuccess()
tel.DriverHealthMut("inverter").RecordSuccess()
Expand All @@ -32,6 +58,9 @@ func buildTestService(t *testing.T, planPV, planLoad float64) (*Service, *teleme
MinReplanGap: time.Millisecond,
PVDivergenceWh: 500,
LoadDivergenceWh: 400,
Horizon: time.Hour,
Defaults: reactiveTestParams(),
BaseLoad: 500,
}
now := time.Now()
s.last = &Plan{
Expand Down Expand Up @@ -175,13 +204,16 @@ func buildDefaultTestService(t *testing.T, planPV, planLoad float64) (*Service,
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
seedReactivePrices(t, st)
tel := telemetry.NewStore()
tel.DriverHealthMut("site").RecordSuccess()
tel.DriverHealthMut("inverter").RecordSuccess()

// Mirror New()'s defaults so this test exercises the production
// reactive-trigger numbers.
s := New(st, tel, "SE3", Params{})
s := New(st, tel, "SE3", reactiveTestParams())
s.Horizon = time.Hour
s.BaseLoad = 500
s.SiteMeter = "site"
s.ReactiveInterval = 10 * time.Millisecond
now := time.Now()
Expand Down Expand Up @@ -255,8 +287,11 @@ func twinDriftService(t *testing.T) *Service {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
seedReactivePrices(t, st)
tel := telemetry.NewStore()
s := New(st, tel, "SE3", Params{})
s := New(st, tel, "SE3", reactiveTestParams())
s.Horizon = time.Hour
s.BaseLoad = 500
s.ReactiveInterval = 10 * time.Millisecond
// Make sure cooldown doesn't suppress the first trigger.
s.lastReplanAt = time.Now().Add(-time.Hour)
Expand All @@ -282,9 +317,8 @@ func TestTwinDriftReplanFiresOnLargePVShift(t *testing.T) {
// A live PV predictor that now returns 1500 W per slot — RMSE = 500 W,
// well past the 250 W threshold.
s.PV = func(time.Time, float64) float64 { return 1500 }
// Stub a minimal plan so replan() (called on trigger) doesn't panic —
// it'll bail with "no prices available yet" but lastReason has been
// set on the service before that call, which is what we assert.
// Stub a minimal active plan. The seeded prices let the triggered replan
// commit its reason with the replacement plan.
s.last = &Plan{GeneratedAtMs: now.UnixMilli()}

s.checkTwinDrift(context.Background())
Expand Down
Loading