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
48 changes: 31 additions & 17 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
46 changes: 37 additions & 9 deletions go/internal/control/golden_dump_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -37,6 +36,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"sort"
"testing"
"time"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 4 additions & 5 deletions go/internal/control/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading