From 05b75f825a7c2097ee17f2cfc3b97bea0ca11e4e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 4 Aug 2026 16:18:17 +0200 Subject: [PATCH] test(control): pin the golden scenario clock to one instant The golden recorder anchored each slot directive on time.Now() at the moment the directive was asked for, then let ComputeDispatch read time.Now() again further down. Energy-path targets are Wh x 3600 / seconds-remaining, so any pause between those two reads shortened the slot and moved the target. On an idle machine that was 1.5e-5 W of run-to-run drift. Under a 50 ms scheduler pause it is 0.46 W, which is 45x the corpus replay tolerance of 0.01 W. State gains an unexported clock func() time.Time. State.now() returns it when set and time.Now() when nil, and every one of the 17 ambient time reads in the package now goes through it. Production never sets the field and cannot: it is unexported, and go/cmd/ftw is the only non-test caller of control.NewState. Production keeps reading time.Now() on exactly the same lines it read it before. The golden recorder captures one instant per scenario and uses it for both the slot anchors and the dispatch clock, so the two can no longer disagree. Across three fresh recordings the 275 slot-bearing targets are now bit-identical; on master the same targets drift 1.6e-5 W. The remaining ~1e-12 W on non-slot targets is float summation order over a Go map in the weighted distributor, present on master too, and is why the 0.01 W tolerance stays. Co-authored-by: Claude Opus 5 --- go/internal/control/dispatch.go | 48 ++++++++++++++++--------- go/internal/control/golden_dump_test.go | 46 +++++++++++++++++++----- go/internal/control/golden_test.go | 9 +++-- 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index f200ffca..7c785a78 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -417,6 +417,10 @@ type State struct { LastDispatch *time.Time PrevTargets map[string]float64 + // clock is nil in production. Tests may set it to keep a dispatch + // scenario on one instant even when the test runner is delayed. + clock func() time.Time + LastTargets []DispatchTarget // Cascade toggle — set by main.go based on whether models exist @@ -970,6 +974,16 @@ func NewState(gridTargetW, gridToleranceW float64, siteMeter string) *State { } } +// now is the only wall-clock read in this package. Production leaves clock +// nil and gets time.Now(); a test may pin one instant so a scheduler pause +// between building a scenario and dispatching it cannot move a target. +func (s *State) now() time.Time { + if s != nil && s.clock != nil { + return s.clock() + } + return time.Now() +} + // SetGridTarget updates both the state and the PI setpoint. func (s *State) SetGridTarget(w float64) { s.GridTargetW = w @@ -1069,7 +1083,7 @@ func ComputeDispatch( // from this data. The point is to measure first, decide whether a // cap is warranted later. { - now := time.Now() + now := state.now() var liveBatTotal float64 for name := range driverCapacities { if r := store.Get(name, telemetry.DerBattery); r != nil { @@ -1153,7 +1167,7 @@ func ComputeDispatch( // pre-processing, the idle/charge short-circuits, the holdoff timer, // and the deadband. Falls through to distribute → slew → SoC clamp // → fuse guard so safety bounds still apply. - manualHold, manualHoldActive := state.GetBatteryManualHold(time.Now()) + manualHold, manualHoldActive := state.GetBatteryManualHold(state.now()) if manualHoldActive { // Reset PI + slot accumulators so reverting to a planner mode // after the hold expires doesn't read stale state — same reset @@ -1172,14 +1186,14 @@ func ComputeDispatch( // Already handled — leave effectiveMode at ModeSelfConsumption. case state.Mode == ModePlannerSelf: effectiveMode = ModeSelfConsumption - decision := preparePlannerSelf(state, time.Now()) + decision := preparePlannerSelf(state, state.now()) plannerSelfIdleGate = decision.idleGate plannerSelfExportSurplusGate = decision.exportSurplusGate plannerSelfNoChargeStalePlan = decision.noChargeOnStalePlan case state.Mode.IsPlannerMode(): // planner_cheap / planner_arbitrage. if state.UseEnergyDispatch && state.SlotDirective != nil { - if dir, ok := state.SlotDirective(time.Now()); ok { + if dir, ok := state.SlotDirective(state.now()); ok { currentDirective = dir // planner_arbitrage and planner_passive_arbitrage idle slots: skip the energy path and // fall through to reactive PI (same as planner_self does always). @@ -1270,7 +1284,7 @@ func ComputeDispatch( var gridW float64 ok := false if state.PlanTarget != nil { - modeStr, gridW, ok = state.PlanTarget(time.Now()) + modeStr, gridW, ok = state.PlanTarget(state.now()) } if ok { effectiveMode = Mode(modeStr) @@ -1302,7 +1316,7 @@ func ComputeDispatch( // model loop all depend on this being accurate. state.LastTargets = out if out != nil { - now := time.Now() + now := state.now() state.LastDispatch = &now } return out @@ -1319,7 +1333,7 @@ func ComputeDispatch( // setpoint and expects immediate effect, not a 5 s wait. The fuse // guard at the end of the cycle still protects the site. if !manualHoldActive && state.LastDispatch != nil { - elapsed := time.Since(*state.LastDispatch).Seconds() + elapsed := state.now().Sub(*state.LastDispatch).Seconds() if elapsed < float64(state.MinDispatchIntervalS) { // Holdoff suppresses normal re-dispatch, but the // fuse-saver overrides — an overflow can't wait 5 s for @@ -1330,7 +1344,7 @@ func ComputeDispatch( // consumers (status/history/learner) see the // commanded discharge. state.LastTargets = out - now := time.Now() + now := state.now() state.LastDispatch = &now } return out @@ -1480,7 +1494,7 @@ func ComputeDispatch( // over this slot". Derive the instantaneous power needed to hit the // remaining energy in the remaining time, then pass (target - currentTotal) // as the correction the existing distributors expect. - now := time.Now() + now := state.now() // Slot rollover: new slot → reset the delivered accumulator. if !currentDirective.SlotStart.Equal(state.currentDirective.SlotStart) { state.currentDirective = currentDirective @@ -1997,7 +2011,7 @@ func ComputeDispatch( threshold = 100 } chargeCeiling := unexpectedIdleExportBeyondPlanW(surplus, currentDirective, threshold) - remainingS := currentDirective.SlotEnd.Sub(time.Now()).Seconds() + remainingS := currentDirective.SlotEnd.Sub(state.now()).Seconds() headroomW := pvSurplusAbsorbHeadroomW(onlineBats, arbitrageFamilyIdleAbsorbCapPct, remainingS) if chargeCeiling > headroomW { chargeCeiling = headroomW @@ -2296,7 +2310,7 @@ func recordDispatchTargets(targets []DispatchTarget, state *State, updatePrevTar return } if recordDispatch { - now := time.Now() + now := state.now() state.LastDispatch = &now } if updatePrevTargets { @@ -2497,7 +2511,7 @@ func ComputePVCurtail(state *State, store *telemetry.Store) []CurtailTarget { return nil } - now := time.Now() + now := state.now() // Operator-installed manual hold takes precedence over the planner // directive. Driver-scoped → cap only that driver. Site-aggregate @@ -3209,7 +3223,7 @@ func planHasNonDischargeIntent(state *State) bool { const idleWh = 50.0 const idleGridW = 100.0 if state.SlotDirective != nil { - if dir, ok := state.SlotDirective(time.Now()); ok { + if dir, ok := state.SlotDirective(state.now()); ok { // For passive_arbitrage: only block reactive discharge when the // plan slot has explicit charge intent. Idle and discharge slots // get no non-discharge block — reactive discharge may cover load. @@ -3236,7 +3250,7 @@ func planHasNonDischargeIntent(state *State) bool { } } if state.PlanTarget != nil { - if modeStr, gridW, ok := state.PlanTarget(time.Now()); ok { + if modeStr, gridW, ok := state.PlanTarget(state.now()); ok { switch Mode(modeStr) { case ModeCharge: return true @@ -3481,7 +3495,7 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta // the planner can't ramp back through the boundary on the next // tick. Window is refreshed every time the clamp fires, so the // hold persists as long as the planner keeps trying to push past. - now := time.Now() + now := state.now() if state.FuseHoldUntil.After(now) { if state.FuseHoldMaxDischargeW > 0 { var totalDischarge float64 @@ -3775,7 +3789,7 @@ func planSignIntent(state *State) int { const idleWh = 50.0 // a near-zero per-slot energy is idle, not signed const idleGridW = 100.0 // matches mpc.IdleGateThresholdW for sign decisions if state.SlotDirective != nil { - if dir, ok := state.SlotDirective(time.Now()); ok { + if dir, ok := state.SlotDirective(state.now()); ok { if dir.BatteryEnergyWh > idleWh { // A charge-from-PV-surplus slot has no hard charge commitment // (see coverLoadChargeSlot) — report idle intent so the sign @@ -3792,7 +3806,7 @@ func planSignIntent(state *State) int { } } if state.PlanTarget != nil { - if modeStr, gridW, ok := state.PlanTarget(time.Now()); ok { + if modeStr, gridW, ok := state.PlanTarget(state.now()); ok { switch Mode(modeStr) { case ModeCharge: return +1 diff --git a/go/internal/control/golden_dump_test.go b/go/internal/control/golden_dump_test.go index 3f4a1cb9..73a95980 100644 --- a/go/internal/control/golden_dump_test.go +++ b/go/internal/control/golden_dump_test.go @@ -24,10 +24,9 @@ package control // Every record is one call to ComputeDispatch from a freshly-constructed // telemetry.Store and control.State (single-tick semantics: PI integral // starts at zero, PrevTargets empty, slot accumulator on its rollover -// branch). Slot directives are anchored to time.Now() with fixed nominal -// elapsed/remaining offsets, so energy-path magnitudes are deterministic -// up to sub-millisecond clock jitter (< 0.01 % relative) — which is why the -// replay compares with a tolerance rather than for equality. +// branch). Slot directives use one captured scenario instant with fixed +// nominal elapsed/remaining offsets. The dispatch clock uses that same instant +// so a scheduler pause cannot change an energy-path target. import ( "encoding/json" @@ -37,6 +36,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "sort" "testing" "time" @@ -203,6 +203,13 @@ func defaultGoldenState(mode Mode) goldenStateInputs { } func runGoldenScenario(sc goldenScenario) goldenRecord { + return runGoldenScenarioAt(sc, time.Now(), 0) +} + +// runGoldenScenarioAt keeps the scenario clock separate from the runner's +// wall clock. The optional delay models a scheduler pause after the slot +// directive has been requested, which used to change energy-path targets. +func runGoldenScenarioAt(sc goldenScenario, scenarioNow time.Time, slotDirectiveDelay time.Duration) goldenRecord { store := telemetry.NewStore() var meterData json.RawMessage @@ -265,6 +272,7 @@ func runGoldenScenario(sc goldenScenario) goldenRecord { si := sc.Inputs.State st := NewState(si.GridTargetW, si.GridToleranceW, "meter") + st.clock = func() time.Time { return scenarioNow } st.Mode = Mode(si.Mode) st.SlewRateW = si.SlewRateW st.SlewEnabled = si.SlewEnabled @@ -297,7 +305,7 @@ func runGoldenScenario(sc goldenScenario) goldenRecord { st.Weights = si.Weights } if si.HoldoffActive { - now := time.Now() + now := scenarioNow st.LastDispatch = &now } if sc.Inputs.Slot != nil { @@ -306,10 +314,12 @@ func runGoldenScenario(sc goldenScenario) goldenRecord { if !slot.Present { return SlotDirective{}, false } - now := time.Now() + if slotDirectiveDelay > 0 { + time.Sleep(slotDirectiveDelay) + } return SlotDirective{ - SlotStart: now.Add(-time.Duration(slot.ElapsedS * float64(time.Second))), - SlotEnd: now.Add(time.Duration(slot.RemainingS * float64(time.Second))), + SlotStart: scenarioNow.Add(-time.Duration(slot.ElapsedS * float64(time.Second))), + SlotEnd: scenarioNow.Add(time.Duration(slot.RemainingS * float64(time.Second))), BatteryEnergyWh: slot.BatteryEnergyWh, Strategy: slot.Strategy, PlannedGridW: slot.PlannedGridW, @@ -371,7 +381,7 @@ func runGoldenScenario(sc goldenScenario) goldenRecord { PlanStale: st.PlanStale, FuseSaturated: st.FuseSaturated, FuseEVMaxW: st.FuseEVMaxW, - FuseHoldLatched: st.FuseHoldUntil.After(time.Now()), + FuseHoldLatched: st.FuseHoldUntil.After(st.now()), FuseHoldMaxChargeW: st.FuseHoldMaxChargeW, FuseHoldMaxDischargeW: st.FuseHoldMaxDischargeW, PerPhaseOverageW: perPhase, @@ -406,6 +416,24 @@ func runGoldenScenario(sc goldenScenario) goldenRecord { } } +func TestGoldenPlannerScenarioIgnoresSchedulingDelay(t *testing.T) { + _, byID := readGoldenCorpus(t, goldenCorpusDir) + want, ok := byID["seeded_planner/095_planner_arbitrage"] + if !ok { + t.Fatal("missing seeded_planner/095_planner_arbitrage") + } + + scenario := goldenScenario{ID: want.ScenarioID, Seed: want.Seed, Inputs: want.Inputs} + scenarioNow := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC) + withoutDelay := runGoldenScenarioAt(scenario, scenarioNow, 0) + withDelay := runGoldenScenarioAt(scenario, scenarioNow, 50*time.Millisecond) + + if !reflect.DeepEqual(withoutDelay.PerDriverTargets, withDelay.PerDriverTargets) { + t.Fatalf("planner target changed after an intentional scheduling delay:\nwithout delay: %v\nwith delay: %v", + withoutDelay.PerDriverTargets, withDelay.PerDriverTargets) + } +} + // ---- scenario builder helpers ------------------------------------------- func gb(name string, capWh, curW, soc float64) goldenBattery { diff --git a/go/internal/control/golden_test.go b/go/internal/control/golden_test.go index f1eb7066..34883af5 100644 --- a/go/internal/control/golden_test.go +++ b/go/internal/control/golden_test.go @@ -32,11 +32,10 @@ package control // did not expect also moved, you have found something before your users did. // // Two things never fix a failure here: deleting the record, and widening the -// tolerance. The tolerance is 0.01 W absolute and exists for one reason — -// energy-path targets are computed as Wh × 3600 / seconds-remaining against a -// slot anchored to the wall clock, so they carry sub-millisecond jitter -// (measured run-to-run drift across the corpus: ≤ 1.3e-5 W). Exact float -// equality would flake. Anything above 0.01 W is a real change in behaviour. +// tolerance. The tolerance remains 0.01 W absolute so the corpus still reports +// small numeric changes without making the test depend on exact float output; +// golden scenarios now use one injected clock instant for the slot and the +// dispatch calculation. Anything above 0.01 W is a real change in behaviour. import ( "encoding/json"