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"