From 66e1caf50fe59408a239e5758a2f221d0ec56862 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 19:47:13 +0100 Subject: [PATCH 1/9] test(site): judge random runs by rig items R2-R7 of the 27 Oct triage The random judge called eight nightly seeds on master 83c2b24c wrong in ways the triage traced to the judge, not to Core: - R2: a stretch over the fuse took only the windows open at its first sample. Every window that opens inside it while it is still allowed now extends it, a Core restart included (207530, 207539). - R3: the battery counted as the 2 s lever while its inverter was offline or had no spare power. It is the lever only while it runs, is above its floor and its spare power covers the overage; otherwise the charger, at max(2, command_response_s) (207530, 207534, 207539). - R4: a car that starts on the charger's own default, or on an order still on its way, is a step FTW did not take, answered by the charger from draw start + max(2, command_response_s) + 5 s. A stretch is FTW's own only after FTW's last change to the car's order was a rise, or its battery order moved toward import (207532, 207534, 207537). - R5 (owner rule): between the fuse and 1.2 x the fuse a phase counts only once Core's 60 s I2 heat is over the fuse too; above 1.2 x at once (207536). r3 and r5a take the same heat budget (#1888 review). - R6: idle seconds with room under Charge now are counted from when the room appears and may last evRestartDwell + command + startup + a tick (+ 2 s meter and tick + 3 s). The count starts again on a Charge now that changes the request, on each FTW cut and when Core answers after a restart. Only meter and charger faults excuse the car. - R7: the floor judges FTW's own orders (status.dispatch), not the inverter register, against max(inverter floor, planner soc_min) with no +0.5 %. TestJudge now also judges the random seeds a results directory holds, from each run's plan.json, so a nightly run can be judged again. Co-Authored-By: Claude Opus 5.5 --- go/test/site/README.md | 6 +- go/test/site/evaluate_test.go | 74 ++++- go/test/site/random_test.go | 398 +++++++++++++++++++++---- go/test/site/random_unit_test.go | 276 +++++++++++++++++ go/test/site/rig_test.go | 21 +- go/test/site/rig_unit_test.go | 46 ++- go/test/site/scenarios_p0_unit_test.go | 40 +++ go/test/site/verdicts_test.go | 14 +- 8 files changed, 787 insertions(+), 88 deletions(-) create mode 100644 go/test/site/random_unit_test.go diff --git a/go/test/site/README.md b/go/test/site/README.md index b1a75ca3c..57f763c31 100644 --- a/go/test/site/README.md +++ b/go/test/site/README.md @@ -37,7 +37,7 @@ make siterig-judge DIR=/tmp/ftw-site-123 # judge a recorded run again | `FTW_SITE_DEVSIM_SRC` | a device-simulator checkout at the pinned commit; without it the test clones the pin into the user cache | | `FTW_SITE_FTW_BIN`, `FTW_SITE_DEVSIM_BIN` | prebuilt binaries | | `FTW_SITE_CATALOG` | the fault catalog, for `TestCatalogCoverage` | -| `FTW_SITE_JUDGE` | a results directory: `TestJudge` judges its runs again with the criteria as they are now, and rewrites their `result.json` and the summary | +| `FTW_SITE_JUDGE` | a results directory: `TestJudge` judges its runs again with the criteria as they are now, random seeds included (from each run's `plan.json`), and rewrites their `result.json` and the summary | Each run directory keeps `house/plant.jsonl` (the house once a second, with every event and mark), `house/ocpp-.jsonl` (every OCPP frame the @@ -79,7 +79,9 @@ its sources (`provenance` in [catalog_test.go](catalog_test.go)). "Over the limit" is the worst phase above the fuse rating, measured through the fuse. A device's response time counts as 2 s, the meter and control -tick as 2 s more. +tick as 2 s more. The random cases, `r3` and `r5a` judge a phase between +the fuse and 1.2 × the fuse as Core does: it counts only once its 60 s heat +(I² filter) is above the fuse too. | Scenario | House and steps | Criteria | Short | |---|---|---|---| diff --git a/go/test/site/evaluate_test.go b/go/test/site/evaluate_test.go index 0ab41b730..70a65fb06 100644 --- a/go/test/site/evaluate_test.go +++ b/go/test/site/evaluate_test.go @@ -46,10 +46,12 @@ type plantDevice struct { EMSCmd float64 `json:"ems_cmd"` // 170 charge, 187 discharge, 204 stop EMSSetW float64 `json:"ems_setpoint_w"` Connected bool `json:"connected"` + Running *bool `json:"running"` // an inverter that answers; nil when not recorded } type ftwStatus struct { Drivers map[string]ftwDriver `json:"drivers"` + Dispatch []ftwDispatch `json:"dispatch"` // Core's battery orders this tick LoadQuality *string `json:"load_quality"` LoadReason *string `json:"load_reason"` LoadW *float64 `json:"load_w"` @@ -61,6 +63,12 @@ type ftwStatus struct { } `json:"energy"` } +// ftwDispatch is one battery order Core sent: + charge, - discharge. +type ftwDispatch struct { + Driver string `json:"driver"` + TargetW float64 `json:"target_w"` +} + type ftwDriver struct { Status string `json:"status"` EVSessionWh *float64 `json:"ev_session_wh"` // a charger's session energy @@ -107,6 +115,7 @@ type sample struct { Bat2 plantDevice // the second, if any Status *ftwStatus LPs []ftwLoadpoint + HeatA [3]float64 // each phase's 60 s heat, filled in by warm } type frame struct { @@ -130,6 +139,7 @@ type runData struct { events []houseEventLine frames []frame // garage's charger meta map[string]any + warmed bool // every sample's HeatA is filled in } type markLine struct { @@ -390,10 +400,72 @@ func (d *runData) runsOver(t0, t1, limit float64) (longest, total float64) { return round(longest, 1), round(total, 1) } +// fuseHeatTauS is the time constant of Core's fuse heat model +// (control/fuse_heat.go). +const fuseHeatTauS = 60.0 + +// warm fills in each sample's HeatA: per phase, the 60 s I² filter of Core's +// fuse heat model, on the current through the fuse in either direction, +// from the record's first sample on. +func (d *runData) warm() { + if d.warmed { + return + } + d.warmed = true + var sq [3]float64 + for i, r := range d.samples { + for k, a := range r.PhaseA { + if i == 0 { + sq[k] = a * a + } else { + sq[k] += (1 - math.Exp(-(r.T-d.samples[i-1].T)/fuseHeatTauS)) * (a*a - sq[k]) + } + r.HeatA[k] = math.Sqrt(sq[k]) + } + } +} + +// heatOver is whether a phase is over the fuse as Core's heat model judges +// it (owner rule, 27 Oct 2026): above the fuse while its 60 s heat is above +// the fuse too, or above fuseInstantFactor x the fuse. Between the fuse and +// that factor the heat is the budget. Call warm first. +func heatOver(r *sample, fuse float64) bool { + for k, a := range r.PhaseA { + a = math.Abs(a) + if a > fuse && (r.HeatA[k] > fuse || a > fuseInstantFactor*fuse) { + return true + } + } + return false +} + +// heatRunsOver is the longest stretch in [t0, t1] that heatOver holds, +// timed like runsOver. +func (d *runData) heatRunsOver(t0, t1, fuse float64) float64 { + d.warm() + longest := 0.0 + var from *float64 + var last float64 + for _, r := range d.window(t0, t1) { + t := r.T + switch { + case heatOver(r, fuse) && from == nil: + from = &t + case !heatOver(r, fuse) && from != nil: + longest, from = math.Max(longest, t-*from), nil + } + last = t + } + if from != nil { + longest = math.Max(longest, last-*from) + } + return round(longest, 1) +} + // i2Over is the seconds the worst phase's I² filter (tau 60 s) is above // limit, and the filter's peak. func (d *runData) i2Over(t0, t1, limit float64) (over, peak float64) { - const tau = 60.0 + const tau = fuseHeatTauS var y, prev float64 started := false for _, r := range d.window(t0, t1) { diff --git a/go/test/site/random_test.go b/go/test/site/random_test.go index 537ac7cca..14cfa38c6 100644 --- a/go/test/site/random_test.go +++ b/go/test/site/random_test.go @@ -212,96 +212,306 @@ func (d *runData) timing() houseTiming { return h } -// eventWindow is when the house changed on its own, and how long FTW may -// take to answer it. +// eventWindow is a stretch of time: a fault, or a Charge now hold. type eventWindow struct{ from, to float64 } +// within is whether t falls in one of ws, or up to afterS after its end. +func within(ws []eventWindow, t, afterS float64) bool { + for _, w := range ws { + if t >= w.from && t <= w.to+afterS { + return true + } + } + return false +} + +// stepWindow is a change FTW did not make, and how long FTW may take to +// answer it: until from + extraS + its lever's time + 2 s for the meter and +// the tick + 3 s. +type stepWindow struct { + from, extraS float64 + leverS float64 // > 0: the only lever that can answer (the charger, for a car FTW did not start) + untilT float64 // > 0: the window ends here, whatever the lever (a Core restart) + noBattery bool // the step took the inverter away +} + +// What the random judge takes from the rig and from Core. +const ( + rigBatteryMaxW = 5000.0 // the simulated Sungrow's largest charge or discharge + evRestartDwellS = 35.0 // control/ev_fuse_budget.go evRestartDwell + tickS = 2.0 // the rig's site.control_interval_s + plannerFloorPct = 10.0 // Core's planner.soc_min when the config leaves it out (cmd/ftw/main.go) +) + +// dispatchW is Core's battery order on r, + charge; false when Core +// dispatched no battery. +func dispatchW(r *sample) (float64, bool) { + if r == nil || r.Status == nil || len(r.Status.Dispatch) == 0 { + return 0, false + } + w := 0.0 + for _, x := range r.Status.Dispatch { + w += x.TargetW + } + return w, true +} + +// worstSignedA is the phase current furthest from 0, with its direction. +func worstSignedA(r *sample) float64 { + w := 0.0 + for _, a := range r.PhaseA { + if math.Abs(a) > math.Abs(w) { + w = a + } + } + return w +} + +// chargeNowChanges says, for each Charge now in the run's plan, whether it +// asked for a power or phase mode other than the Charge now before it. +// Without a plan every Charge now counts as a change. +func (d *runData) chargeNowChanges() []bool { + b, err := os.ReadFile(filepath.Join(d.dir, "plan.json")) + if d.dir == "" || err != nil { + return nil + } + var p randomPlan + if json.Unmarshal(b, &p) != nil { + return nil + } + var out []bool + var last *ftwAction + for i, a := range p.Actions { + if a.Do != "charge_now" { + continue + } + out = append(out, last == nil || last.W != a.W || last.PhaseMode != a.PhaseMode) + last = &p.Actions[i] + } + return out +} + func judgeInvariants(d *runData) (metrics, []criterion) { fuse := d.metaF("fuse") h := d.timing() t0, t1 := d.marks["start"], d.marks["end"] run := d.window(t0, t1) + d.warm() m := metrics{"charger_command_s": h.commandS, "charger_startup_s": h.startupS, "events": len(d.events)} + charger := math.Max(2, h.commandS) // the charger answers in its own time, never under 2 s + orders := d.offers() // Windows in which an over-limit second is the house's doing. - var windows []eventWindow - faultUntil := map[float64]float64{} // a fault's start and end + var windows []stepWindow + var faults, chargerFaults, inverterOut []eventWindow for _, ev := range d.events { - batteryUsable := true - if r := d.at(ev.T); r != nil && r.Bat.SoCPct <= h.floorPct+1 { - batteryUsable = false - } - // The fastest lever: the battery answers in 2 s, the charger in its - // own time, never under 2 s; then the meter and tick, 2 s, and 3 s. - lever := math.Max(2, h.commandS) - if batteryUsable { - lever = 2 - } - allow := lever + 2 + 3 + w := stepWindow{from: ev.T} + until := 0.0 switch ev.Event.Do { case "meter_late", "silence", "offline", "reconnect": - allow += ev.Event.ForS - faultUntil[ev.T] = ev.T + ev.Event.ForS + w.extraS, until = ev.Event.ForS, ev.T+ev.Event.ForS case "meter_glitch": - faultUntil[ev.T] = ev.T + 5 + until = ev.T + 5 } - windows = append(windows, eventWindow{ev.T, ev.T + allow}) + if until > 0 { + f := eventWindow{ev.T, until} + faults = append(faults, f) + switch ev.Event.Device { + case "", "meter", "garage": + chargerFaults = append(chargerFaults, f) + case "inverter": + inverterOut = append(inverterOut, f) + w.noBattery = true + } + } + windows = append(windows, w) } for _, mk := range d.allMarks { if mk.Label == "ftw:restart" { - windows = append(windows, eventWindow{mk.T, mk.T + 60}) + windows = append(windows, stepWindow{from: mk.T, untilT: mk.T + 60}) } } - explained := func(t float64) (float64, bool) { - best, ok := 0.0, false - for _, w := range windows { - if t >= w.from && t <= w.to { - best, ok = math.Max(best, w.to), true + // A car that starts to draw more than FTW's last order lets it (the + // charger's own default, or a lower order still on its way) is a step + // FTW did not take, and only the charger can answer it. + orderW := func(t float64) (float64, bool) { + w, ok := 0.0, false + for _, p := range orders { + if p.T > t { + break + } + w, ok = p.offerW(), true + } + return w, ok + } + carStarts := 0 + for i := 1; i < len(d.samples); i++ { + r := d.samples[i] + if r.T < t0 || r.T > t1 || carW(r, 0)-carW(d.samples[i-1], 0) <= 1000 { + continue + } + if w, ok := orderW(r.T); !ok || carW(r, 0) > w+3*volt { + windows = append(windows, stepWindow{from: r.T, leverS: charger}) + carStarts++ + } + } + m["car_starts_ftw_did_not_order"] = carStarts + + // lever is how long FTW's fastest lever takes, from t, to take overA off + // the worst phase: the battery, in 2 s, while its inverter runs, it is + // above its floor and its spare power covers the overage; otherwise the + // charger. + lever := func(t, overA float64, export bool) float64 { + r := d.at(t) + if r == nil || within(inverterOut, t, 0) || (r.Bat.Running != nil && !*r.Bat.Running) || r.Bat.SoCPct <= h.floorPct+1 { + return charger + } + spare := rigBatteryMaxW + r.BatW // discharge more, or charge less + if export { + spare = rigBatteryMaxW - r.BatW + } + if spare < overA*3*volt { + return charger + } + return 2 + } + until := func(w stepWindow, at, overA float64, export bool) float64 { + if w.untilT > 0 { + return w.untilT + } + lv := w.leverS + if lv == 0 { + lv = charger + if !w.noBattery { + lv = lever(math.Max(w.from, at), overA, export) + } + } + return w.from + w.extraS + lv + 2 + 3 + } + // ownRise is whether FTW raised what a stretch from s can come from: its + // last change to the car's order was a rise, sent within the time the + // charger takes to apply it, or a battery order moved toward import in + // the last 10 s. + ownRise := func(s float64) bool { + changedAt, rose, prev := math.Inf(-1), false, 0.0 + for _, p := range orders { + if p.T > s { + break + } + if w := p.offerW(); math.Abs(w-prev) > 1 { + changedAt, rose, prev = p.T, w > prev, w + } + } + if rose && changedAt >= s-(h.commandS+h.startupS+10) { + return true + } + lo := math.Inf(1) + for _, r := range d.window(s-10, s) { + if w, ok := dispatchW(r); ok { + lo = math.Min(lo, w) } } - return best, ok + w, ok := dispatchW(d.at(s)) + return ok && w > lo+100 } // 1, 2: the fuse holds; over the limit only as long as an outside step - // allows, and at most 3 s after FTW's own raise. + // allows, and at most 3 s after FTW's own raise. Between the fuse and + // 1.2 x the fuse the 60 s heat is the budget (heatOver): the clock + // starts when the heat passes the fuse, as Core's cut does. blownAny := anyBlown(run) unexplained, longestOwn := 0.0, 0.0 var firstBad float64 - var start *sample + bad := func(t float64) { + if firstBad == 0 { + firstBad = t + } + } + // judged lists each stretch the heat or 1.2 x the fuse put on the clock: + // from, end, clock start and allowed until, in seconds from the start, + // and whether it was FTW's own raise or a step. + judged := [][]any{} + judgeStretch := func(stretch []*sample, end float64) { + s := stretch[0].T + var clock *sample + overA, export := 0.0, false + for _, r := range stretch { + if clock == nil && heatOver(r, fuse) { + clock = r + } + if w := worstSignedA(r); math.Abs(w)-fuse > overA { + overA, export = math.Abs(w)-fuse, w < 0 + } + } + if clock == nil { + return // the heat budget covers it + } + c := clock.T + to, explained, own := math.Inf(-1), false, false + for _, w := range windows { + if x := until(w, s, overA, export); w.from <= s && s <= x { + to, explained = math.Max(to, x), true + } + } + switch { + case explained: + case ownRise(s): + own, to = true, c+3 + default: // nothing FTW raised: a change outside it + to = s + lever(s, overA, export) + 2 + 3 + } + if !own && c > s { // Core answers once the heat says so + to = math.Max(to, c+lever(c, overA, export)+2+3) + } + // Every window that opens inside the stretch while it is still + // allowed extends it: a second step, an outage or a Core restart. + for grown := true; grown; { + grown = false + for _, w := range windows { + if x := until(w, w.from, overA, export); w.from >= s && w.from <= math.Min(end, to) && x > to { + to, own, grown = x, false, true + } + } + } + kind := "step" + if own { + kind = "own" + } + judged = append(judged, []any{round(s-t0, 1), round(end-t0, 1), round(c-t0, 1), round(to-t0, 1), kind}) + switch { + case end <= to: + case own: + longestOwn = math.Max(longestOwn, end-c) + bad(to) + default: + unexplained = math.Max(unexplained, end-to) + bad(to) + } + } + from := -1 for i, r := range run { over := maxAbsI(r) > fuse - if over && start == nil { - start = r - } - if (!over || i == len(run)-1) && start != nil { - end := r.T - if to, ok := explained(start.T); ok { - if end > to { - unexplained = math.Max(unexplained, end-to) - if firstBad == 0 { - firstBad = to - } - } - } else if end-start.T > 3 { - longestOwn = math.Max(longestOwn, end-start.T) - if firstBad == 0 { - firstBad = start.T + 3 - } + if over && from < 0 { + from = i + } + if (!over || i == len(run)-1) && from >= 0 { + hi := i + if over { + hi = i + 1 } - start = nil + judgeStretch(run[from:hi], r.T) + from = -1 } } + heatPeak := 0.0 + for _, r := range run { + heatPeak = math.Max(heatPeak, max(r.HeatA[0], r.HeatA[1], r.HeatA[2])) + } m["over_limit_after_steps_s"], m["over_limit_own_s"] = round(unexplained, 1), round(longestOwn, 1) + m["heat_peak_a"], m["over_limit_judged"] = round(heatPeak, 2), judged // 3: with every device reading and no fault, Home use is measured. - inFault := func(t float64) bool { - for from, to := range faultUntil { - if t >= from && t <= to+30 { - return true - } - } - return false - } + inFault := func(t float64) bool { return within(faults, t, 30) } restartAt := []float64{t0} for _, mk := range d.allMarks { if mk.Label == "ftw:up" { @@ -346,12 +556,24 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } m["home_use_estimated_while_reading_s"] = round(dishonest, 1) - // 4: during Charge now the car charges when there is room for it. + // 4: during Charge now the car charges when there is room for it. With + // room, Core waits evRestartDwell before it starts a stopped car, then + // the charger takes its own time, and a tick. The count of idle seconds + // with room starts again on each Charge now that changes the request, + // on each cut FTW sends and when Core answers after a restart. Only a + // fault of the meter or of the charger excuses an idle car. var holds []eventWindow + var graceFrom []float64 var holdFrom float64 + changes, k := d.chargeNowChanges(), 0 for _, mk := range d.allMarks { switch mk.Label { case "ftw:charge_now": + changed := k >= len(changes) || changes[k] + k++ + if holdFrom == 0 || changed { + graceFrom = append(graceFrom, mk.T) + } if holdFrom == 0 { holdFrom = mk.T } @@ -362,19 +584,56 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } } } - grace := 60 + h.commandS + h.startupS + 10 + for i := 1; i < len(orders); i++ { + if orders[i].offerW() < orders[i-1].offerW()-1 { + graceFrom = append(graceFrom, orders[i].T) + } + } + // While Core is down nothing can start the car; it starts the grace + // again when it answers. + var down []eventWindow + for i, mk := range d.allMarks { + if mk.Label != "ftw:restart" { + continue + } + up := t1 + for _, n := range d.allMarks[i:] { + if n.Label == "ftw:up" { + up = n.T + break + } + } + down = append(down, eventWindow{mk.T, up}) + graceFrom = append(graceFrom, up) + } + // The car may stand idle with room for Core's dwell, the charger's + // command and startup time and a tick, plus the 2 s for the meter and + // tick and 3 s the fuse budgets also give. + allowedIdle := evRestartDwellS + h.commandS + h.startupS + tickS + 2 + 3 + regrace := func(from, to float64) bool { + for _, g := range graceFrom { + if g > from && g <= to { + return true + } + } + return false + } idle, run4 := 0.0, 0.0 for _, hw := range holds { run4 = 0 - for i, r := range d.window(hw.from+grace, hw.to) { + ws := d.window(hw.from, hw.to) + for i, r := range ws { + if i > 0 && regrace(ws[i-1].T, r.T) { + run4 = 0 + } // Room below the fuse, the car's line in Core; 6 A is the car's // minimum on three phases. roomA := fuse - (maxAbsI(r) - carW(r, 0)/(3*volt)) - bad := carW(r, 0) < 0.5*1380 && roomA >= 6 && !inFault(r.T) && !nearRestart(r.T) + bad := carW(r, 0) < 0.5*1380 && roomA >= 6 && !within(chargerFaults, r.T, 30) && !within(down, r.T, 0) if bad && i > 0 { - run4++ + run4 += r.T - ws[i-1].T idle = math.Max(idle, run4) - if idle > 30 { + if run4 > allowedIdle { note("charge now", r.T) } } else { @@ -382,7 +641,7 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } } } - m["charge_now_idle_with_room_s"] = idle + m["charge_now_idle_with_room_s"], m["charge_now_idle_allowed_s"] = round(idle, 1), allowedIdle // 5: no order above the charger's 16 A per phase. over := 0 @@ -394,17 +653,26 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } m["offers_over_rating"] = over - // 6: FTW does not ask the battery to discharge at its floor. + // 6: FTW does not ask the battery to discharge at its floor: the higher + // of the inverter's own floor and the planner's soc_min. FTW's orders + // are judged, not the inverter's register, which can hold an order from + // before an outage. + floorPct := h.floorPct + if p := d.metaF("planner_floor_pct"); p > 0 { + floorPct = math.Max(floorPct, p) + } else { + floorPct = math.Max(floorPct, plannerFloorPct) + } floorS := 0.0 for _, r := range run { - if r.Bat.EMSCmd == 187 && r.Bat.EMSSetW > 50 && r.Bat.SoCPct <= h.floorPct+0.5 { + if w, ok := dispatchW(r); ok && w < -50 && r.Bat.SoCPct <= floorPct { floorS++ if floorS > 10 { note("floor", r.T) } } } - m["discharge_at_floor_s"] = floorS + m["discharge_at_floor_s"], m["floor_pct"] = floorS, floorPct // 7: a stale meter means no raise. raises := 0 @@ -434,7 +702,7 @@ func judgeInvariants(d *runData) (metrics, []criterion) { crit("over the limit no longer than an outside step allows", unexplained == 0), crit("over the limit at most 3 s after FTW's own raise", longestOwn == 0), crit("Home use measured while every device reads", dishonest <= 10), - crit("Charge now charges when there is room", idle <= 30), + crit("Charge now charges when there is room", idle <= allowedIdle), crit("no offer above the charger's rating", over == 0), crit("no discharge asked at the battery's floor", floorS <= 10), crit("no raise on a stale meter", raises == 0), diff --git a/go/test/site/random_unit_test.go b/go/test/site/random_unit_test.go new file mode 100644 index 000000000..9ec5b1097 --- /dev/null +++ b/go/test/site/random_unit_test.go @@ -0,0 +1,276 @@ +//go:build unix + +package site + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" +) + +// Unit tests for the random judge (judgeInvariants), each on the shape of +// a nightly seed that the judge called wrong (triage of 27 Oct 2026, rig +// items R2-R7). They run in every go test, without a simulator. + +const ( + limitStep = "over the limit no longer than an outside step allows" + limitOwn = "over the limit at most 3 s after FTW's own raise" + chargeNow = "Charge now charges when there is room" + atFloor = "no discharge asked at the battery's floor" +) + +// randomRun is n one-second samples on a fuse, from start at 0 to end at +// n-1: house current base per phase, the car idle, the battery at 50 % +// with its inverter running. set changes sample i. +func randomRun(n int, fuse, base float64, set func(i int, r *sample)) *runData { + d := &runData{marks: map[string]float64{"start": 0, "end": float64(n - 1)}, meta: map[string]any{"fuse": fuse}} + running := true + for i := 0; i < n; i++ { + r := &sample{T: float64(i), PhaseA: [3]float64{base, base, base}, CarW: []float64{0}, + Bat: plantDevice{SoCPct: 50, Running: &running}} + if set != nil { + set(i, r) + } + d.samples = append(d.samples, r) + } + return d +} + +// withCharger records the charger's command and startup times as the house +// wrote them, so the judge reads them as from a real run. +func withCharger(t *testing.T, d *runData, commandS, startupS float64) { + t.Helper() + dir := t.TempDir() + spec := houseSpec{Devices: []houseDevice{{ID: "garage", CommandResponseS: &commandS, StartupDelayS: &startupS}}} + b, _ := json.Marshal(spec) + if err := os.MkdirAll(filepath.Join(dir, "house"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "house", "timeline.json"), b, 0o644); err != nil { + t.Fatal(err) + } + d.dir = dir +} + +func judged(d *runData) map[string]bool { + _, cs := judgeInvariants(d) + return verdictsOf(cs) +} + +// R2, seed 207539's shape: a step the battery answers opens the stretch, +// and a Core restart 1 s into it extends what the stretch may take. A +// restart that comes after the step's time ran out excuses nothing. +func TestInvariantsExtendAStretchByEveryWindowInsideIt(t *testing.T) { + for _, c := range []struct { + name string + restartAt float64 // 0: no restart + want bool + }{ + {"restart 1 s into the stretch", 120, true}, + {"no restart", 0, false}, + {"restart after the step's time", 130, false}, + } { + // 20 A fuse; 26 A (1.3 x) from 119 to 143 s, after a load step + // at 114.7 s. The battery's 5 kW (7.2 A) covers the 6 A overage, + // so the step alone allows 2 + 2 + 3 s. + d := randomRun(301, 20, 10, func(i int, r *sample) { + if i >= 119 && i < 144 { + r.PhaseA = [3]float64{26, 13, 12} + } + }) + withCharger(t, d, 40, 0) + d.events = []houseEventLine{{T: 114.7, Event: houseEvent{Do: "loads"}}} + if c.restartAt > 0 { + d.allMarks = []markLine{{T: c.restartAt, Label: "ftw:restart"}, {T: c.restartAt + 9, Label: "ftw:up"}} + } + if got := judged(d)[limitStep]; got != c.want { + t.Errorf("%s: %q = %v, want %v", c.name, limitStep, got, c.want) + } + } +} + +// R3, seeds 207534, 207530 and 207539: the battery answers in 2 s only +// while its inverter runs and its spare power covers the overage; otherwise +// the charger is the lever, in its own time (5 s here). +func TestInvariantsBatteryIsTheLeverOnlyWhenItCanAnswer(t *testing.T) { + for _, c := range []struct { + name string + batW float64 + running bool + want bool + }{ + {"battery idle, 7.2 A spare for a 4 A overage", 0, true, false}, + {"battery already discharging 3266 W, 2.5 A spare", -3266, true, true}, + {"inverter offline", 0, false, true}, + } { + // 16 A fuse; 20 A (1.25 x) from 50 to 59 s after a step at 49.5 s: + // 9.5 s, past the battery's 7 s and inside the charger's 10 s. + d := randomRun(101, 16, 8, func(i int, r *sample) { + r.BatW, r.Bat.Running = c.batW, &c.running + if i >= 50 && i < 59 { + r.PhaseA = [3]float64{20, 12, 12} + } + }) + withCharger(t, d, 5, 0) + d.events = []houseEventLine{{T: 49.5, Event: houseEvent{Do: "loads"}}} + if got := judged(d)[limitStep]; got != c.want { + t.Errorf("%s: %q = %v, want %v", c.name, limitStep, got, c.want) + } + } +} + +// R4, seeds 207532 and 207537: a car that starts on the charger's own +// 16 A while FTW's order is 0 A is a step FTW did not take, answered by the +// 40 s charger. The same start right after FTW raised the order to 16 A is +// FTW's own. +func TestInvariantsCarStartingOnItsOwnIsAStep(t *testing.T) { + for _, c := range []struct { + name string + frames []frame + wantStep bool + wantOwn bool + }{ + {"FTW ordered 0 A", []frame{profileAt(0, 0), profileAt(20, 0)}, true, true}, + {"FTW raised to 16 A", []frame{profileAt(0, 0), profileAt(25, 16)}, true, false}, + } { + // 16 A fuse; the car draws 11 040 W from 30 s; the meter shows + // 20 A (1.25 x) from 31 to 39 s. + d := randomRun(121, 16, 4, func(i int, r *sample) { + if i >= 30 && i < 40 { + r.CarW[0] = 11040 + } + if i >= 31 && i < 39 { + r.PhaseA = [3]float64{20, 18, 19} + } + }) + withCharger(t, d, 40, 30) + d.frames = c.frames + v := judged(d) + if v[limitStep] != c.wantStep || v[limitOwn] != c.wantOwn { + t.Errorf("%s: step %v, own %v; want %v, %v", c.name, v[limitStep], v[limitOwn], c.wantStep, c.wantOwn) + } + } +} + +// R5, seed 207536: between the fuse and 1.2 x the fuse the 60 s heat is the +// budget (owner rule, 27 Oct 2026). 27.84 A on 25 A for 15 s from 15 A +// passes; above 1.2 x it fails once the step's time is up; so does the same +// 27.84 A held until the heat passes the fuse, and 25.5 A on a fuse already +// warm at its rating. +func TestInvariantsJudgeBetweenFuseAndInstantByHeat(t *testing.T) { + for _, c := range []struct { + name string + base, a float64 + from, to int + want bool + }{ + {"27.84 A for 15 s", 15, 27.84, 50, 65, true}, + {"30.1 A for 15 s", 15, 30.1, 50, 65, false}, + {"27.84 A for 200 s", 15, 27.84, 50, 250, false}, + {"25.5 A for 15 s on a warm fuse", 25, 25.5, 50, 65, false}, + } { + d := randomRun(301, 25, c.base, func(i int, r *sample) { + if i >= c.from && i < c.to { + r.PhaseA[0] = c.a + } + }) + d.events = []houseEventLine{{T: 49.5, Event: houseEvent{Do: "loads"}}} + if got := judged(d)[limitStep]; got != c.want { + t.Errorf("%s: %q = %v, want %v", c.name, limitStep, got, c.want) + } + } +} + +// R6, seeds 207533, 207536 and 207539: during Charge now an idle car with +// room is allowed Core's 35 s dwell, the charger's command and startup time +// and a tick, plus 5 s (72 s here, with a 30 s startup). The grace starts +// again on each Charge now that changes the request, on each cut FTW sends +// and when Core answers after a restart. A fault of the meter or the +// charger excuses the car; an inverter outage does not. +func TestInvariantsChargeNowGrace(t *testing.T) { + for _, c := range []struct { + name string + startS int // the car draws from here; 0: never + marks []markLine + frames []frame + events []houseEventLine + want bool + }{ + {"starts after 70 s", 71, nil, nil, nil, true}, + {"starts after 80 s", 81, nil, nil, nil, false}, + {"a changed Charge now at 60 s", 121, []markLine{{T: 60, Label: "ftw:charge_now"}}, nil, nil, true}, + {"FTW cuts at 60 s", 121, nil, []frame{profileAt(0, 16), profileAt(60, 0)}, nil, true}, + {"Core restarts at 50 s and answers at 60 s", 121, + []markLine{{T: 50, Label: "ftw:restart"}, {T: 60, Label: "ftw:up"}}, nil, nil, true}, + {"the meter late for 40 s from 10 s", 140, nil, nil, + []houseEventLine{{T: 10, Event: houseEvent{Do: "meter_late", ForS: 40}}}, true}, + {"the inverter offline for 40 s from 10 s", 140, nil, nil, + []houseEventLine{{T: 10, Event: houseEvent{Do: "offline", Device: "inverter", ForS: 40}}}, false}, + } { + // 16 A fuse, a 5 A house: 11 A of room. Charge now at 1 s. + d := randomRun(201, 16, 5, func(i int, r *sample) { + if c.startS > 0 && i >= c.startS { + r.CarW[0] = 6900 + r.PhaseA = [3]float64{15, 15, 15} + } + }) + withCharger(t, d, 0, 30) + d.allMarks = append([]markLine{{T: 1, Label: "ftw:charge_now"}}, c.marks...) + d.allMarks = append(d.allMarks, markLine{T: 200, Label: "end"}) + d.frames, d.events = c.frames, c.events + if got := judged(d)[chargeNow]; got != c.want { + t.Errorf("%s: %q = %v, want %v", c.name, chargeNow, got, c.want) + } + } +} + +// R7, seeds 207534 and 207536: the floor judges FTW's own discharge orders +// (status.dispatch) against the higher of the inverter's 5 % and the +// planner's 10 %, with no extra 0.5 %. A forced discharge the inverter +// still holds while FTW dispatches nothing is not FTW's order. +func TestInvariantsFloorJudgesFTWsOrders(t *testing.T) { + for _, c := range []struct { + name string + socPct float64 + orderW float64 // FTW's dispatch; 0: none + forcedW float64 // the inverter's register + want bool + }{ + {"FTW orders 3 kW at 5.0 %", 5, -3000, 3000, false}, + {"FTW orders 3 kW at 9 %, under the planner's 10 %", 9, -3000, 3000, false}, + {"FTW orders 3 kW at 10.5 %", 10.5, -3000, 3000, true}, + {"the register holds 3 kW at 5.0 %, FTW orders nothing", 5, 0, 3000, true}, + } { + d := randomRun(61, 16, 5, func(i int, r *sample) { + r.Bat.SoCPct = c.socPct + r.Bat.EMSMode, r.Bat.EMSCmd, r.Bat.EMSSetW = 2, 187, c.forcedW + r.Status = &ftwStatus{} + if c.orderW != 0 { + r.Status.Dispatch = []ftwDispatch{{Driver: "sungrow", TargetW: c.orderW}} + } + }) + if got := judged(d)[atFloor]; got != c.want { + t.Errorf("%s: %q = %v, want %v", c.name, atFloor, got, c.want) + } + } +} + +// TestJudge also judges the random seeds a results directory holds, from +// the plan each run wrote. +func TestRecordedRandomFindsSeedRuns(t *testing.T) { + dir := t.TempDir() + for _, seed := range []int64{7, 9} { + b, _ := json.Marshal(drawPlan(seed)) + os.MkdirAll(filepath.Join(dir, fmt.Sprintf("random-%d", seed)), 0o755) + os.WriteFile(filepath.Join(dir, fmt.Sprintf("random-%d", seed), "plan.json"), b, 0o644) + } + var got []string + for _, sc := range recordedRandom(dir) { + got = append(got, sc.name+"@"+sc.dir) + } + if len(got) != 2 || got[0] != "r7@random-7" || got[1] != "r9@random-9" { + t.Fatalf("recordedRandom = %v", got) + } +} diff --git a/go/test/site/rig_test.go b/go/test/site/rig_test.go index a2730cf71..f507fc33d 100644 --- a/go/test/site/rig_test.go +++ b/go/test/site/rig_test.go @@ -375,7 +375,7 @@ func TestJudge(t *testing.T) { t.Skip("set FTW_SITE_JUDGE to a results directory of an earlier run") } var outs []outcome - for _, sc := range selected(t, "full") { + for _, sc := range append(selected(t, "full"), recordedRandom(dir)...) { rundir := filepath.Join(dir, sc.dir) b, err := os.ReadFile(filepath.Join(rundir, "meta.json")) if err != nil { @@ -403,6 +403,25 @@ func TestJudge(t *testing.T) { writeSummary(t, dir, outs) } +// recordedRandom is each random seed recorded in dir, from the plan.json +// its run wrote, so TestJudge judges a nightly random run again. +func recordedRandom(dir string) []scenario { + files, _ := filepath.Glob(filepath.Join(dir, "random-*", "plan.json")) + sort.Strings(files) + var out []scenario + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + continue + } + var p randomPlan + if json.Unmarshal(b, &p) == nil && p.Found == nil { + out = append(out, randomScenario(p)) + } + } + return out +} + func writeSummary(t *testing.T, out string, outs []outcome) { if len(outs) == 0 { return diff --git a/go/test/site/rig_unit_test.go b/go/test/site/rig_unit_test.go index 6ceab9e1c..d4ae8e810 100644 --- a/go/test/site/rig_unit_test.go +++ b/go/test/site/rig_unit_test.go @@ -107,20 +107,26 @@ func TestRandomPlansAreSeeded(t *testing.T) { } // An over-limit stretch right after an outside step is the house's; the -// same stretch with no step before it is FTW's own and fails after 3 s. +// same stretch right after FTW raised the car's order is FTW's own and +// fails after 3 s. With neither before it, it counts as a step FTW did not +// take (R4) and gets the step's time. 31 A is over 1.2 x the 25 A fuse, so +// the heat gives no budget. func TestInvariantsTellStepsFromOwnRaises(t *testing.T) { - mk := func(withStep bool) *runData { + mk := func(withStep, withRise bool) *runData { d := &runData{marks: map[string]float64{"start": 0, "end": 100}, meta: map[string]any{"fuse": 25.0}} for i := 0; i <= 100; i++ { a := 10.0 if i >= 50 && i < 56 { - a = 26 // 6 s over the 25 A fuse + a = 31 // 6 s over the 25 A fuse } d.samples = append(d.samples, &sample{T: float64(i), PhaseA: [3]float64{a, a, a}, Bat: plantDevice{SoCPct: 50}}) } if withStep { d.events = append(d.events, houseEventLine{T: 49.5, Event: houseEvent{Do: "loads"}}) } + if withRise { + d.frames = []frame{profileAt(40, 0), profileAt(45, 16)} + } return d } failed := func(cs []criterion) []string { @@ -132,38 +138,50 @@ func TestInvariantsTellStepsFromOwnRaises(t *testing.T) { } return out } - if _, cs := judgeInvariants(mk(true)); len(failed(cs)) != 0 { + if _, cs := judgeInvariants(mk(true, false)); len(failed(cs)) != 0 { t.Fatalf("after a step: %v", failed(cs)) } - _, cs := judgeInvariants(mk(false)) + if _, cs := judgeInvariants(mk(false, false)); len(failed(cs)) != 0 { + t.Fatalf("after nothing FTW raised: %v", failed(cs)) + } + _, cs := judgeInvariants(mk(false, true)) if f := failed(cs); len(f) != 1 || f[0] != "over the limit at most 3 s after FTW's own raise" { - t.Fatalf("without a step: %v", f) + t.Fatalf("after FTW's raise: %v", f) } } // The limit is the fuse rating: a phase may sit at exactly the fuse until -// the end of the run, and 0.1 A above it fails once the time budget is up. +// the end of the run, and 0.1 A above it fails once the time budget is up +// while the fuse is already warm (heat at the fuse). From a cool fuse the +// same 20 s stays inside the heat budget. func TestInvariantsJudgeAtTheFuse(t *testing.T) { const fuse = 25.0 const own = "over the limit at most 3 s after FTW's own raise" const step = "over the limit no longer than an outside step allows" for _, c := range []struct { name string - a float64 - to int // the level holds from 50 s until this second + base, a float64 // the level before 50 s, and from 50 s + to int // the level holds from 50 s until this second withStep bool want []string }{ - {"at the fuse", fuse, 101, false, nil}, - {"at the fuse after a step", fuse, 101, true, nil}, + {"at the fuse", fuse, fuse, 101, false, nil}, + {"at the fuse after a step", fuse, fuse, 101, true, nil}, // 20 s is past the 3 s for FTW's own raise, and past the // 2 + 2 + 3 s a step allows while the battery can answer. - {"0.1 A over", fuse + 0.1, 70, false, []string{own}}, - {"0.1 A over after a step", fuse + 0.1, 70, true, []string{step}}, + {"0.1 A over", fuse, fuse + 0.1, 70, false, []string{own}}, + {"0.1 A over after a step", fuse, fuse + 0.1, 70, true, []string{step}}, + {"0.1 A over a cool fuse", 10, fuse + 0.1, 70, false, nil}, + {"0.1 A over a cool fuse after a step", 10, fuse + 0.1, 70, true, nil}, } { d := &runData{marks: map[string]float64{"start": 0, "end": 100}, meta: map[string]any{"fuse": fuse}} + // FTW raised the car's order at 45 s. + d.frames = []frame{profileAt(40, 0), profileAt(45, 16)} + if c.withStep { + d.frames = nil + } for i := 0; i <= 100; i++ { - a := 10.0 + a := c.base if i >= 50 && i < c.to { a = c.a } diff --git a/go/test/site/scenarios_p0_unit_test.go b/go/test/site/scenarios_p0_unit_test.go index be1d59704..dfdb6624c 100644 --- a/go/test/site/scenarios_p0_unit_test.go +++ b/go/test/site/scenarios_p0_unit_test.go @@ -407,3 +407,43 @@ func TestJudgeR3JudgesAtTheFuse(t *testing.T) { t.Error("25.1 A for 80 s passes") } } + +// Between the fuse and 1.2 x the fuse R3 and R5a allow what Core's 60 s +// heat allows (the #1888 review): a cool site may sit at 1.08 x the fuse +// past the budget while the heat stays under the fuse; above 1.2 x the +// budget holds. +func TestJudgeR3AndR5aAllowTheHeatBudget(t *testing.T) { + const r3over = "over the fuse no longer than the charger's delay + 7 s" + const r5over = "over the fuse at most 3 s once under it" + r3 := func(a float64) bool { + d := synth(400, map[string]float64{"plug": 0, "charge_now": 20, "end": 400}, + map[string]any{"fuse": 25.0, "charger_delay_s": 60.0, "planner_floor_pct": 10.0}, func(i int, r *sample) { + if i >= 100 && i < 180 { // 80 s, past the 67 s budget + r.PhaseA = [3]float64{a, a, a} + } + }) + _, cs := judgeR3(d) + return verdictsOf(cs)[r3over] + } + if !r3(27) { + t.Error("R3: 27 A for 80 s from a cool fuse fails; its heat stays under 25 A") + } + if r3(30.5) { + t.Error("R3: 30.5 A, over 1.2 x the fuse, for 80 s passes") + } + r5 := func(a float64) bool { + _, cs := judgeR5a(r5aRun(30, func(i int) float64 { + if i >= 200 && i < 215 { // 15 s, past the 3 s budget + return a + } + return 20 + })) + return verdictsOf(cs)[r5over] + } + if !r5(27) { + t.Error("R5a: 27 A for 15 s after 2 min at 20 A fails; its heat stays under 25 A") + } + if r5(30.5) { + t.Error("R5a: 30.5 A, over 1.2 x the fuse, for 15 s passes") + } +} diff --git a/go/test/site/verdicts_test.go b/go/test/site/verdicts_test.go index 428f985c8..64d3fbb99 100644 --- a/go/test/site/verdicts_test.go +++ b/go/test/site/verdicts_test.go @@ -670,11 +670,13 @@ func judgeR3(d *runData) (metrics, []criterion) { } minSoC := minOf(run, func(r *sample) float64 { return r.Bat.SoCPct }) // Core may work up to the fuse rating; safety_margin_a is a site's - // opt-in, so the overage that counts is above the fuse. - over, _ := d.runsOver(tCN, t1, fuse) + // opt-in, so the overage that counts is above the fuse. Between the + // fuse and 1.2 x the fuse Core's 60 s heat is the budget (heatOver). + overInstant, _ := d.runsOver(tCN, t1, fuse) + over := d.heatRunsOver(tCN, t1, fuse) allowed := delay + 7 m := metrics{"discharge_below_floor_samples": below, "battery_order_swing_last5min_w": math.Round(swing), - "soc_min_pct": round(minSoC, 2), "over_fuse_longest_s": over, + "soc_min_pct": round(minSoC, 2), "over_fuse_longest_s": overInstant, "over_fuse_heat_longest_s": over, "allowed_over_s": allowed, "max_i": round(maxOf(run, maxAbsI), 2)} return m, []criterion{ crit("the battery reached the planner floor", len(run) > 0 && minSoC <= floor+0.5), // the setup @@ -739,7 +741,9 @@ func (d *runData) homeUseBack(t0 float64) *float64 { // judgeR5a: Core starting beside a car that already draws its maximum, with // the site over the fuse, brings the site under the limit within 30 s and // keeps it there. The limit is the fuse: Core may work up to the rating, -// and safety_margin_a is a site's opt-in. +// and safety_margin_a is a site's opt-in. Once under it, a reading between +// the fuse and 1.2 x the fuse counts only while the 60 s heat is over the +// fuse too (heatOver), as Core's cut does. func judgeR5a(d *runData) (metrics, []criterion) { fuse := d.metaF("fuse") tS, tUp, t1 := d.marks["ftw_start"], d.marks["ftw_up"], d.marks["end"] @@ -748,7 +752,7 @@ func judgeR5a(d *runData) (metrics, []criterion) { fit := d.firstT(tUp, func(r *sample) bool { return maxAbsI(r) <= fuse }) overAfter := 0.0 if fit != nil { - overAfter, _ = d.runsOver(tUp+*fit, t1, fuse) + overAfter = d.heatRunsOver(tUp+*fit, t1, fuse) } shown := d.homeUseBack(tUp) m := metrics{"car_w_before_start": math.Round(pre), "under_limit_s": roundp(fit, 1), "over_after_that_longest_s": overAfter, From 44e74f307bc621b81126eb2c8c023f5011bc21b6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 20:14:24 +0100 Subject: [PATCH 2/9] test(site): tighten the random judge after review of #1920 - A stretch over the fuse that no outside change explains is FTW's own again (3 s after the heat clock), in either direction. The guess that it was a step let export raises and warm fuses split by short dips pass. ownRise is gone: with that rule it decided nothing. - A window that opens inside FTW's own raise no longer turns it into a step; only a step's stretch grows. - Charge now: a cut no longer restarts the idle count. A cut FTW had to make stops the car, and the count starts when the car stops; a cut with room is FTW's doing. The restart on Core's return was dead (the down mask already resets the count). - The battery lever uses the floor the floor check uses (max of the inverter floor and the planner soc_min). At the floor it can only charge less. - Only a meter fault adds its length to a step's time. An inverter outage takes the battery lever away; a charger outage delays the charger lever by what is left of it. - New check (R8, C1): a forced battery mode FTW does not dispatch lasts at most 10 s once the inverter and Core are back. Co-Authored-By: Claude Opus 5.5 --- go/test/site/random_test.go | 165 +++++++++++++++++-------------- go/test/site/random_unit_test.go | 161 +++++++++++++++++++++++++++--- go/test/site/rig_unit_test.go | 18 ++-- 3 files changed, 248 insertions(+), 96 deletions(-) diff --git a/go/test/site/random_test.go b/go/test/site/random_test.go index 14cfa38c6..beab667e1 100644 --- a/go/test/site/random_test.go +++ b/go/test/site/random_test.go @@ -301,15 +301,18 @@ func judgeInvariants(d *runData) (metrics, []criterion) { charger := math.Max(2, h.commandS) // the charger answers in its own time, never under 2 s orders := d.offers() - // Windows in which an over-limit second is the house's doing. + // Windows in which an over-limit second is the house's doing. Only a + // meter fault adds its length to the time FTW may take: it blinds FTW. + // An inverter outage takes the battery lever away; a charger outage + // delays the charger lever (chargerLever). var windows []stepWindow - var faults, chargerFaults, inverterOut []eventWindow + var faults, chargerFaults, inverterOut, chargerOut []eventWindow for _, ev := range d.events { w := stepWindow{from: ev.T} until := 0.0 switch ev.Event.Do { case "meter_late", "silence", "offline", "reconnect": - w.extraS, until = ev.Event.ForS, ev.T+ev.Event.ForS + until = ev.T + ev.Event.ForS case "meter_glitch": until = ev.T + 5 } @@ -317,8 +320,12 @@ func judgeInvariants(d *runData) (metrics, []criterion) { f := eventWindow{ev.T, until} faults = append(faults, f) switch ev.Event.Device { - case "", "meter", "garage": + case "", "meter": chargerFaults = append(chargerFaults, f) + w.extraS = ev.Event.ForS + case "garage": + chargerFaults = append(chargerFaults, f) + chargerOut = append(chargerOut, f) case "inverter": inverterOut = append(inverterOut, f) w.noBattery = true @@ -326,6 +333,15 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } windows = append(windows, w) } + // The battery's floor: the higher of the inverter's own floor and the + // planner's soc_min. FTW may not discharge it there (check 6), so there + // it is no lever for import either. + floorPct := h.floorPct + if p := d.metaF("planner_floor_pct"); p > 0 { + floorPct = math.Max(floorPct, p) + } else { + floorPct = math.Max(floorPct, plannerFloorPct) + } for _, mk := range d.allMarks { if mk.Label == "ftw:restart" { windows = append(windows, stepWindow{from: mk.T, untilT: mk.T + 60}) @@ -357,21 +373,35 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } m["car_starts_ftw_did_not_order"] = carStarts + // chargerLever is how long the charger takes to answer from t: its own + // time, after the rest of any outage it is in. + chargerLever := func(t float64) float64 { + left := 0.0 + for _, w := range chargerOut { + if t >= w.from && t <= w.to { + left = math.Max(left, w.to-t) + } + } + return charger + left + } // lever is how long FTW's fastest lever takes, from t, to take overA off - // the worst phase: the battery, in 2 s, while its inverter runs, it is - // above its floor and its spare power covers the overage; otherwise the - // charger. + // the worst phase: the battery, in 2 s, while its inverter runs and its + // spare power covers the overage; otherwise the charger. At its floor + // the battery may only charge less, never discharge more. lever := func(t, overA float64, export bool) float64 { r := d.at(t) - if r == nil || within(inverterOut, t, 0) || (r.Bat.Running != nil && !*r.Bat.Running) || r.Bat.SoCPct <= h.floorPct+1 { - return charger + if r == nil || within(inverterOut, t, 0) || (r.Bat.Running != nil && !*r.Bat.Running) { + return chargerLever(t) } spare := rigBatteryMaxW + r.BatW // discharge more, or charge less - if export { + switch { + case export: spare = rigBatteryMaxW - r.BatW + case r.Bat.SoCPct <= floorPct: + spare = math.Max(0, r.BatW) } if spare < overA*3*volt { - return charger + return chargerLever(t) } return 2 } @@ -380,40 +410,16 @@ func judgeInvariants(d *runData) (metrics, []criterion) { return w.untilT } lv := w.leverS - if lv == 0 { - lv = charger - if !w.noBattery { - lv = lever(math.Max(w.from, at), overA, export) - } + switch { + case lv > 0: + lv += chargerLever(w.from) - charger + case w.noBattery: + lv = chargerLever(w.from) + default: + lv = lever(math.Max(w.from, at), overA, export) } return w.from + w.extraS + lv + 2 + 3 } - // ownRise is whether FTW raised what a stretch from s can come from: its - // last change to the car's order was a rise, sent within the time the - // charger takes to apply it, or a battery order moved toward import in - // the last 10 s. - ownRise := func(s float64) bool { - changedAt, rose, prev := math.Inf(-1), false, 0.0 - for _, p := range orders { - if p.T > s { - break - } - if w := p.offerW(); math.Abs(w-prev) > 1 { - changedAt, rose, prev = p.T, w > prev, w - } - } - if rose && changedAt >= s-(h.commandS+h.startupS+10) { - return true - } - lo := math.Inf(1) - for _, r := range d.window(s-10, s) { - if w, ok := dispatchW(r); ok { - lo = math.Min(lo, w) - } - } - w, ok := dispatchW(d.at(s)) - return ok && w > lo+100 - } // 1, 2: the fuse holds; over the limit only as long as an outside step // allows, and at most 3 s after FTW's own raise. Between the fuse and @@ -453,23 +459,20 @@ func judgeInvariants(d *runData) (metrics, []criterion) { to, explained = math.Max(to, x), true } } - switch { - case explained: - case ownRise(s): + if !explained { // no change outside FTW: its own, import or export own, to = true, c+3 - default: // nothing FTW raised: a change outside it - to = s + lever(s, overA, export) + 2 + 3 } if !own && c > s { // Core answers once the heat says so to = math.Max(to, c+lever(c, overA, export)+2+3) } - // Every window that opens inside the stretch while it is still + // Every window that opens inside a step's stretch while it is still // allowed extends it: a second step, an outage or a Core restart. - for grown := true; grown; { + // FTW's own raise stays its own. + for grown := !own; grown; { grown = false for _, w := range windows { if x := until(w, w.from, overA, export); w.from >= s && w.from <= math.Min(end, to) && x > to { - to, own, grown = x, false, true + to, grown = x, true } } } @@ -559,9 +562,12 @@ func judgeInvariants(d *runData) (metrics, []criterion) { // 4: during Charge now the car charges when there is room for it. With // room, Core waits evRestartDwell before it starts a stopped car, then // the charger takes its own time, and a tick. The count of idle seconds - // with room starts again on each Charge now that changes the request, - // on each cut FTW sends and when Core answers after a restart. Only a - // fault of the meter or of the charger excuses an idle car. + // with room starts again on each Charge now that changes the request. + // A cut FTW had to send needs no new start: the count runs only while + // the car is idle with room, so it starts when the cut car stops. A + // cut with room is FTW's doing and starts nothing. While Core is down + // the count stops. Only a fault of the meter or of the charger excuses + // an idle car. var holds []eventWindow var graceFrom []float64 var holdFrom float64 @@ -584,13 +590,10 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } } } - for i := 1; i < len(orders); i++ { - if orders[i].offerW() < orders[i-1].offerW()-1 { - graceFrom = append(graceFrom, orders[i].T) - } - } - // While Core is down nothing can start the car; it starts the grace - // again when it answers. + // roomA is the room below the fuse for the car, Core's line; 6 A is + // the car's minimum on three phases. + roomA := func(r *sample) float64 { return fuse - (maxAbsI(r) - carW(r, 0)/(3*volt)) } + // While Core is down nothing can start the car. var down []eventWindow for i, mk := range d.allMarks { if mk.Label != "ftw:restart" { @@ -604,7 +607,6 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } } down = append(down, eventWindow{mk.T, up}) - graceFrom = append(graceFrom, up) } // The car may stand idle with room for Core's dwell, the charger's // command and startup time and a tick, plus the 2 s for the meter and @@ -626,10 +628,7 @@ func judgeInvariants(d *runData) (metrics, []criterion) { if i > 0 && regrace(ws[i-1].T, r.T) { run4 = 0 } - // Room below the fuse, the car's line in Core; 6 A is the car's - // minimum on three phases. - roomA := fuse - (maxAbsI(r) - carW(r, 0)/(3*volt)) - bad := carW(r, 0) < 0.5*1380 && roomA >= 6 && !within(chargerFaults, r.T, 30) && !within(down, r.T, 0) + bad := carW(r, 0) < 0.5*1380 && roomA(r) >= 6 && !within(chargerFaults, r.T, 30) && !within(down, r.T, 0) if bad && i > 0 { run4 += r.T - ws[i-1].T idle = math.Max(idle, run4) @@ -653,16 +652,9 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } m["offers_over_rating"] = over - // 6: FTW does not ask the battery to discharge at its floor: the higher - // of the inverter's own floor and the planner's soc_min. FTW's orders - // are judged, not the inverter's register, which can hold an order from - // before an outage. - floorPct := h.floorPct - if p := d.metaF("planner_floor_pct"); p > 0 { - floorPct = math.Max(floorPct, p) - } else { - floorPct = math.Max(floorPct, plannerFloorPct) - } + // 6: FTW does not ask the battery to discharge at its floor (floorPct). + // FTW's orders are judged, not the inverter's register, which can hold + // an order from before an outage; check 8 judges that. floorS := 0.0 for _, r := range run { if w, ok := dispatchW(r); ok && w < -50 && r.Bat.SoCPct <= floorPct { @@ -690,6 +682,27 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } } m["raises_on_a_stale_meter"] = raises + + // 8: a battery FTW does not dispatch is back in its own mode within + // 10 s: no forced charge or discharge left in the inverter's register + // once the inverter and Core are both up again (R8, C1). The 10 s + // count starts when both are back. + forcedS, run8 := 0.0, 0.0 + for i, r := range run { + w, ok := dispatchW(r) + stray := r.Status != nil && (!ok || math.Abs(w) <= 50) && math.Abs(batOrder(r)) > 50 && + !within(inverterOut, r.T, 0) && !within(down, r.T, 0) + if stray && i > 0 { + run8 += r.T - run[i-1].T + forcedS = math.Max(forcedS, run8) + if run8 > 10 { + note("forced battery", r.T) + } + } else { + run8 = 0 + } + } + m["battery_forced_without_dispatch_s"] = round(forcedS, 1) // The first second an invariant broke, from the start, per invariant: // a kept seed is cut a minute after the one it failed on. firsts := map[string]float64{} @@ -706,6 +719,7 @@ func judgeInvariants(d *runData) (metrics, []criterion) { crit("no offer above the charger's rating", over == 0), crit("no discharge asked at the battery's floor", floorS <= 10), crit("no raise on a stale meter", raises == 0), + crit("no forced battery mode FTW does not dispatch", forcedS <= 10), } } @@ -758,6 +772,7 @@ var invariantKey = map[string]string{ "no offer above the charger's rating": "rating", "no discharge asked at the battery's floor": "floor", "no raise on a stale meter": "stale meter", + "no forced battery mode FTW does not dispatch": "forced battery", } // keepFailure writes a failing random run as a case to keep: its plan with diff --git a/go/test/site/random_unit_test.go b/go/test/site/random_unit_test.go index 9ec5b1097..ea502de39 100644 --- a/go/test/site/random_unit_test.go +++ b/go/test/site/random_unit_test.go @@ -186,27 +186,37 @@ func TestInvariantsJudgeBetweenFuseAndInstantByHeat(t *testing.T) { // R6, seeds 207533, 207536 and 207539: during Charge now an idle car with // room is allowed Core's 35 s dwell, the charger's command and startup time // and a tick, plus 5 s (72 s here, with a 30 s startup). The grace starts -// again on each Charge now that changes the request, on each cut FTW sends -// and when Core answers after a restart. A fault of the meter or the -// charger excuses the car; an inverter outage does not. +// again on each Charge now that changes the request and on each cut FTW +// had to send, over the fuse or with no room; a cut with room does not. +// While Core is down the count stops. A fault of the meter or the charger +// excuses the car; an inverter outage does not. func TestInvariantsChargeNowGrace(t *testing.T) { for _, c := range []struct { name string - startS int // the car draws from here; 0: never + startS int // the car draws from here; 0: never + overS [2]int // the car draws 16 A, 21 A on the 16 A fuse, in [from, to) marks []markLine frames []frame events []houseEventLine want bool }{ - {"starts after 70 s", 71, nil, nil, nil, true}, - {"starts after 80 s", 81, nil, nil, nil, false}, - {"a changed Charge now at 60 s", 121, []markLine{{T: 60, Label: "ftw:charge_now"}}, nil, nil, true}, - {"FTW cuts at 60 s", 121, nil, []frame{profileAt(0, 16), profileAt(60, 0)}, nil, true}, - {"Core restarts at 50 s and answers at 60 s", 121, + {"starts after 70 s", 71, [2]int{}, nil, nil, nil, true}, + {"starts after 80 s", 81, [2]int{}, nil, nil, nil, false}, + {"a changed Charge now at 60 s", 121, [2]int{}, []markLine{{T: 60, Label: "ftw:charge_now"}}, nil, nil, true}, + {"FTW cuts the car over the fuse at 60 s", 121, [2]int{10, 61}, nil, + []frame{profileAt(0, 16), profileAt(60, 0)}, nil, true}, + {"FTW cuts an idle car with room at 60 s", 121, [2]int{}, nil, + []frame{profileAt(0, 16), profileAt(60, 0)}, nil, false}, + {"FTW hunts 6 A, 0 A, 6 A with room", 0, [2]int{}, nil, + []frame{profileAt(0, 6), profileAt(15, 0), profileAt(30, 6), profileAt(45, 0), + profileAt(60, 6), profileAt(75, 0), profileAt(90, 6), profileAt(105, 0), + profileAt(120, 6), profileAt(135, 0), profileAt(150, 6), profileAt(165, 0), + profileAt(180, 6), profileAt(195, 0)}, nil, false}, + {"Core restarts at 50 s and answers at 60 s", 121, [2]int{}, []markLine{{T: 50, Label: "ftw:restart"}, {T: 60, Label: "ftw:up"}}, nil, nil, true}, - {"the meter late for 40 s from 10 s", 140, nil, nil, + {"the meter late for 40 s from 10 s", 140, [2]int{}, nil, nil, []houseEventLine{{T: 10, Event: houseEvent{Do: "meter_late", ForS: 40}}}, true}, - {"the inverter offline for 40 s from 10 s", 140, nil, nil, + {"the inverter offline for 40 s from 10 s", 140, [2]int{}, nil, nil, []houseEventLine{{T: 10, Event: houseEvent{Do: "offline", Device: "inverter", ForS: 40}}}, false}, } { // 16 A fuse, a 5 A house: 11 A of room. Charge now at 1 s. @@ -215,6 +225,10 @@ func TestInvariantsChargeNowGrace(t *testing.T) { r.CarW[0] = 6900 r.PhaseA = [3]float64{15, 15, 15} } + if i >= c.overS[0] && i < c.overS[1] { + r.CarW[0] = 11040 + r.PhaseA = [3]float64{21, 21, 21} + } }) withCharger(t, d, 0, 30) d.allMarks = append([]markLine{{T: 1, Label: "ftw:charge_now"}}, c.marks...) @@ -257,6 +271,131 @@ func TestInvariantsFloorJudgesFTWsOrders(t *testing.T) { } } +// An over-limit stretch no outside change explains is FTW's own, whichever +// way the current flows and however it is cut up: FTW's discharge pushing +// export over the fuse, and a warm fuse held over its rating with a 1 s dip +// every 7 s. Both fail after 3 s. +func TestInvariantsAStretchWithNoStepIsFTWsOwn(t *testing.T) { + export := randomRun(121, 16, -10, func(i int, r *sample) { + w := 0.0 + if i >= 47 { + w, r.BatW = -5000, -5000 + } + r.Status = &ftwStatus{Dispatch: []ftwDispatch{{Driver: "sungrow", TargetW: w}}} + if i >= 50 && i < 56 { + r.PhaseA = [3]float64{-21, -21, -21} + } + }) + dips := randomRun(601, 25, 25.5, func(i int, r *sample) { + if i%7 == 6 { + r.PhaseA = [3]float64{24.9, 24.9, 24.9} + } + }) + for name, d := range map[string]*runData{"export 6 s at 1.31 x In": export, "600 s on a warm fuse, with dips": dips} { + withCharger(t, d, 5, 0) + if v := judged(d); v[limitOwn] { + t.Errorf("%s: %q passed", name, limitOwn) + } + } +} + +// A window that opens inside FTW's own raise does not turn it into a step: +// FTW raises the car at 45 s and the phases sit at 31 A (1.24 x 25 A) from +// 50 to 60 s; PV rising at 51 s changes nothing. The battery is at its +// floor, so the 40 s charger would be a step's lever. +func TestInvariantsAnEventInsideFTWsRaiseLeavesItOwn(t *testing.T) { + for _, withEvent := range []bool{false, true} { + d := randomRun(121, 25, 10, func(i int, r *sample) { + r.Bat.SoCPct = 6 + if i >= 50 && i < 60 { + r.PhaseA = [3]float64{31, 31, 31} + } + }) + withCharger(t, d, 40, 0) + d.frames = []frame{profileAt(40, 0), profileAt(45, 16)} + if withEvent { + d.events = []houseEventLine{{T: 51, Event: houseEvent{Do: "pv", Device: "inverter"}}} + } + if v := judged(d); v[limitOwn] { + t.Errorf("event %v: FTW's raise held 10 s at 1.24 x In and passed", withEvent) + } + } +} + +// The battery is the 2 s lever for import only above the floor the floor +// check uses (the planner's 10 % here), and never while its inverter is +// out; a charger outage delays the charger. Only a meter fault adds its +// length to a step's time. 16 A fuse, a 5 s charger, 20 A on L1 (1.25 x). +func TestInvariantsLeverAndFaults(t *testing.T) { + for _, c := range []struct { + name string + socPct float64 + from, to int // 20 A on L1 in [from, to) + events []houseEventLine + want bool + }{ + {"a step at 8 %: the charger answers", 8, 50, 59, nil, true}, + {"a step at 11 %: the battery answers", 11, 50, 59, nil, false}, + {"a step while the inverter is offline", 50, 50, 59, + []houseEventLine{{T: 30, Event: houseEvent{Do: "offline", Device: "inverter", ForS: 40}}}, true}, + {"the inverter offline 40 s, 15 s over", 50, 50, 65, + []houseEventLine{{T: 45, Event: houseEvent{Do: "offline", Device: "inverter", ForS: 40}}}, false}, + {"the meter late 40 s, 15 s over", 50, 50, 65, + []houseEventLine{{T: 45, Event: houseEvent{Do: "meter_late", ForS: 40}}}, true}, + {"the charger away 20 s, 18 s over", 8, 50, 68, + []houseEventLine{{T: 45, Event: houseEvent{Do: "reconnect", Device: "garage", ForS: 20}}}, true}, + } { + d := randomRun(121, 16, 8, func(i int, r *sample) { + r.Bat.SoCPct = c.socPct + if i >= c.from && i < c.to { + r.PhaseA = [3]float64{20, 12, 12} + } + }) + withCharger(t, d, 5, 0) + d.events = append([]houseEventLine{{T: 49.5, Event: houseEvent{Do: "loads"}}}, c.events...) + if got := judged(d)[limitStep]; got != c.want { + t.Errorf("%s: %q = %v, want %v", c.name, limitStep, got, c.want) + } + } +} + +// R8, C1: once the inverter and Core are back, a battery FTW does not +// dispatch leaves its forced mode within 10 s. +func TestInvariantsNoForcedBatteryWithoutDispatch(t *testing.T) { + const forced = "no forced battery mode FTW does not dispatch" + for _, c := range []struct { + name string + forcedS int // the register holds 187 / 3 kW until here + orderW float64 // FTW's dispatch; 0: none + down bool // Core, not the inverter, is away from 10 to 40 s + want bool + }{ + {"8 s after the inverter is back", 48, 0, false, true}, + {"30 s after the inverter is back", 70, 0, false, false}, + {"FTW dispatches the discharge", 120, -3000, false, true}, + {"8 s after Core is back", 48, 0, true, true}, + {"30 s after Core is back", 70, 0, true, false}, + } { + d := randomRun(121, 16, 5, func(i int, r *sample) { + r.Status = &ftwStatus{} + if c.orderW != 0 { + r.Status.Dispatch = []ftwDispatch{{Driver: "sungrow", TargetW: c.orderW}} + } + if i < c.forcedS { + r.Bat.EMSMode, r.Bat.EMSCmd, r.Bat.EMSSetW = 2, 187, 3000 + } + }) + if c.down { + d.allMarks = []markLine{{T: 10, Label: "ftw:restart"}, {T: 40, Label: "ftw:up"}} + } else { + d.events = []houseEventLine{{T: 10, Event: houseEvent{Do: "offline", Device: "inverter", ForS: 30}}} + } + if got := judged(d)[forced]; got != c.want { + t.Errorf("%s: %q = %v, want %v", c.name, forced, got, c.want) + } + } +} + // TestJudge also judges the random seeds a results directory holds, from // the plan each run wrote. func TestRecordedRandomFindsSeedRuns(t *testing.T) { diff --git a/go/test/site/rig_unit_test.go b/go/test/site/rig_unit_test.go index d4ae8e810..fff3d3251 100644 --- a/go/test/site/rig_unit_test.go +++ b/go/test/site/rig_unit_test.go @@ -107,10 +107,9 @@ func TestRandomPlansAreSeeded(t *testing.T) { } // An over-limit stretch right after an outside step is the house's; the -// same stretch right after FTW raised the car's order is FTW's own and -// fails after 3 s. With neither before it, it counts as a step FTW did not -// take (R4) and gets the step's time. 31 A is over 1.2 x the 25 A fuse, so -// the heat gives no budget. +// same stretch with no step before it is FTW's own and fails after 3 s, +// whether or not the record shows FTW raising anything. 31 A is over +// 1.2 x the 25 A fuse, so the heat gives no budget. func TestInvariantsTellStepsFromOwnRaises(t *testing.T) { mk := func(withStep, withRise bool) *runData { d := &runData{marks: map[string]float64{"start": 0, "end": 100}, meta: map[string]any{"fuse": 25.0}} @@ -141,12 +140,11 @@ func TestInvariantsTellStepsFromOwnRaises(t *testing.T) { if _, cs := judgeInvariants(mk(true, false)); len(failed(cs)) != 0 { t.Fatalf("after a step: %v", failed(cs)) } - if _, cs := judgeInvariants(mk(false, false)); len(failed(cs)) != 0 { - t.Fatalf("after nothing FTW raised: %v", failed(cs)) - } - _, cs := judgeInvariants(mk(false, true)) - if f := failed(cs); len(f) != 1 || f[0] != "over the limit at most 3 s after FTW's own raise" { - t.Fatalf("after FTW's raise: %v", f) + for _, withRise := range []bool{false, true} { + _, cs := judgeInvariants(mk(false, withRise)) + if f := failed(cs); len(f) != 1 || f[0] != "over the limit at most 3 s after FTW's own raise" { + t.Fatalf("with no step, FTW's raise %v: %v", withRise, f) + } } } From 4c48376fcb9696e56d932903ea4335c5b70fb13e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 20:39:49 +0100 Subject: [PATCH 3/9] test(site): R8 counts a forced battery from when Core has the inverter back The random judge's R8 check counted 10 s from the inverter's return. The Modbus reconnect backoff (up to 60 s) holds Core's first good read back, so the check could not pass even with C1 fixed (207539: 55.7 s). The count now starts at Core's first good read of the inverter after an outage or a restart, at the latest 60 s after the return. After an outage it allows the registry's 30 s default retry (defaultRetryMax) until #1915 (C11) lands. While Core is stopped with SIGTERM, a running inverter must leave its forced mode within 10 s of the stop: the driver cleanup writes the default (triage item 10). Co-Authored-By: Claude Opus 5.5 --- go/test/site/evaluate_test.go | 5 +- go/test/site/random_test.go | 102 ++++++++++++++++++---- go/test/site/random_unit_test.go | 143 ++++++++++++++++++++++++------- 3 files changed, 204 insertions(+), 46 deletions(-) diff --git a/go/test/site/evaluate_test.go b/go/test/site/evaluate_test.go index 70a65fb06..7a2f93145 100644 --- a/go/test/site/evaluate_test.go +++ b/go/test/site/evaluate_test.go @@ -70,8 +70,9 @@ type ftwDispatch struct { } type ftwDriver struct { - Status string `json:"status"` - EVSessionWh *float64 `json:"ev_session_wh"` // a charger's session energy + Status string `json:"status"` + EVSessionWh *float64 `json:"ev_session_wh"` // a charger's session energy + ConsecutiveErrors *int `json:"consecutive_errors"` // failed polls in a row; 0 once a poll reads } type ftwLoadpoint struct { diff --git a/go/test/site/random_test.go b/go/test/site/random_test.go index beab667e1..1f07cfa98 100644 --- a/go/test/site/random_test.go +++ b/go/test/site/random_test.go @@ -225,6 +225,60 @@ func within(ws []eventWindow, t, afterS float64) bool { return false } +// inverterBackFrom gives, for a time t, when the R8 count may start after +// the latest return before t: the inverter's from an outage (inverterOut) +// or Core's from a restart (down). Core has the inverter back at its first +// good read of it (no consecutive errors), which the Modbus reconnect +// backoff may hold back up to reconnectBackoffMaxS. After an outage, a +// default that failed while the inverter was away waits on the registry's +// retry timer, up to defaultRetryMaxS more, until #1915 (C11) sends it on +// the first good poll. A Core that starts fresh has no such wait. +func (d *runData) inverterBackFrom(inverterOut, down []eventWindow) func(t float64) float64 { + type back struct { + at, from float64 + } + var backs []back + add := func(at float64, outage bool) { + from := at + reconnectBackoffMaxS + for _, r := range d.samples { + if r.T >= at && r.T < from && readsInverter(r) { + from = r.T + break + } + } + if outage { + from += defaultRetryMaxS + } + backs = append(backs, back{at, from}) + } + for _, w := range inverterOut { + add(w.to, true) + } + for _, w := range down { + add(w.to, false) + } + return func(t float64) float64 { + at, from := math.Inf(-1), math.Inf(-1) + for _, b := range backs { + if b.at <= t && b.at >= at { + at, from = b.at, b.from + } + } + return from + } +} + +// readsInverter is whether Core's last poll of the inverter succeeded. A +// status without the driver's error count says nothing, and counts as a +// read. +func readsInverter(r *sample) bool { + if r.Status == nil { + return false + } + dr, ok := r.Status.Drivers[inverterDriver] + return !ok || dr.ConsecutiveErrors == nil || *dr.ConsecutiveErrors == 0 +} + // stepWindow is a change FTW did not make, and how long FTW may take to // answer it: until from + extraS + its lever's time + 2 s for the meter and // the tick + 3 s. @@ -241,6 +295,13 @@ const ( evRestartDwellS = 35.0 // control/ev_fuse_budget.go evRestartDwell tickS = 2.0 // the rig's site.control_interval_s plannerFloorPct = 10.0 // Core's planner.soc_min when the config leaves it out (cmd/ftw/main.go) + + // R8: how long a battery FTW does not dispatch may hold a forced mode, + // and the Core timers that may delay it after the inverter returns. + inverterDriver = "sungrow" // the rig's inverter driver (stack_test.go) + forcedBudgetS = 10.0 + reconnectBackoffMaxS = 60.0 // modbus/client.go reconnectBackoffMax + defaultRetryMaxS = 30.0 // drivers/registry.go defaultRetryMax; 0 once #1915 (C11) lands ) // dispatchW is Core's battery order on r, + charge; false when Core @@ -683,26 +744,37 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } m["raises_on_a_stale_meter"] = raises - // 8: a battery FTW does not dispatch is back in its own mode within - // 10 s: no forced charge or discharge left in the inverter's register - // once the inverter and Core are both up again (R8, C1). The 10 s - // count starts when both are back. - forcedS, run8 := 0.0, 0.0 + // 8: a battery FTW does not dispatch runs in its own mode (R8, C1). The + // inverter's register may hold a forced charge or discharge FTW does + // not dispatch for forcedBudgetS. After an inverter outage or a Core + // restart the count starts once Core has the inverter back (backFrom). + // While Core is stopped with SIGTERM its driver cleanup writes the + // default, so a running inverter gets the same 10 s from the stop. One + // that comes back while Core is down has its outage's allowance. + backFrom := d.inverterBackFrom(inverterOut, down) + forcedS, pastS, run8, from8 := 0.0, 0.0, 0.0, 0.0 for i, r := range run { w, ok := dispatchW(r) - stray := r.Status != nil && (!ok || math.Abs(w) <= 50) && math.Abs(batOrder(r)) > 50 && - !within(inverterOut, r.T, 0) && !within(down, r.T, 0) - if stray && i > 0 { - run8 += r.T - run[i-1].T - forcedS = math.Max(forcedS, run8) - if run8 > 10 { - note("forced battery", r.T) - } - } else { + stray := math.Abs(batOrder(r)) > 50 && !within(inverterOut, r.T, 0) + if !within(down, r.T, 0) { + stray = stray && r.Status != nil && (!ok || math.Abs(w) <= 50) + } + if !stray || i == 0 { run8 = 0 + continue + } + if run8 == 0 { + from8 = run[i-1].T + } + run8 += r.T - run[i-1].T + forcedS = math.Max(forcedS, run8) + if r.T > math.Max(from8, backFrom(r.T))+forcedBudgetS { + pastS += r.T - run[i-1].T + note("forced battery", r.T) } } m["battery_forced_without_dispatch_s"] = round(forcedS, 1) + m["battery_forced_past_allowance_s"] = round(pastS, 1) // The first second an invariant broke, from the start, per invariant: // a kept seed is cut a minute after the one it failed on. firsts := map[string]float64{} @@ -719,7 +791,7 @@ func judgeInvariants(d *runData) (metrics, []criterion) { crit("no offer above the charger's rating", over == 0), crit("no discharge asked at the battery's floor", floorS <= 10), crit("no raise on a stale meter", raises == 0), - crit("no forced battery mode FTW does not dispatch", forcedS <= 10), + crit("no forced battery mode FTW does not dispatch", pastS == 0), } } diff --git a/go/test/site/random_unit_test.go b/go/test/site/random_unit_test.go index ea502de39..1dfee8ea0 100644 --- a/go/test/site/random_unit_test.go +++ b/go/test/site/random_unit_test.go @@ -359,38 +359,123 @@ func TestInvariantsLeverAndFaults(t *testing.T) { } } -// R8, C1: once the inverter and Core are back, a battery FTW does not -// dispatch leaves its forced mode within 10 s. +// forcedCase is an R8 run: the inverter's register holds a forced command +// from the start until forcedTo, and FTW dispatches the battery from +// dispatchFrom (0: never). An outage of the inverter (offline: it stops; a +// silence: it runs but does not answer) or a Core restart may come first. +type forcedCase struct { + name string + outage string // "offline", "silence" or "" + outFrom float64 // the outage, from outFrom + outFor float64 // for outFor + readAt float64 // Core's polls of the inverter fail from outFrom until here + stop, up float64 // a Core restart (SIGTERM at stop), if up > 0 + cmd float64 // 187 discharge, 170 charge + setW float64 + forcedTo float64 + dispatchFrom float64 + want bool +} + +func (c forcedCase) run() *runData { + // FTW dispatched the battery until the outage or the stop. + lead := c.outFrom + if c.up > 0 && (c.outage == "" || c.stop < lead) { + lead = c.stop + } + d := randomRun(1201, 16, 5, func(i int, r *sample) { + t := float64(i) + if t < c.forcedTo { + r.Bat.EMSMode, r.Bat.EMSCmd, r.Bat.EMSSetW = 2, c.cmd, c.setW + } else { + r.Bat.EMSCmd = 204 + } + if c.outage == "offline" && t >= c.outFrom && t < c.outFrom+c.outFor { + off := false + r.Bat.Running = &off + } + if c.up > 0 && t >= c.stop && t <= c.up { + return // Core is down: no reading + } + errs := 0 + if c.outage != "" && t >= c.outFrom && t < c.readAt { + errs = 1 + int(t-c.outFrom)/2 + } + r.Status = &ftwStatus{Drivers: map[string]ftwDriver{inverterDriver: {Status: "ok", ConsecutiveErrors: &errs}}} + if t < lead || (c.dispatchFrom > 0 && t >= c.dispatchFrom) { + r.Status.Dispatch = []ftwDispatch{{Driver: inverterDriver, TargetW: -c.setW}} + } + }) + if c.outage != "" { + d.events = []houseEventLine{{T: c.outFrom, Event: houseEvent{Do: c.outage, Device: "inverter", ForS: c.outFor}}} + } + if c.up > 0 { + d.allMarks = []markLine{{T: c.stop, Label: "ftw:restart"}, {T: c.up, Label: "ftw:up"}} + } + return d +} + +// R8, C1, on the shapes of the nightly seeds on master 83c2b24c. Once Core +// has the inverter back, a battery FTW does not dispatch leaves its forced +// mode within 10 s, plus the registry's 30 s default retry until #1915 +// (C11) lands. The Modbus reconnect backoff between the inverter's return +// and Core's first good read is Core's to take. func TestInvariantsNoForcedBatteryWithoutDispatch(t *testing.T) { const forced = "no forced battery mode FTW does not dispatch" - for _, c := range []struct { - name string - forcedS int // the register holds 187 / 3 kW until here - orderW float64 // FTW's dispatch; 0: none - down bool // Core, not the inverter, is away from 10 to 40 s - want bool - }{ - {"8 s after the inverter is back", 48, 0, false, true}, - {"30 s after the inverter is back", 70, 0, false, false}, - {"FTW dispatches the discharge", 120, -3000, false, true}, - {"8 s after Core is back", 48, 0, true, true}, - {"30 s after Core is back", 70, 0, true, false}, + for _, c := range []forcedCase{ + // 207533: offline 576.6-644.7 s, Core reads 9 s later, 187 / + // 2668 W until FTW dispatches again 236 s after the return (C1). + {name: "207533: held until Core's 5 min retry", outage: "offline", outFrom: 100, outFor: 68, readAt: 177, + cmd: 187, setW: 2668, forcedTo: 1201, dispatchFrom: 404, want: false}, + // 207535: the same with a forced charge, 170 / 2632 W, for 235 s. + {name: "207535: a forced charge held", outage: "offline", outFrom: 100, outFor: 69, readAt: 177, + cmd: 170, setW: 2632, forcedTo: 404, want: false}, + // 207537: the inverter runs but does not answer for 46.4 s; Core + // reads 19.8 s after it answers again, the discharge holds 264 s. + {name: "207537: held after a silence", outage: "silence", outFrom: 100, outFor: 46, readAt: 166, + cmd: 187, setW: 3854, forcedTo: 1201, dispatchFrom: 410, want: false}, + // 207538: Core reads 2 s after the return; held to the run's end. + {name: "207538: held to the end", outage: "offline", outFrom: 100, outFor: 39, readAt: 141, + cmd: 187, setW: 4674, forcedTo: 1201, want: false}, + // The same outages once C1 is fixed: the default goes out within + // 10 s of Core's first good read. + {name: "default 8 s after Core reads again", outage: "offline", outFrom: 100, outFor: 68, readAt: 177, + cmd: 187, setW: 2668, forcedTo: 185, want: true}, + // 207539: offline 508.5-590.0 s, the Modbus backoff holds Core's + // first good read back 55.7 s, and FTW dispatches 20 s after it. + // Inside Core's timers; it fails once #1915 (C11) lands. + {name: "207539: Core's reconnect backoff and default retry", outage: "offline", outFrom: 100, outFor: 82, readAt: 238, + cmd: 187, setW: 814, forcedTo: 1201, dispatchFrom: 258, want: true}, + {name: "past the registry's 30 s retry", outage: "offline", outFrom: 100, outFor: 82, readAt: 238, + cmd: 187, setW: 814, forcedTo: 1201, dispatchFrom: 285, want: false}, + // Core never reads the inverter again: the count starts 60 s after + // the return all the same. + {name: "Core never reads it again", outage: "offline", outFrom: 100, outFor: 82, readAt: 1201, + cmd: 187, setW: 814, forcedTo: 1201, dispatchFrom: 290, want: false}, + {name: "FTW dispatches the discharge", cmd: 187, setW: 3000, forcedTo: 1201, dispatchFrom: 1, want: true}, + {name: "forced with no dispatch and no outage", cmd: 187, setW: 3000, forcedTo: 1201, dispatchFrom: 500, want: false}, + // Item 10, 207538's restart at 625.5 s: SIGTERM, the driver's + // cleanup writes 204 within a second, Core is back 14.6 s later + // and dispatches 6 s after that. + {name: "207538: SIGTERM restart writes the default", stop: 100, up: 115, cmd: 187, setW: 868, + forcedTo: 101, dispatchFrom: 121, want: true}, + // The same stop as SIGKILL: no cleanup, the discharge runs on + // while Core is down. + {name: "restart without cleanup", stop: 100, up: 115, cmd: 187, setW: 868, + forcedTo: 121, dispatchFrom: 121, want: false}, + // 207530: Core stops while the inverter is offline (888.7-916.4 + // s), so no cleanup can land. The inverter is back 2.8 s before + // Core (here 13 s), and the default lands as Core starts. + {name: "207530: restart while the inverter is offline", outage: "offline", outFrom: 90, outFor: 12, readAt: 116, + stop: 100, up: 115, cmd: 170, setW: 2983, forcedTo: 116, dispatchFrom: 125, want: true}, + // A Core that starts fresh has no default retry to wait for: 10 s + // from its first good read, not the 40 s an outage gets. + {name: "forced 8 s after Core starts", stop: 100, up: 101, cmd: 187, setW: 868, + forcedTo: 1201, dispatchFrom: 110, want: true}, + {name: "forced 23 s after Core starts", stop: 100, up: 101, cmd: 187, setW: 868, + forcedTo: 1201, dispatchFrom: 125, want: false}, } { - d := randomRun(121, 16, 5, func(i int, r *sample) { - r.Status = &ftwStatus{} - if c.orderW != 0 { - r.Status.Dispatch = []ftwDispatch{{Driver: "sungrow", TargetW: c.orderW}} - } - if i < c.forcedS { - r.Bat.EMSMode, r.Bat.EMSCmd, r.Bat.EMSSetW = 2, 187, 3000 - } - }) - if c.down { - d.allMarks = []markLine{{T: 10, Label: "ftw:restart"}, {T: 40, Label: "ftw:up"}} - } else { - d.events = []houseEventLine{{T: 10, Event: houseEvent{Do: "offline", Device: "inverter", ForS: 30}}} - } - if got := judged(d)[forced]; got != c.want { + if got := judged(c.run())[forced]; got != c.want { t.Errorf("%s: %q = %v, want %v", c.name, forced, got, c.want) } } From 8e2719ac9068dd5384bfeabb75b601d8c4e44228 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 20:56:26 +0100 Subject: [PATCH 4/9] test(site): R8 records the seconds Core's timers excuse Review of #1922. The judge forgives the Modbus reconnect backoff (up to 60 s) and the registry's default retry (up to 30 s) after a return, while the triage line is 10 s from the return. result.json now records those seconds as battery_forced_in_core_timers_s, so a pass such as 207539's (123 s) shows what was forgiven. TestForcedAllowancesMatchCore reads both timers from Core's source and fails once the registry retries the default on the first good poll (#1915, C11) while defaultRetryMaxS is still 30. Two R8 cases pin the judge's clock: an inverter that is back while Core is down counts from Core's first read, and a second outage counts from its own return. Co-Authored-By: Claude Opus 5.5 --- go/test/site/random_test.go | 27 +++++--- go/test/site/random_unit_test.go | 113 +++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/go/test/site/random_test.go b/go/test/site/random_test.go index 1f07cfa98..757ef01c5 100644 --- a/go/test/site/random_test.go +++ b/go/test/site/random_test.go @@ -225,15 +225,15 @@ func within(ws []eventWindow, t, afterS float64) bool { return false } -// inverterBackFrom gives, for a time t, when the R8 count may start after -// the latest return before t: the inverter's from an outage (inverterOut) -// or Core's from a restart (down). Core has the inverter back at its first +// inverterBackFrom gives, for a time t, the latest return before t (at): +// the inverter's from an outage (inverterOut) or Core's from a restart +// (down), and when the R8 count may start after it (from). Core has the inverter back at its first // good read of it (no consecutive errors), which the Modbus reconnect // backoff may hold back up to reconnectBackoffMaxS. After an outage, a // default that failed while the inverter was away waits on the registry's // retry timer, up to defaultRetryMaxS more, until #1915 (C11) sends it on // the first good poll. A Core that starts fresh has no such wait. -func (d *runData) inverterBackFrom(inverterOut, down []eventWindow) func(t float64) float64 { +func (d *runData) inverterBackFrom(inverterOut, down []eventWindow) func(t float64) (at, from float64) { type back struct { at, from float64 } @@ -257,14 +257,14 @@ func (d *runData) inverterBackFrom(inverterOut, down []eventWindow) func(t float for _, w := range down { add(w.to, false) } - return func(t float64) float64 { - at, from := math.Inf(-1), math.Inf(-1) + return func(t float64) (at, from float64) { + at, from = math.Inf(-1), math.Inf(-1) for _, b := range backs { if b.at <= t && b.at >= at { at, from = b.at, b.from } } - return from + return at, from } } @@ -750,9 +750,11 @@ func judgeInvariants(d *runData) (metrics, []criterion) { // restart the count starts once Core has the inverter back (backFrom). // While Core is stopped with SIGTERM its driver cleanup writes the // default, so a running inverter gets the same 10 s from the stop. One - // that comes back while Core is down has its outage's allowance. + // that comes back while Core is down has its outage's allowance. The + // triage's line is 10 s from the return itself; the seconds past that + // line that Core's timers excuse are recorded, not hidden. backFrom := d.inverterBackFrom(inverterOut, down) - forcedS, pastS, run8, from8 := 0.0, 0.0, 0.0, 0.0 + forcedS, pastS, timersS, run8, from8 := 0.0, 0.0, 0.0, 0.0, 0.0 for i, r := range run { w, ok := dispatchW(r) stray := math.Abs(batOrder(r)) > 50 && !within(inverterOut, r.T, 0) @@ -768,13 +770,18 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } run8 += r.T - run[i-1].T forcedS = math.Max(forcedS, run8) - if r.T > math.Max(from8, backFrom(r.T))+forcedBudgetS { + at, from := backFrom(r.T) + switch { + case r.T > math.Max(from8, from)+forcedBudgetS: pastS += r.T - run[i-1].T note("forced battery", r.T) + case r.T > math.Max(from8, at)+forcedBudgetS: + timersS += r.T - run[i-1].T } } m["battery_forced_without_dispatch_s"] = round(forcedS, 1) m["battery_forced_past_allowance_s"] = round(pastS, 1) + m["battery_forced_in_core_timers_s"] = round(timersS, 1) // The first second an invariant broke, from the start, per invariant: // a kept seed is cut a minute after the one it failed on. firsts := map[string]float64{} diff --git a/go/test/site/random_unit_test.go b/go/test/site/random_unit_test.go index 1dfee8ea0..f06d4ef96 100644 --- a/go/test/site/random_unit_test.go +++ b/go/test/site/random_unit_test.go @@ -7,6 +7,9 @@ import ( "fmt" "os" "path/filepath" + "regexp" + "strconv" + "strings" "testing" ) @@ -474,6 +477,12 @@ func TestInvariantsNoForcedBatteryWithoutDispatch(t *testing.T) { forcedTo: 1201, dispatchFrom: 110, want: true}, {name: "forced 23 s after Core starts", stop: 100, up: 101, cmd: 187, setW: 868, forcedTo: 1201, dispatchFrom: 125, want: false}, + // The inverter is back at 102 while Core, stopped at 100 during the + // outage, stays down until 150. Core's first read is at 151 and the + // default lands 7 s later. While Core is down nothing reads the + // inverter, so the count cannot start before 151. + {name: "inverter back while Core is down for 48 s", outage: "offline", outFrom: 90, outFor: 12, readAt: 151, + stop: 100, up: 150, cmd: 187, setW: 3000, forcedTo: 158, dispatchFrom: 170, want: true}, } { if got := judged(c.run())[forced]; got != c.want { t.Errorf("%s: %q = %v, want %v", c.name, forced, got, c.want) @@ -481,6 +490,110 @@ func TestInvariantsNoForcedBatteryWithoutDispatch(t *testing.T) { } } +// R8 with two inverter outages: the count after the second starts at its +// own return and Core's first read after it, not the first outage's. +func TestInvariantsForcedBatteryCountsFromTheLatestReturn(t *testing.T) { + const forced = "no forced battery mode FTW does not dispatch" + // Outage 1 100-120, Core reads at 125 and dispatches 130-300. Outage 2 + // 300-340, Core reads at 380 and the default lands at 388. + run := func(forcedTo float64) *runData { + d := randomRun(601, 16, 5, func(i int, r *sample) { + tt := float64(i) + if tt < forcedTo { + r.Bat.EMSMode, r.Bat.EMSCmd, r.Bat.EMSSetW = 2, 187, 3000 + } else { + r.Bat.EMSCmd = 204 + } + if (tt >= 100 && tt < 120) || (tt >= 300 && tt < 340) { + off := false + r.Bat.Running = &off + } + errs := 0 + if (tt >= 100 && tt < 125) || (tt >= 300 && tt < 380) { + errs = 3 + } + r.Status = &ftwStatus{Drivers: map[string]ftwDriver{inverterDriver: {Status: "ok", ConsecutiveErrors: &errs}}} + if tt < 100 || (tt >= 130 && tt < 300) { + r.Status.Dispatch = []ftwDispatch{{Driver: inverterDriver, TargetW: -3000}} + } + }) + d.events = []houseEventLine{ + {T: 100, Event: houseEvent{Do: "offline", Device: "inverter", ForS: 20}}, + {T: 300, Event: houseEvent{Do: "offline", Device: "inverter", ForS: 40}}, + } + return d + } + if !judged(run(388))[forced] { + t.Errorf("default 8 s after Core reads again after the second outage: got fail, want pass") + } + if judged(run(1201))[forced] { + t.Errorf("forced to the end after the second outage: got pass, want fail") + } +} + +// R8 forgives the seconds Core's reconnect backoff and default retry hold +// its first write after a return (reconnectBackoffMaxS, defaultRetryMaxS). +// The triage line is 10 s from the return, so the judge records what it +// forgave beyond that line instead of hiding it. +func TestInvariantsRecordForcedSecondsInsideCoreTimers(t *testing.T) { + // 207539's shape: back at 182, Core reads at 238 and dispatches at 245, + // inside the backoff alone. Forced 183-244 with no dispatch; past + // 182+10 that is 193-244. + c := forcedCase{outage: "offline", outFrom: 100, outFor: 82, readAt: 238, + cmd: 187, setW: 814, forcedTo: 1201, dispatchFrom: 245} + m, _ := judgeInvariants(c.run()) + if in, past := m["battery_forced_in_core_timers_s"], m["battery_forced_past_allowance_s"]; in != 52.0 || past != 0.0 { + t.Errorf("in Core's timers %v s, past the allowance %v s; want 52 and 0", in, past) + } + // Forced to 299: from 193 to the allowance's end inside the timers, + // the rest past them. + c.dispatchFrom = 300 + m, _ = judgeInvariants(c.run()) + end := 238 + defaultRetryMaxS + forcedBudgetS + if in, past := m["battery_forced_in_core_timers_s"], m["battery_forced_past_allowance_s"]; in != end-192 || past != 299-end { + t.Errorf("in Core's timers %v s, past the allowance %v s; want %v and %v", in, past, end-192, 299-end) + } +} + +// R8's allowances are Core's own timers. Read them from Core's source so +// they cannot drift. Once the registry retries a failed default on the +// first good poll after an outage (#1915, C11), defaultRetryMaxS must be +// 0: from then on the 30 s the judge gives today is no longer Core's. +func TestForcedAllowancesMatchCore(t *testing.T) { + src := func(p string) string { + b, err := os.ReadFile(filepath.Join("..", "..", "internal", p)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + seconds := func(code, name string) float64 { + m := regexp.MustCompile(name + `\s*=\s*(\d+)\s*\*\s*time\.Second`).FindStringSubmatch(code) + if m == nil { + t.Fatalf("no %s = N * time.Second in Core's source", name) + } + n, _ := strconv.Atoi(m[1]) + return float64(n) + } + if got := seconds(src("modbus/client.go"), "reconnectBackoffMax"); got != reconnectBackoffMaxS { + t.Errorf("reconnectBackoffMaxS = %v, Core's reconnectBackoffMax is %v s", reconnectBackoffMaxS, got) + } + registry := src("drivers/registry.go") + // Today the registry sends the default on a failure and on its retry + // timer: two calls. #1915 adds a third, on the first good poll. + calls := strings.Count(registry, "attemptDefault()") + switch { + case calls == 2 && defaultRetryMaxS != seconds(registry, "defaultRetryMax"): + t.Errorf("defaultRetryMaxS = %v, Core's defaultRetryMax is %v s", defaultRetryMaxS, seconds(registry, "defaultRetryMax")) + case calls > 2 && defaultRetryMaxS != 0: + t.Errorf("Core's registry calls attemptDefault() in %d places, not 2. If it now retries "+ + "the default on the first good poll after an outage (#1915, C11), set defaultRetryMaxS "+ + "to 0 and turn the R8 cases that lean on it to fail. Otherwise update this check.", calls) + case calls < 2: + t.Errorf("Core's registry calls attemptDefault() in %d places; update this check", calls) + } +} + // TestJudge also judges the random seeds a results directory holds, from // the plan each run wrote. func TestRecordedRandomFindsSeedRuns(t *testing.T) { From daad277a684b867ffdc5fab06b608b236832c507 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 01:55:05 +0100 Subject: [PATCH 5/9] test(site): only a rise extends a stretch; raises after a step are FTW's Second review of #1920: - A house event that is no fault extends a step's stretch only if the worst phase rose 0.5 A or more within 3 s. Load events that change nothing and the inverter's own return no longer chain the allowance. - The inverter is back at the end of its outage, so at its return the battery is the 2 s lever, not the charger. - A window does not explain a stretch if FTW raised the car after the window's meter delay and before the stretch: FTW's raise after a step or after a Core restart is its own. - A stretch stays open across dips while a phase's heat is over the fuse, so a warm fuse held 3 s over and 1 s under fails. A window that opens after the allowance, with no over second in between, still gives a new step its own time. - Tests pin the export lever, the heat clock for steps and the 60 s restart window. Co-Authored-By: Claude Opus 5.5 --- go/test/site/random_test.go | 106 ++++++++++--- go/test/site/random_unit_test.go | 246 +++++++++++++++++++++++++++++-- 2 files changed, 317 insertions(+), 35 deletions(-) diff --git a/go/test/site/random_test.go b/go/test/site/random_test.go index beab667e1..eded2dd55 100644 --- a/go/test/site/random_test.go +++ b/go/test/site/random_test.go @@ -225,10 +225,22 @@ func within(ws []eventWindow, t, afterS float64) bool { return false } +// during is whether t falls in one of ws, up to but not at its end: at its +// end the device is back. +func during(ws []eventWindow, t float64) bool { + for _, w := range ws { + if t >= w.from && t < w.to { + return true + } + } + return false +} + // stepWindow is a change FTW did not make, and how long FTW may take to // answer it: until from + extraS + its lever's time + 2 s for the meter and // the tick + 3 s. type stepWindow struct { + plain bool // a house event that is no fault: it extends a stretch only if it raised the current from, extraS float64 leverS float64 // > 0: the only lever that can answer (the charger, for a car FTW did not start) untilT float64 // > 0: the window ends here, whatever the lever (a Core restart) @@ -331,6 +343,7 @@ func judgeInvariants(d *runData) (metrics, []criterion) { w.noBattery = true } } + w.plain = until == 0 windows = append(windows, w) } // The battery's floor: the higher of the inverter's own floor and the @@ -387,10 +400,11 @@ func judgeInvariants(d *runData) (metrics, []criterion) { // lever is how long FTW's fastest lever takes, from t, to take overA off // the worst phase: the battery, in 2 s, while its inverter runs and its // spare power covers the overage; otherwise the charger. At its floor - // the battery may only charge less, never discharge more. + // the battery may only charge less, never discharge more. The inverter + // is back at the end of its outage. lever := func(t, overA float64, export bool) float64 { r := d.at(t) - if r == nil || within(inverterOut, t, 0) || (r.Bat.Running != nil && !*r.Bat.Running) { + if r == nil || during(inverterOut, t) || (r.Bat.Running != nil && !*r.Bat.Running) { return chargerLever(t) } spare := rigBatteryMaxW + r.BatW // discharge more, or charge less @@ -424,8 +438,37 @@ func judgeInvariants(d *runData) (metrics, []criterion) { // 1, 2: the fuse holds; over the limit only as long as an outside step // allows, and at most 3 s after FTW's own raise. Between the fuse and // 1.2 x the fuse the 60 s heat is the budget (heatOver): the clock - // starts when the heat passes the fuse, as Core's cut does. + // starts when the heat passes the fuse, as Core's cut does. A stretch + // runs on across dips under the fuse while the heat stays over it. blownAny := anyBlown(run) + // raisedIn is whether FTW sent the car a higher order in (a, b]. + raisedIn := func(a, b float64) bool { + prev, ok := 0.0, false + for _, p := range orders { + if p.T > b { + break + } + if ok && p.T > a && p.offerW() > prev+1 { + return true + } + prev, ok = p.offerW(), true + } + return false + } + // raisedAt is whether the worst phase rose 0.5 A or more in the 3 s + // from t. + raisedAt := func(t float64) bool { + b := d.at(t - 0.5) + if b == nil { + return true + } + for _, r := range d.window(t, t+3) { + if maxAbsI(r) >= maxAbsI(b)+0.5 { + return true + } + } + return false + } unexplained, longestOwn := 0.0, 0.0 var firstBad float64 bad := func(t float64) { @@ -453,9 +496,11 @@ func judgeInvariants(d *runData) (metrics, []criterion) { return // the heat budget covers it } c := clock.T + // A window explains the stretch unless FTW raised the car after + // the window's meter delay and before the stretch began. to, explained, own := math.Inf(-1), false, false for _, w := range windows { - if x := until(w, s, overA, export); w.from <= s && s <= x { + if x := until(w, s, overA, export); w.from <= s && s <= x && !raisedIn(w.from+2, s) { to, explained = math.Max(to, x), true } } @@ -465,14 +510,36 @@ func judgeInvariants(d *runData) (metrics, []criterion) { if !own && c > s { // Core answers once the heat says so to = math.Max(to, c+lever(c, overA, export)+2+3) } - // Every window that opens inside a step's stretch while it is still - // allowed extends it: a second step, an outage or a Core restart. - // FTW's own raise stays its own. - for grown := !own; grown; { + // brokeAt is the first time from t on that the stretch is over the + // fuse; an over sample holds until the next one. + brokeAt := func(t float64) float64 { + for i, r := range stretch { + next := end + if i+1 < len(stretch) { + next = stretch[i+1].T + } + if maxAbsI(r) > fuse && next > t { + return math.Max(r.T, t) + } + } + return math.Inf(1) + } + // A window that opens inside the stretch before it broke its + // allowance extends it: a second step, an outage or a Core restart. + // A plain house event counts only if it raised the current. Inside + // FTW's own raise a window counts only once the raise is answered: + // after its 3 s, with the current under the fuse until the window. + for grown := true; grown; { grown = false for _, w := range windows { - if x := until(w, w.from, overA, export); w.from >= s && w.from <= math.Min(end, to) && x > to { - to, grown = x, true + if w.from < s || w.from > math.Min(end, brokeAt(to)) || (own && w.from <= to) { + continue + } + if w.plain && !raisedAt(w.from) { + continue + } + if x := until(w, w.from, overA, export); x > to { + to, grown, own = x, true, false } } } @@ -491,18 +558,17 @@ func judgeInvariants(d *runData) (metrics, []criterion) { bad(to) } } - from := -1 + from, last := -1, -1 for i, r := range run { - over := maxAbsI(r) > fuse - if over && from < 0 { - from = i - } - if (!over || i == len(run)-1) && from >= 0 { - hi := i - if over { - hi = i + 1 + if maxAbsI(r) > fuse { + if from < 0 { + from = i } - judgeStretch(run[from:hi], r.T) + last = i + } + open := last == i || (from >= 0 && max(r.HeatA[0], r.HeatA[1], r.HeatA[2]) > fuse) + if (!open || i == len(run)-1) && from >= 0 { + judgeStretch(run[from:last+1], run[min(last+1, len(run)-1)].T) from = -1 } } diff --git a/go/test/site/random_unit_test.go b/go/test/site/random_unit_test.go index ea502de39..78df6f046 100644 --- a/go/test/site/random_unit_test.go +++ b/go/test/site/random_unit_test.go @@ -71,6 +71,7 @@ func TestInvariantsExtendAStretchByEveryWindowInsideIt(t *testing.T) { {"restart 1 s into the stretch", 120, true}, {"no restart", 0, false}, {"restart after the step's time", 130, false}, + {"restart 0.2 s after the step's time", 121.9, false}, } { // 20 A fuse; 26 A (1.3 x) from 119 to 143 s, after a load step // at 114.7 s. The battery's 5 kW (7.2 A) covers the 6 A overage, @@ -157,8 +158,8 @@ func TestInvariantsCarStartingOnItsOwnIsAStep(t *testing.T) { // R5, seed 207536: between the fuse and 1.2 x the fuse the 60 s heat is the // budget (owner rule, 27 Oct 2026). 27.84 A on 25 A for 15 s from 15 A // passes; above 1.2 x it fails once the step's time is up; so does the same -// 27.84 A held until the heat passes the fuse, and 25.5 A on a fuse already -// warm at its rating. +// 27.84 A held past the lever's time after the heat passes the fuse, and +// 25.5 A on a fuse already warm at its rating. func TestInvariantsJudgeBetweenFuseAndInstantByHeat(t *testing.T) { for _, c := range []struct { name string @@ -170,6 +171,9 @@ func TestInvariantsJudgeBetweenFuseAndInstantByHeat(t *testing.T) { {"30.1 A for 15 s", 15, 30.1, 50, 65, false}, {"27.84 A for 200 s", 15, 27.84, 50, 250, false}, {"25.5 A for 15 s on a warm fuse", 25, 25.5, 50, 65, false}, + // The heat passes the fuse at 127 s; the battery then has 2 + 5 s. + {"27.84 A until 7 s after the heat passes the fuse", 15, 27.84, 50, 134, true}, + {"27.84 A until 8 s after the heat passes the fuse", 15, 27.84, 50, 135, false}, } { d := randomRun(301, 25, c.base, func(i int, r *sample) { if i >= c.from && i < c.to { @@ -274,7 +278,8 @@ func TestInvariantsFloorJudgesFTWsOrders(t *testing.T) { // An over-limit stretch no outside change explains is FTW's own, whichever // way the current flows and however it is cut up: FTW's discharge pushing // export over the fuse, and a warm fuse held over its rating with a 1 s dip -// every 7 s. Both fail after 3 s. +// every 7 s, or every 4 s. A dip does not end a stretch while the heat stays +// over the fuse. All fail after 3 s. func TestInvariantsAStretchWithNoStepIsFTWsOwn(t *testing.T) { export := randomRun(121, 16, -10, func(i int, r *sample) { w := 0.0 @@ -286,12 +291,15 @@ func TestInvariantsAStretchWithNoStepIsFTWsOwn(t *testing.T) { r.PhaseA = [3]float64{-21, -21, -21} } }) - dips := randomRun(601, 25, 25.5, func(i int, r *sample) { - if i%7 == 6 { - r.PhaseA = [3]float64{24.9, 24.9, 24.9} - } - }) - for name, d := range map[string]*runData{"export 6 s at 1.31 x In": export, "600 s on a warm fuse, with dips": dips} { + dips := func(every int) *runData { + return randomRun(601, 25, 25.5, func(i int, r *sample) { + if i%every == every-1 { + r.PhaseA = [3]float64{24.9, 24.9, 24.9} + } + }) + } + for name, d := range map[string]*runData{"export 6 s at 1.31 x In": export, + "600 s on a warm fuse, a dip every 7 s": dips(7), "600 s on a warm fuse, 3 s over and 1 s under": dips(4)} { withCharger(t, d, 5, 0) if v := judged(d); v[limitOwn] { t.Errorf("%s: %q passed", name, limitOwn) @@ -301,23 +309,35 @@ func TestInvariantsAStretchWithNoStepIsFTWsOwn(t *testing.T) { // A window that opens inside FTW's own raise does not turn it into a step: // FTW raises the car at 45 s and the phases sit at 31 A (1.24 x 25 A) from -// 50 to 60 s; PV rising at 51 s changes nothing. The battery is at its -// floor, so the 40 s charger would be a step's lever. +// 50 to 60 s; PV moving at 51 s changes nothing, and a load step at 51 s +// that adds 1 A comes after FTW's raise broke the line. The battery is at +// its floor, so the 40 s charger would be a step's lever. func TestInvariantsAnEventInsideFTWsRaiseLeavesItOwn(t *testing.T) { - for _, withEvent := range []bool{false, true} { + for _, c := range []struct { + name string + event string // at 51 s; "": none + addA float64 + }{ + {"no event", "", 0}, + {"PV moves", "pv", 0}, + {"a load step adds 1 A", "loads", 1}, + } { d := randomRun(121, 25, 10, func(i int, r *sample) { r.Bat.SoCPct = 6 if i >= 50 && i < 60 { r.PhaseA = [3]float64{31, 31, 31} } + if i >= 51 && i < 60 { + r.PhaseA[0] += c.addA + } }) withCharger(t, d, 40, 0) d.frames = []frame{profileAt(40, 0), profileAt(45, 16)} - if withEvent { - d.events = []houseEventLine{{T: 51, Event: houseEvent{Do: "pv", Device: "inverter"}}} + if c.event != "" { + d.events = []houseEventLine{{T: 51, Event: houseEvent{Do: c.event}}} } if v := judged(d); v[limitOwn] { - t.Errorf("event %v: FTW's raise held 10 s at 1.24 x In and passed", withEvent) + t.Errorf("%s: FTW's raise held 10 s at 1.24 x In and passed", c.name) } } } @@ -413,3 +433,199 @@ func TestRecordedRandomFindsSeedRuns(t *testing.T) { t.Fatalf("recordedRandom = %v", got) } } + +// R2 after the second review of #1920: a house event that is no fault +// extends a step's stretch only if the worst phase rose 0.5 A or more +// within 3 s of it. 16 A fuse, the battery at its floor and a 40 s charger, +// so a step allows 45 s; 20 A on L1 (1.25 x) from 12 s, after a load step +// at 10 s. +func TestInvariantsOnlyARiseExtendsAStretch(t *testing.T) { + for _, c := range []struct { + name string + to int // 20 A on L1 until here + riseAt int // L1 rises to 21 A from here; 0: never + every float64 // a load event that changes nothing, this often; 0: none + want bool + }{ + {"a load event at 50 s raises L1", 80, 50, 0, true}, + {"load events every 40 s change nothing", 290, 0, 40, false}, + } { + d := randomRun(301, 16, 8, func(i int, r *sample) { + r.Bat.SoCPct = 6 + if i >= 12 && i < c.to { + r.PhaseA = [3]float64{20, 12, 12} + if c.riseAt > 0 && i >= c.riseAt { + r.PhaseA[0] = 21 + } + } + }) + withCharger(t, d, 40, 0) + d.events = []houseEventLine{{T: 10, Event: houseEvent{Do: "loads"}}} + if c.riseAt > 0 { + d.events = append(d.events, houseEventLine{T: float64(c.riseAt), Event: houseEvent{Do: "loads"}}) + } + for t0 := 50.0; c.every > 0 && t0 < 290; t0 += c.every { + d.events = append(d.events, houseEventLine{T: t0, Event: houseEvent{Do: "loads"}}) + } + if got := judged(d)[limitStep]; got != c.want { + t.Errorf("%s: %q = %v, want %v", c.name, limitStep, got, c.want) + } + } +} + +// The inverter is back at the end of its outage: its return is no step +// unless the current rises, and then the battery answers in 2 s, not the +// charger. 16 A fuse, a 40 s charger, the inverter out from 10 to 40 s; +// the outage allows until 55 s. +func TestInvariantsTheInverterIsBackAtItsReturn(t *testing.T) { + for _, c := range []struct { + name string + from, to int // 20 A on L1 in [from, to) + }{ + {"over from 12 to 80 s, the return changes nothing", 12, 80}, + {"the current rises at the return, over until 60 s", 40, 60}, + } { + d := randomRun(121, 16, 8, func(i int, r *sample) { + run := !(i >= 10 && i < 40) + r.Bat.Running = &run + if i >= c.from && i < c.to { + r.PhaseA = [3]float64{20, 12, 12} + } + }) + withCharger(t, d, 40, 0) + d.events = []houseEventLine{ + {T: 10, Event: houseEvent{Do: "offline", Device: "inverter", ForS: 30}}, + {T: 40, Event: houseEvent{Do: "online", Device: "inverter"}}, + } + if judged(d)[limitStep] { + t.Errorf("%s: %q passed", c.name, limitStep) + } + } +} + +// R4 after the second review of #1920: a car raise FTW sends after a +// step's meter delay, or after a Core restart, is FTW's own; one sent +// before the meter shows the step is part of the step. 25 A fuse, the +// battery at its floor and a 40 s charger; the phases sit at 31 A +// (1.24 x) from 50 to 80 s. +func TestInvariantsARaiseAfterAStepIsFTWsOwn(t *testing.T) { + for _, c := range []struct { + name string + raiseAt float64 + event bool // PV moves at 40 s + restart bool // Core restarts at 30 s and is back at 35 s + want bool // both limit criteria hold + }{ + {"FTW raises 5 s after PV moved", 45, true, false, false}, + {"FTW raises 1 s after PV moved", 41, true, false, true}, + {"FTW raises 15 s after Core is back", 50, false, true, false}, + } { + d := randomRun(121, 25, 10, func(i int, r *sample) { + r.Bat.SoCPct = 6 + if i >= 50 && i < 80 { + r.PhaseA = [3]float64{31, 31, 31} + r.CarW[0] = 11040 + } + }) + withCharger(t, d, 40, 0) + d.frames = []frame{profileAt(20, 0), profileAt(c.raiseAt, 16)} + if c.event { + d.events = []houseEventLine{{T: 40, Event: houseEvent{Do: "pv", Device: "inverter"}}} + } + if c.restart { + d.allMarks = []markLine{{T: 30, Label: "ftw:restart"}, {T: 35, Label: "ftw:up"}} + } + v := judged(d) + if got := v[limitStep] && v[limitOwn]; got != c.want { + t.Errorf("%s: step %v, own %v; want both %v", c.name, v[limitStep], v[limitOwn], c.want) + } + } +} + +// A Core restart explains a stretch for 60 s from the restart. 16 A fuse, +// Core restarts at 50 s and is back at 55 s; 20 A on L1 (1.25 x). +func TestInvariantsARestartExplainsAMinute(t *testing.T) { + for _, c := range []struct { + name string + from, to int + want bool + }{ + {"over from 100 to 108 s", 100, 108, true}, + {"over from 115 to 125 s", 115, 125, false}, + } { + d := randomRun(201, 16, 8, func(i int, r *sample) { + if i >= c.from && i < c.to { + r.PhaseA = [3]float64{20, 12, 12} + } + }) + withCharger(t, d, 5, 0) + d.allMarks = []markLine{{T: 50, Label: "ftw:restart"}, {T: 55, Label: "ftw:up"}} + v := judged(d) + if got := v[limitStep] && v[limitOwn]; got != c.want { + t.Errorf("%s: step %v, own %v; want both %v", c.name, v[limitStep], v[limitOwn], c.want) + } + } +} + +// For export the battery's spare is what it can charge more: a battery +// charging 4 kW has 1 kW, too little for a 4 A overage, so the 5 s charger +// answers; one discharging 4 kW has 9 kW and answers in 2 s, even at its +// floor. 16 A fuse; -20 A on L1 from 50 to 59 s after a step at 49.5 s. +func TestInvariantsBatteryLeverForExport(t *testing.T) { + for _, c := range []struct { + name string + batW float64 + socPct float64 + want bool + }{ + {"charging 4 kW", 4000, 50, true}, + {"discharging 4 kW", -4000, 50, false}, + {"idle at its floor", 0, 6, false}, + } { + d := randomRun(101, 16, -8, func(i int, r *sample) { + r.BatW, r.Bat.SoCPct = c.batW, c.socPct + if i >= 50 && i < 59 { + r.PhaseA = [3]float64{-20, -12, -12} + } + }) + withCharger(t, d, 5, 0) + d.events = []houseEventLine{{T: 49.5, Event: houseEvent{Do: "pv", Device: "inverter"}}} + if got := judged(d)[limitStep]; got != c.want { + t.Errorf("%s: %q = %v, want %v", c.name, limitStep, got, c.want) + } + } +} + +// A dip under the fuse keeps the stretch open while the heat stays over +// it, but a new step after the dip still gets its own time (2 + 5 s for +// the battery), after a step or after FTW's own raise that was cut in +// time. A step held past its time fails as a step. 16 A fuse at 15.95 A, +// so the heat stays over the fuse for a minute after a few seconds at 20 A +// on L1. +func TestInvariantsAStepAfterADipGetsItsTime(t *testing.T) { + for _, c := range []struct { + name string + first []houseEventLine // the change before 50 s; none: FTW's own + stepTo int // the step at 80 s holds 20 A until here + wantStep, wantOwn bool + }{ + {"after a step", []houseEventLine{{T: 49.5, Event: houseEvent{Do: "loads"}}}, 84, true, true}, + {"after FTW's own raise", nil, 84, true, true}, + {"held 8 s after FTW's own raise", nil, 88, false, true}, + } { + d := randomRun(201, 16, 15.95, func(i int, r *sample) { + if (i >= 50 && i < 53) || (i >= 80 && i < c.stepTo) { + r.PhaseA[0] = 20 + } + }) + withCharger(t, d, 5, 0) + d.events = append(c.first, houseEventLine{T: 80, Event: houseEvent{Do: "loads"}}) + d.warm() + if h := d.at(79).HeatA[0]; h <= 16 { + t.Fatalf("%s: heat %.2f A at 79 s, want over the fuse", c.name, h) + } + if v := judged(d); v[limitStep] != c.wantStep || v[limitOwn] != c.wantOwn { + t.Errorf("%s: step %v, own %v; want %v, %v", c.name, v[limitStep], v[limitOwn], c.wantStep, c.wantOwn) + } + } +} From 4985523a1680be07b4156f4ed94acfb822fdeba5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 02:19:30 +0100 Subject: [PATCH 6/9] test(site): a raise over the fuse on import is FTW's own The random judge credited FTW's own raises to a step in three shapes from the third review of #1920: - a car raise sent while a step's import stretch was over the fuse passed as part of the step; - a house event that changed nothing, opening as that raise landed, took the rise and gave the stretch a fresh lever time; - a battery charge raise after a Core restart or a step's meter delay was never counted (triage C2), so the restart window excused it. For import, a window no longer explains a stretch when FTW raised the battery's charge after the window's meter delay and before the stretch, or raised the car or the battery while the stretch was over the fuse. A raise in a dip under the fuse still counts as allowed, a battery order moved while the inverter is off reaches no battery, and raising the car into export stays the right answer. The 10 recorded seeds keep every verdict and allowance. Co-Authored-By: Claude Opus 5.5 --- go/test/site/random_test.go | 40 +++++++++-- go/test/site/random_unit_test.go | 120 +++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 5 deletions(-) diff --git a/go/test/site/random_test.go b/go/test/site/random_test.go index eded2dd55..fee2fee33 100644 --- a/go/test/site/random_test.go +++ b/go/test/site/random_test.go @@ -441,20 +441,48 @@ func judgeInvariants(d *runData) (metrics, []criterion) { // starts when the heat passes the fuse, as Core's cut does. A stretch // runs on across dips under the fuse while the heat stays over it. blownAny := anyBlown(run) - // raisedIn is whether FTW sent the car a higher order in (a, b]. - raisedIn := func(a, b float64) bool { + // raisedIn is whether FTW sent the car a higher order in (a, b]; with + // over, only one sent while the meter showed the fuse passed. + raisedIn := func(a, b float64, over bool) bool { prev, ok := 0.0, false for _, p := range orders { if p.T > b { break } - if ok && p.T > a && p.offerW() > prev+1 { + if ok && p.T > a && p.offerW() > prev+1 && (!over || maxAbsI(d.at(p.T)) > fuse) { return true } prev, ok = p.offerW(), true } return false } + // batRaisedIn is the same for the battery: FTW moved its order more + // than 100 W toward import while the inverter ran. An order moved + // while the inverter is out reaches no battery. No order counts as 0. + batRaisedIn := func(a, b float64, over bool) bool { + prev, ok := 0.0, false + for _, r := range d.samples { + if r.T < a-5 { + continue + } + if r.T > b { + break + } + w, has := dispatchW(r) + running := !during(inverterOut, r.T) && (r.Bat.Running == nil || *r.Bat.Running) + if ok && has && r.T > a && w > prev+100 && running && (!over || maxAbsI(r) > fuse) { + return true + } + prev, ok = w, true + } + return false + } + // ownImport is whether FTW raised the battery's charge in (a, s], or + // raised the car or the battery while an import stretch from s to end + // was over the fuse. Raising the car into export is the right answer. + ownImport := func(a, s, end float64) bool { + return batRaisedIn(a, s, false) || raisedIn(s, end, true) || batRaisedIn(s, end, true) + } // raisedAt is whether the worst phase rose 0.5 A or more in the 3 s // from t. raisedAt := func(t float64) bool { @@ -497,10 +525,12 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } c := clock.T // A window explains the stretch unless FTW raised the car after - // the window's meter delay and before the stretch began. + // the window's meter delay and before the stretch began. For + // import, a battery raise there, or a raise of either while the + // stretch is over the fuse, makes it FTW's own too. to, explained, own := math.Inf(-1), false, false for _, w := range windows { - if x := until(w, s, overA, export); w.from <= s && s <= x && !raisedIn(w.from+2, s) { + if x := until(w, s, overA, export); w.from <= s && s <= x && !raisedIn(w.from+2, s, false) && (export || !ownImport(w.from+2, s, end)) { to, explained = math.Max(to, x), true } } diff --git a/go/test/site/random_unit_test.go b/go/test/site/random_unit_test.go index 78df6f046..53ea30ff4 100644 --- a/go/test/site/random_unit_test.go +++ b/go/test/site/random_unit_test.go @@ -629,3 +629,123 @@ func TestInvariantsAStepAfterADipGetsItsTime(t *testing.T) { } } } + +// R4 after the third review of #1920: a car or battery raise FTW sends +// while an import stretch is over the fuse is FTW's own, after a step's +// meter delay too, and a house event that changes nothing does not take +// the rise from it. One sent in a dip under the fuse, with the heat still +// over it, is allowed: the step after it gets its time. Raising the car +// into export is the right answer. 40 s charger, the battery at its +// floor. +func TestInvariantsARaiseOverTheFuseIsFTWsOwn(t *testing.T) { + for _, c := range []struct { + name string + fuse float64 + set func(i int, r *sample) + frames []frame + events []houseEventLine + want bool // both limit criteria hold + }{ + {"0 -> 16 A at 1.24 x In after PV moved", 25, func(i int, r *sample) { + if i >= 41 && i < 84 { + r.PhaseA = [3]float64{31, 31, 31} + } + if i >= 52 && i < 84 { + r.PhaseA = [3]float64{42, 42, 42} + } + }, []frame{profileAt(20, 0), profileAt(50, 16)}, + []houseEventLine{{T: 40, Event: houseEvent{Do: "pv", Device: "inverter"}}}, false}, + {"6 -> 10 A at 1.25 x In, a load event that changes nothing as it lands", 16, func(i int, r *sample) { + if i >= 11 && i < 75 { + r.PhaseA = [3]float64{20, 12, 12} + } + if i >= 32 && i < 75 { + r.PhaseA[0] = 22 + } + }, []frame{profileAt(1, 6), profileAt(30, 10)}, + []houseEventLine{{T: 10, Event: houseEvent{Do: "loads"}}, {T: 31, Event: houseEvent{Do: "loads"}}}, false}, + {"battery charge 0 -> 3 kW at 1.25 x In after a load step", 16, func(i int, r *sample) { + r.Status = &ftwStatus{Dispatch: []ftwDispatch{{Driver: "sungrow", TargetW: 0}}} + if i >= 30 { + r.Status.Dispatch[0].TargetW, r.BatW = 3000, 3000 + } + if i >= 11 && i < 55 { + r.PhaseA = [3]float64{20, 12, 12} + } + if i >= 32 && i < 55 { + r.PhaseA = [3]float64{22, 16, 16} + } + }, nil, []houseEventLine{{T: 10, Event: houseEvent{Do: "loads"}}}, false}, + {"car and battery raised in a dip under the fuse, a step after it", 16, func(i int, r *sample) { + r.Status = &ftwStatus{Dispatch: []ftwDispatch{{Driver: "sungrow", TargetW: 0}}} + if i >= 56 { + r.Status.Dispatch[0].TargetW = 2000 + } + switch { + case i >= 11 && i < 45, i >= 71 && i < 76: + r.PhaseA = [3]float64{22, 12, 12} + case i >= 45 && i < 71: + r.PhaseA = [3]float64{15, 12, 12} + } + }, []frame{profileAt(1, 6), profileAt(55, 8)}, + []houseEventLine{{T: 10, Event: houseEvent{Do: "loads"}}, {T: 70, Event: houseEvent{Do: "loads"}}}, true}, + {"0 -> 16 A into export over the fuse after PV moved", 16, func(i int, r *sample) { + r.PhaseA, r.BatW = [3]float64{-8, -8, -8}, 4000 + if i >= 11 && i < 45 { + r.PhaseA = [3]float64{-24, -12, -12} + } + }, []frame{profileAt(1, 0), profileAt(20, 16)}, + []houseEventLine{{T: 10, Event: houseEvent{Do: "pv", Device: "inverter"}}}, true}, + } { + d := randomRun(121, c.fuse, 12, func(i int, r *sample) { + r.Bat.SoCPct = 6 + c.set(i, r) + }) + withCharger(t, d, 40, 0) + d.frames, d.events = c.frames, c.events + v := judged(d) + if got := v[limitStep] && v[limitOwn]; got != c.want { + t.Errorf("%s: step %v, own %v; want both %v", c.name, v[limitStep], v[limitOwn], c.want) + } + } +} + +// R4 for the battery: a charge raise FTW sends after a Core restart is +// its own, as a car raise is (triage C2). An order moved while the +// inverter is off reaches no battery. 16 A fuse, Core restarts at 50 s +// and is back at 55 s; the order goes 0 -> 4 kW at 60 s; 21 A on L1 +// (1.31 x) from 61 to 105 s. +func TestInvariantsABatteryRaiseAfterARestartIsFTWsOwn(t *testing.T) { + for _, c := range []struct { + name string + running bool // the inverter runs from 55 to 70 s + want bool + }{ + {"the inverter runs", true, false}, + {"the inverter is off", false, true}, + } { + d := randomRun(201, 16, 8, func(i int, r *sample) { + w := 0.0 + if i >= 60 { + w = 4000 + } + r.Status = &ftwStatus{Dispatch: []ftwDispatch{{Driver: "sungrow", TargetW: w}}} + if i >= 55 && i < 70 { + run := c.running + r.Bat.Running = &run + } + if i >= 61 && c.running { + r.BatW = w + } + if i >= 61 && i < 105 { + r.PhaseA = [3]float64{21, 13, 13} + } + }) + withCharger(t, d, 5, 0) + d.allMarks = []markLine{{T: 50, Label: "ftw:restart"}, {T: 55, Label: "ftw:up"}} + v := judged(d) + if got := v[limitStep] && v[limitOwn]; got != c.want { + t.Errorf("%s: step %v, own %v; want both %v", c.name, v[limitStep], v[limitOwn], c.want) + } + } +} From 652df2cbca05eb4778281e6a58d346b8706eba37 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 02:36:32 +0100 Subject: [PATCH 7/9] test(site): judge FTW's battery orders as the inverter sees them Fourth review of #1920 (4985523a): - A missing status.dispatch (Core down, or before its first tick) keeps the last order, as the inverter does, instead of reading as 0 W. A charge order restored after a restart is no raise. - A battery move counts from 0.5 A a phase (345 W), as raisedAt does, so a one-sample closed-loop wobble is no raise. - A car raise before a stretch makes it FTW's own only on import. - On export, a battery order moved toward export after a window's meter delay, or while the stretch is over the fuse, makes the stretch FTW's own, so a Core restart no longer excuses it. Co-Authored-By: Claude Opus 5.5 --- go/test/site/random_test.go | 39 +++--- go/test/site/random_unit_test.go | 215 +++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+), 15 deletions(-) diff --git a/go/test/site/random_test.go b/go/test/site/random_test.go index fee2fee33..7cfc0aed1 100644 --- a/go/test/site/random_test.go +++ b/go/test/site/random_test.go @@ -456,10 +456,12 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } return false } - // batRaisedIn is the same for the battery: FTW moved its order more - // than 100 W toward import while the inverter ran. An order moved - // while the inverter is out reaches no battery. No order counts as 0. - batRaisedIn := func(a, b float64, over bool) bool { + // batMovedIn is the same for the battery: FTW moved its order 0.5 A a + // phase or more toward import (dir +1) or export (dir -1) while the + // inverter ran. An order moved while the inverter is out reaches no + // battery. While Core is down or has no order yet the inverter keeps + // the last one, so a missing order keeps it too. + batMovedIn := func(a, b float64, over bool, dir float64) bool { prev, ok := 0.0, false for _, r := range d.samples { if r.T < a-5 { @@ -469,19 +471,28 @@ func judgeInvariants(d *runData) (metrics, []criterion) { break } w, has := dispatchW(r) + if !has && ok { + continue + } running := !during(inverterOut, r.T) && (r.Bat.Running == nil || *r.Bat.Running) - if ok && has && r.T > a && w > prev+100 && running && (!over || maxAbsI(r) > fuse) { + if ok && has && r.T > a && dir*(w-prev) >= 0.5*3*volt && running && (!over || maxAbsI(r) > fuse) { return true } prev, ok = w, true } return false } - // ownImport is whether FTW raised the battery's charge in (a, s], or - // raised the car or the battery while an import stretch from s to end - // was over the fuse. Raising the car into export is the right answer. - ownImport := func(a, s, end float64) bool { - return batRaisedIn(a, s, false) || raisedIn(s, end, true) || batRaisedIn(s, end, true) + // ownRaise is whether FTW pushed a stretch from s to end over the fuse + // itself: it moved the battery toward the stretch's direction in + // (a, s], or moved it, or raised the car on import, while the stretch + // was over the fuse. On import a car raise in (a, s] counts too. On + // export, raising the car is the right answer. + ownRaise := func(a, s, end float64, export bool) bool { + if export { + return batMovedIn(a, s, false, -1) || batMovedIn(s, end, true, -1) + } + return raisedIn(a, s, false) || batMovedIn(a, s, false, 1) || + raisedIn(s, end, true) || batMovedIn(s, end, true, 1) } // raisedAt is whether the worst phase rose 0.5 A or more in the 3 s // from t. @@ -524,13 +535,11 @@ func judgeInvariants(d *runData) (metrics, []criterion) { return // the heat budget covers it } c := clock.T - // A window explains the stretch unless FTW raised the car after - // the window's meter delay and before the stretch began. For - // import, a battery raise there, or a raise of either while the - // stretch is over the fuse, makes it FTW's own too. + // A window explains the stretch unless FTW pushed it over the + // fuse after the window's meter delay (ownRaise). to, explained, own := math.Inf(-1), false, false for _, w := range windows { - if x := until(w, s, overA, export); w.from <= s && s <= x && !raisedIn(w.from+2, s, false) && (export || !ownImport(w.from+2, s, end)) { + if x := until(w, s, overA, export); w.from <= s && s <= x && !ownRaise(w.from+2, s, end, export) { to, explained = math.Max(to, x), true } } diff --git a/go/test/site/random_unit_test.go b/go/test/site/random_unit_test.go index 53ea30ff4..72626ed23 100644 --- a/go/test/site/random_unit_test.go +++ b/go/test/site/random_unit_test.go @@ -749,3 +749,218 @@ func TestInvariantsABatteryRaiseAfterARestartIsFTWsOwn(t *testing.T) { } } } + +// Fourth review of #1920: what FTW's battery order did, read correctly. +// While Core is down, or before its first tick, status has no dispatch, +// but the inverter keeps the last order: a missing order is no move. A +// move counts from 0.5 A a phase (345 W), as raisedAt does, so a one- +// sample 158 W wobble of Core's closed loop (seed 207539 at 151.7 s) is +// no raise. 16 A fuse, a load step at 40 s puts 20 A on L1. +func TestInvariantsABatteryOrderMovesOnlyWhenFTWMovesIt(t *testing.T) { + order := func(r *sample, w float64) { + r.Status = &ftwStatus{Dispatch: []ftwDispatch{{Driver: "sungrow", TargetW: w}}} + } + for _, c := range []struct { + name string + set func(i int, r *sample) + down bool // Core restarts at 44 s and is back at 49 s + want bool // both limit criteria hold + }{ + // At 6 % SoC the battery charging 1 kW cannot take 4 A a phase + // off, so the charger is the lever; Core's restart extends the + // stretch to 104 s. + {"1 kW order kept while Core is down", func(i int, r *sample) { + r.BatW, r.Bat.SoCPct = 1000, 6 + order(r, 1000) + if i >= 41 && i < 80 { + r.PhaseA = [3]float64{20, 8, 8} + } + }, true, true}, + {"1 kW order missing while Core is down, then restored", func(i int, r *sample) { + r.BatW, r.Bat.SoCPct = 1000, 6 + if i < 44 || i >= 50 { + order(r, 1000) + } + if i >= 41 && i < 80 { + r.PhaseA = [3]float64{20, 8, 8} + } + }, true, true}, + {"0 W order missing while Core is down, then 1 kW", func(i int, r *sample) { + r.Bat.SoCPct = 6 + switch { + case i < 44: + order(r, 0) + case i >= 50: + order(r, 1000) + r.BatW = 1000 + } + if i >= 41 && i < 80 { + r.PhaseA = [3]float64{20, 8, 8} + } + }, true, false}, + // The battery discharges 4 999 W and answers in 2 s: allowed + // until 47 s. + {"steady discharge order", func(i int, r *sample) { + r.BatW = -4999 + order(r, -4999) + if i >= 41 && i < 46 { + r.PhaseA = [3]float64{20, 8, 8} + } + }, false, true}, + {"one 158 W wobble toward import", func(i int, r *sample) { + r.BatW = -4999 + order(r, -4999) + if i == 43 { + order(r, -4841) + } + if i >= 41 && i < 46 { + r.PhaseA = [3]float64{20, 8, 8} + } + }, false, true}, + {"one 360 W move toward import", func(i int, r *sample) { + r.BatW = -4999 + order(r, -4999) + if i == 43 { + order(r, -4639) + } + if i >= 41 && i < 46 { + r.PhaseA = [3]float64{20, 8, 8} + } + }, false, false}, + } { + d := randomRun(121, 16, 8, c.set) + withCharger(t, d, 40, 0) + d.events = []houseEventLine{{T: 40, Event: houseEvent{Do: "loads"}}} + if c.down { + d.allMarks = []markLine{{T: 44, Label: "ftw:restart"}, {T: 49, Label: "ftw:up"}} + } + m, _ := judgeInvariants(d) + v := judged(d) + if got := v[limitStep] && v[limitOwn]; got != c.want { + t.Errorf("%s: step %v, own %v; want both %v; judged %v", c.name, v[limitStep], v[limitOwn], c.want, m["over_limit_judged"]) + } + } +} + +// Export is judged by what pushes export. A car raise before an export +// stretch soaks it up, so the stretch keeps its step's time. A battery +// pushed toward export after a Core restart is FTW's own push: the +// restart's minute does not excuse it. +func TestInvariantsExportIsFTWsOwnOnlyWhenFTWPushedExport(t *testing.T) { + t.Run("car raise before an export stretch", func(t *testing.T) { + off := false + d := randomRun(121, 16, 0, func(i int, r *sample) { + r.Bat.Running = &off + if i >= 45 && i < 80 { + r.PhaseA = [3]float64{-20, -20, -20} + } + }) + withCharger(t, d, 40, 0) + d.frames = []frame{profileAt(20, 0), profileAt(43, 16)} + d.events = []houseEventLine{{T: 40, Event: houseEvent{Do: "pv", Device: "inverter"}}} + m, _ := judgeInvariants(d) + if v := judged(d); !(v[limitStep] && v[limitOwn]) { + t.Errorf("step %v, own %v; judged %v", v[limitStep], v[limitOwn], m["over_limit_judged"]) + } + }) + for _, c := range []struct { + name string + running bool // the inverter runs from 55 s + want bool + }{ + {"battery pushed 0 -> -5 kW after a restart", true, false}, + {"the same order with the inverter off", false, true}, + } { + t.Run(c.name, func(t *testing.T) { + d := randomRun(201, 16, 0, func(i int, r *sample) { + w := 0.0 + if i >= 57 { + w = -5000 + } + r.Status = &ftwStatus{Dispatch: []ftwDispatch{{Driver: "sungrow", TargetW: w}}} + if i >= 55 && !c.running { + run := false + r.Bat.Running = &run + } + if i >= 58 && c.running { + r.BatW = w + } + if i >= 58 && i < 100 { + r.PhaseA = [3]float64{-21, -10, -10} + } + }) + withCharger(t, d, 5, 0) + d.allMarks = []markLine{{T: 50, Label: "ftw:restart"}, {T: 55, Label: "ftw:up"}} + m, _ := judgeInvariants(d) + v := judged(d) + if got := v[limitStep] && v[limitOwn]; got != c.want { + t.Errorf("step %v, own %v; want both %v; judged %v", v[limitStep], v[limitOwn], c.want, m["over_limit_judged"]) + } + }) + } + // Inside an export stretch after a PV step. The battery charges at + // its maximum, so the 40 s charger is the lever. Moving its order + // toward export while the meter is over the fuse is FTW's own; the + // same move in a dip under the fuse, with a step after it, is not. + for _, c := range []struct { + name string + moveAt int // the order goes 5 -> 4 kW; 0: no move + dip bool + want bool + }{ + {"no move", 0, false, true}, + {"battery moved toward export over the fuse", 20, false, false}, + {"the same move in a dip, a step after it", 56, true, true}, + } { + t.Run(c.name, func(t *testing.T) { + d := randomRun(121, 16, -12, func(i int, r *sample) { + r.BatW = rigBatteryMaxW + w := rigBatteryMaxW + if c.moveAt > 0 && i >= c.moveAt { + w = 4000 + } + r.Status = &ftwStatus{Dispatch: []ftwDispatch{{Driver: "sungrow", TargetW: w}}} + switch { + case !c.dip && i >= 11 && i < 45: + r.PhaseA = [3]float64{-22, -12, -12} + case c.dip && (i >= 11 && i < 45 || i >= 71 && i < 76): + r.PhaseA = [3]float64{-22, -12, -12} + case c.dip && i >= 45 && i < 71: + r.PhaseA = [3]float64{-15, -12, -12} + } + }) + withCharger(t, d, 40, 0) + d.events = []houseEventLine{{T: 10, Event: houseEvent{Do: "pv", Device: "inverter"}}} + if c.dip { + d.events = append(d.events, houseEventLine{T: 70, Event: houseEvent{Do: "pv", Device: "inverter"}}) + } + m, _ := judgeInvariants(d) + v := judged(d) + if got := v[limitStep] && v[limitOwn]; got != c.want { + t.Errorf("step %v, own %v; want both %v; judged %v", v[limitStep], v[limitOwn], c.want, m["over_limit_judged"]) + } + }) + } +} + +// The line is In, not 1.2 x In: a car raise sent while the meter shows +// 1.1 x In (under the instant cut, the heat still cool) is sent over the +// line, and the stretch is FTW's own. +func TestInvariantsARaiseBetweenFuseAndInstantIsOwn(t *testing.T) { + d := randomRun(121, 25, 12, func(i int, r *sample) { + r.Bat.SoCPct = 6 + if i >= 41 && i < 84 { + r.PhaseA = [3]float64{27.5, 27.5, 27.5} + } + if i >= 52 && i < 84 { + r.PhaseA = [3]float64{42, 42, 42} + } + }) + withCharger(t, d, 40, 0) + d.frames = []frame{profileAt(20, 0), profileAt(50, 16)} + d.events = []houseEventLine{{T: 40, Event: houseEvent{Do: "pv", Device: "inverter"}}} + m, _ := judgeInvariants(d) + if v := judged(d); v[limitStep] && v[limitOwn] { + t.Errorf("a raise sent at 1.1 x In passed; judged %v", m["over_limit_judged"]) + } +} From 04151611f43a60fda2c241936639e29f258c21fd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 09:29:24 +0100 Subject: [PATCH 8/9] test(site): R8 reads Core's reconnect backoff from source The judge's reconnectBackoffMaxS was a fixed 60 s, checked against modbus/client.go. #1930 cuts Core's cap to 10 s, so the check would fail whichever of the two lands second. The judge now reads the cap from the source the rig builds Core from, and the 207539 shapes are set relative to it. The unit tests pass with 60 s and with #1930's 10 s applied. Co-Authored-By: Claude Opus 5.5 --- go/test/site/random_test.go | 30 ++++++++++++++++++++--- go/test/site/random_unit_test.go | 42 ++++++++++++++++++-------------- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/go/test/site/random_test.go b/go/test/site/random_test.go index d30d9979e..6c0e9c3af 100644 --- a/go/test/site/random_test.go +++ b/go/test/site/random_test.go @@ -9,7 +9,9 @@ import ( "math/rand" "os" "path/filepath" + "regexp" "sort" + "strconv" "strings" "syscall" "time" @@ -310,12 +312,32 @@ const ( // R8: how long a battery FTW does not dispatch may hold a forced mode, // and the Core timers that may delay it after the inverter returns. - inverterDriver = "sungrow" // the rig's inverter driver (stack_test.go) - forcedBudgetS = 10.0 - reconnectBackoffMaxS = 60.0 // modbus/client.go reconnectBackoffMax - defaultRetryMaxS = 30.0 // drivers/registry.go defaultRetryMax; 0 once #1915 (C11) lands + inverterDriver = "sungrow" // the rig's inverter driver (stack_test.go) + forcedBudgetS = 10.0 + defaultRetryMaxS = 30.0 // drivers/registry.go defaultRetryMax; 0 once #1915 (C11) lands ) +// reconnectBackoffMaxS is Core's Modbus reconnect backoff cap +// (modbus/client.go reconnectBackoffMax), read from the source the rig +// builds Core from, so R8 follows Core when the cap changes (60 s, then +// 10 s with #1930). +var reconnectBackoffMaxS = coreSeconds("modbus/client.go", "reconnectBackoffMax") + +// coreSeconds reads "name = N * time.Second" from a file under go/internal. +// It panics when the line is gone: the judge has no number to fall back on. +func coreSeconds(file, name string) float64 { + b, err := os.ReadFile(filepath.Join("..", "..", "internal", file)) + if err != nil { + panic(err) + } + m := regexp.MustCompile(name + `\s*=\s*(\d+)\s*\*\s*time\.Second`).FindSubmatch(b) + if m == nil { + panic(fmt.Sprintf("no %s = N * time.Second in Core's %s", name, file)) + } + n, _ := strconv.Atoi(string(m[1])) + return float64(n) +} + // dispatchW is Core's battery order on r, + charge; false when Core // dispatched no battery. func dispatchW(r *sample) (float64, bool) { diff --git a/go/test/site/random_unit_test.go b/go/test/site/random_unit_test.go index 57a97b28e..1c7b8c36b 100644 --- a/go/test/site/random_unit_test.go +++ b/go/test/site/random_unit_test.go @@ -445,6 +445,7 @@ func (c forcedCase) run() *runData { // and Core's first good read is Core's to take. func TestInvariantsNoForcedBatteryWithoutDispatch(t *testing.T) { const forced = "no forced battery mode FTW does not dispatch" + read207539 := 182 + reconnectBackoffMaxS - 4 for _, c := range []forcedCase{ // 207533: offline 576.6-644.7 s, Core reads 9 s later, 187 / // 2668 W until FTW dispatches again 236 s after the return (C1). @@ -464,15 +465,16 @@ func TestInvariantsNoForcedBatteryWithoutDispatch(t *testing.T) { // 10 s of Core's first good read. {name: "default 8 s after Core reads again", outage: "offline", outFrom: 100, outFor: 68, readAt: 177, cmd: 187, setW: 2668, forcedTo: 185, want: true}, - // 207539: offline 508.5-590.0 s, the Modbus backoff holds Core's - // first good read back 55.7 s, and FTW dispatches 20 s after it. + // 207539: offline 508.5-590.0 s, the Modbus backoff (60 s then) + // holds Core's first good read back 55.7 s, and FTW dispatches 20 s + // after it. Here the read comes 4 s before the backoff's cap ends. // Inside Core's timers; it fails once #1915 (C11) lands. - {name: "207539: Core's reconnect backoff and default retry", outage: "offline", outFrom: 100, outFor: 82, readAt: 238, - cmd: 187, setW: 814, forcedTo: 1201, dispatchFrom: 258, want: true}, - {name: "past the registry's 30 s retry", outage: "offline", outFrom: 100, outFor: 82, readAt: 238, - cmd: 187, setW: 814, forcedTo: 1201, dispatchFrom: 285, want: false}, - // Core never reads the inverter again: the count starts 60 s after - // the return all the same. + {name: "207539: Core's reconnect backoff and default retry", outage: "offline", outFrom: 100, outFor: 82, readAt: read207539, + cmd: 187, setW: 814, forcedTo: 1201, dispatchFrom: read207539 + 20, want: true}, + {name: "past the registry's 30 s retry", outage: "offline", outFrom: 100, outFor: 82, readAt: read207539, + cmd: 187, setW: 814, forcedTo: 1201, dispatchFrom: read207539 + 47, want: false}, + // Core never reads the inverter again: the count starts at the + // backoff's cap after the return all the same. {name: "Core never reads it again", outage: "offline", outFrom: 100, outFor: 82, readAt: 1201, cmd: 187, setW: 814, forcedTo: 1201, dispatchFrom: 290, want: false}, {name: "FTW dispatches the discharge", cmd: 187, setW: 3000, forcedTo: 1201, dispatchFrom: 1, want: true}, @@ -556,20 +558,22 @@ func TestInvariantsForcedBatteryCountsFromTheLatestReturn(t *testing.T) { // The triage line is 10 s from the return, so the judge records what it // forgave beyond that line instead of hiding it. func TestInvariantsRecordForcedSecondsInsideCoreTimers(t *testing.T) { - // 207539's shape: back at 182, Core reads at 238 and dispatches at 245, - // inside the backoff alone. Forced 183-244 with no dispatch; past - // 182+10 that is 193-244. - c := forcedCase{outage: "offline", outFrom: 100, outFor: 82, readAt: 238, - cmd: 187, setW: 814, forcedTo: 1201, dispatchFrom: 245} + // 207539's shape: back at 182, Core reads 4 s before the backoff's cap + // ends (238 with 60 s) and dispatches 7 s later, inside the backoff + // alone. Forced with no dispatch until then; past 182+10 that is from + // 193 (52 s with 60 s). + read := 182 + reconnectBackoffMaxS - 4 + c := forcedCase{outage: "offline", outFrom: 100, outFor: 82, readAt: read, + cmd: 187, setW: 814, forcedTo: 1201, dispatchFrom: read + 7} m, _ := judgeInvariants(c.run()) - if in, past := m["battery_forced_in_core_timers_s"], m["battery_forced_past_allowance_s"]; in != 52.0 || past != 0.0 { - t.Errorf("in Core's timers %v s, past the allowance %v s; want 52 and 0", in, past) + if in, past, want := m["battery_forced_in_core_timers_s"], m["battery_forced_past_allowance_s"], read+6-192; in != want || past != 0.0 { + t.Errorf("in Core's timers %v s, past the allowance %v s; want %v and 0", in, past, want) } // Forced to 299: from 193 to the allowance's end inside the timers, // the rest past them. c.dispatchFrom = 300 m, _ = judgeInvariants(c.run()) - end := 238 + defaultRetryMaxS + forcedBudgetS + end := read + defaultRetryMaxS + forcedBudgetS if in, past := m["battery_forced_in_core_timers_s"], m["battery_forced_past_allowance_s"]; in != end-192 || past != 299-end { t.Errorf("in Core's timers %v s, past the allowance %v s; want %v and %v", in, past, end-192, 299-end) } @@ -595,8 +599,10 @@ func TestForcedAllowancesMatchCore(t *testing.T) { n, _ := strconv.Atoi(m[1]) return float64(n) } - if got := seconds(src("modbus/client.go"), "reconnectBackoffMax"); got != reconnectBackoffMaxS { - t.Errorf("reconnectBackoffMaxS = %v, Core's reconnectBackoffMax is %v s", reconnectBackoffMaxS, got) + // reconnectBackoffMaxS is read from Core's source; the 207539 shapes + // below need a backoff that can hold Core's first read back past 4 s. + if reconnectBackoffMaxS < 5 { + t.Errorf("Core's reconnectBackoffMax is %v s; rework the 207539 shapes", reconnectBackoffMaxS) } registry := src("drivers/registry.go") // Today the registry sends the default on a failure and on its retry From 959cae3d8be40eafa447bc0c35895204c6db1f43 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 10:15:29 +0100 Subject: [PATCH 9/9] test(site): cap a stretch's events by the fuse heat; a car cut into export is FTW's own Owner decisions of 28 Oct 2026 for the random judge: - A car cut FTW sends that pushes export over the fuse is FTW's own, as a battery move toward export already was: after a step's meter delay and before the stretch, or while the stretch is over the fuse. - What events add to a stretch (R2 growth) stops at the fuse's heat budget: once a phase's 60 s heat passes the fuse, Core's cut gets its lever's time and no further event extends the stretch. A window that opens after the allowance ran out, with the current under the fuse since, is a new step and its cap runs from it. This closes E3 (small real rises chaining a stretch's allowance). Co-Authored-By: Claude Opus 5.5 --- go/test/site/random_test.go | 49 ++++++++++++----- go/test/site/random_unit_test.go | 93 ++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 12 deletions(-) diff --git a/go/test/site/random_test.go b/go/test/site/random_test.go index 7cfc0aed1..e2855ab73 100644 --- a/go/test/site/random_test.go +++ b/go/test/site/random_test.go @@ -385,6 +385,9 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } } m["car_starts_ftw_did_not_order"] = carStarts + // In time order, so a new step after a stretch's allowance is told + // from a window inside it (judgeStretch). + sort.SliceStable(windows, func(i, j int) bool { return windows[i].from < windows[j].from }) // chargerLever is how long the charger takes to answer from t: its own // time, after the rest of any outage it is in. @@ -441,15 +444,16 @@ func judgeInvariants(d *runData) (metrics, []criterion) { // starts when the heat passes the fuse, as Core's cut does. A stretch // runs on across dips under the fuse while the heat stays over it. blownAny := anyBlown(run) - // raisedIn is whether FTW sent the car a higher order in (a, b]; with - // over, only one sent while the meter showed the fuse passed. - raisedIn := func(a, b float64, over bool) bool { + // carMovedIn is whether FTW sent the car a higher order (dir +1, toward + // import) or a lower one (dir -1, toward export) in (a, b]; with over, + // only one sent while the meter showed the fuse passed. + carMovedIn := func(a, b float64, over bool, dir float64) bool { prev, ok := 0.0, false for _, p := range orders { if p.T > b { break } - if ok && p.T > a && p.offerW() > prev+1 && (!over || maxAbsI(d.at(p.T)) > fuse) { + if ok && p.T > a && dir*(p.offerW()-prev) > 1 && (!over || maxAbsI(d.at(p.T)) > fuse) { return true } prev, ok = p.offerW(), true @@ -483,16 +487,18 @@ func judgeInvariants(d *runData) (metrics, []criterion) { return false } // ownRaise is whether FTW pushed a stretch from s to end over the fuse - // itself: it moved the battery toward the stretch's direction in - // (a, s], or moved it, or raised the car on import, while the stretch - // was over the fuse. On import a car raise in (a, s] counts too. On - // export, raising the car is the right answer. + // itself: it moved the car or the battery toward the stretch's + // direction in (a, s], or while the stretch was over the fuse. On + // import that is a car raise; on export a car cut (owner, 28 Oct 2026), + // as a battery move toward export already was. Raising the car on + // export is the right answer. ownRaise := func(a, s, end float64, export bool) bool { + dir := 1.0 if export { - return batMovedIn(a, s, false, -1) || batMovedIn(s, end, true, -1) + dir = -1 } - return raisedIn(a, s, false) || batMovedIn(a, s, false, 1) || - raisedIn(s, end, true) || batMovedIn(s, end, true, 1) + return carMovedIn(a, s, false, dir) || batMovedIn(a, s, false, dir) || + carMovedIn(s, end, true, dir) || batMovedIn(s, end, true, dir) } // raisedAt is whether the worst phase rose 0.5 A or more in the 3 s // from t. @@ -563,11 +569,27 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } return math.Inf(1) } + // heatCap is the most that windows opening from t on may add: once + // a phase's 60 s heat is over the fuse, Core's cut has its lever's + // time, and no further event extends the stretch (owner, 28 Oct + // 2026). Unbounded while the heat stays under the fuse. + heatCap := func(t float64) float64 { + for _, r := range stretch { + if r.T >= t && max(r.HeatA[0], r.HeatA[1], r.HeatA[2]) > fuse { + return r.T + lever(r.T, overA, export) + 2 + 3 + } + } + return math.Inf(1) + } // A window that opens inside the stretch before it broke its // allowance extends it: a second step, an outage or a Core restart. // A plain house event counts only if it raised the current. Inside // FTW's own raise a window counts only once the raise is answered: // after its 3 s, with the current under the fuse until the window. + // What windows add stops at the heat's cap, however many there are. + // A window that opens once the allowance has run out, with the + // current under the fuse since, is a new step: its cap runs from it. + capT := heatCap(s) for grown := true; grown; { grown = false for _, w := range windows { @@ -577,7 +599,10 @@ func judgeInvariants(d *runData) (metrics, []criterion) { if w.plain && !raisedAt(w.from) { continue } - if x := until(w, w.from, overA, export); x > to { + if w.from > to { + capT = math.Max(capT, heatCap(w.from)) + } + if x := math.Min(until(w, w.from, overA, export), capT); x > to { to, grown, own = x, true, false } } diff --git a/go/test/site/random_unit_test.go b/go/test/site/random_unit_test.go index 72626ed23..70b0766ef 100644 --- a/go/test/site/random_unit_test.go +++ b/go/test/site/random_unit_test.go @@ -964,3 +964,96 @@ func TestInvariantsARaiseBetweenFuseAndInstantIsOwn(t *testing.T) { t.Errorf("a raise sent at 1.1 x In passed; judged %v", m["over_limit_judged"]) } } + +// Owner, 28 Oct 2026: what events add to a stretch stops at the fuse's +// heat budget. Once L1's 60 s heat passes the fuse, Core's cut has its +// lever's time (here the 5 s charger: the battery is at its floor), and +// no further event extends the stretch, however many come. 16 A fuse, +// 8 A on each phase, a load step at 10 s puts L1 at 20 A (1.25 x In) +// from 11 s; a load event every 8 s from 18 s raises it 0.5 A from a +// 1 s dip (the third review's E3). L1's heat passes the fuse at 62 s, so +// the cap is 62 + 5 + 2 + 3 = 72 s. A Core restart at 15 s, which would +// excuse a minute, is capped too: with no dips the heat passes at 61 s. +func TestInvariantsHeatCapsWhatEventsAdd(t *testing.T) { + for _, c := range []struct { + name string + to int // L1 over the fuse until here + restartAt float64 // a Core restart instead of the load events; 0: none + want bool + }{ + {"events end before the heat passes the fuse", 55, 0, true}, + {"events end inside Core's cut time", 72, 0, true}, + {"events past the heat's cap", 76, 0, false}, + {"events chained 280 s", 290, 0, false}, + {"a Core restart past the heat's cap", 74, 15, false}, + {"a Core restart before the heat passes the fuse", 55, 15, true}, + } { + d := randomRun(301, 16, 8, func(i int, r *sample) { + r.Bat.SoCPct = 6 + if i >= 11 && i < c.to { + r.PhaseA[0] = 20 + if c.restartAt == 0 && i >= 17 && (i-17)%8 == 0 { + r.PhaseA[0] = 19.5 + } + } + }) + withCharger(t, d, 5, 0) + d.events = []houseEventLine{{T: 10, Event: houseEvent{Do: "loads"}}} + if c.restartAt > 0 { + d.allMarks = []markLine{{T: c.restartAt, Label: "ftw:restart"}, {T: c.restartAt + 9, Label: "ftw:up"}} + } else { + for at := 18.0; at < float64(c.to); at += 8 { + d.events = append(d.events, houseEventLine{T: at, Event: houseEvent{Do: "loads"}}) + } + } + d.warm() + if h := d.at(61).HeatA[0]; c.restartAt == 0 && h > 16 { + t.Fatalf("%s: heat %.2f A at 61 s, want under the fuse", c.name, h) + } + if h := d.at(62).HeatA[0]; c.to > 62 && h <= 16 { + t.Fatalf("%s: heat %.2f A at 62 s, want over the fuse", c.name, h) + } + m, _ := judgeInvariants(d) + if got := judged(d)[limitStep]; got != c.want { + t.Errorf("%s: %q = %v, want %v; judged %v", c.name, limitStep, got, c.want, m["over_limit_judged"]) + } + } +} + +// Owner, 28 Oct 2026: a car cut FTW sends that pushes export over the +// fuse is FTW's own, as a battery move toward export is: after a step's +// 2 s meter delay and before the stretch, or while the stretch is over +// the fuse. A cut the meter cannot have shown the step for yet is part +// of the step. 16 A fuse, 12 A export per phase, the battery charging at +// its maximum, so the 40 s charger is the lever; PV steps at 10 s and +// L1 exports 22 A from `from` to 45 s, inside the step's 55 s. +func TestInvariantsACarCutIntoExportIsFTWsOwn(t *testing.T) { + for _, c := range []struct { + name string + from int + frames []frame + want bool // both limit criteria hold + }{ + {"no cut", 11, []frame{profileAt(1, 16)}, true}, + {"16 -> 0 A while export is over the fuse", 11, []frame{profileAt(1, 16), profileAt(20, 0)}, false}, + {"16 -> 6 A after the meter shows the step, before the stretch", 20, []frame{profileAt(1, 16), profileAt(15, 6)}, false}, + {"16 -> 6 A before the meter shows the step", 20, []frame{profileAt(1, 16), profileAt(11, 6)}, true}, + {"6 -> 16 A while export is over the fuse", 11, []frame{profileAt(1, 6), profileAt(20, 16)}, true}, + } { + d := randomRun(121, 16, -12, func(i int, r *sample) { + r.BatW = rigBatteryMaxW + r.Status = &ftwStatus{Dispatch: []ftwDispatch{{Driver: "sungrow", TargetW: rigBatteryMaxW}}} + if i >= c.from && i < 45 { + r.PhaseA[0] = -22 + } + }) + withCharger(t, d, 40, 0) + d.frames = c.frames + d.events = []houseEventLine{{T: 10, Event: houseEvent{Do: "pv", Device: "inverter"}}} + m, _ := judgeInvariants(d) + v := judged(d) + if got := v[limitStep] && v[limitOwn]; got != c.want { + t.Errorf("%s: step %v, own %v; want both %v; judged %v", c.name, v[limitStep], v[limitOwn], c.want, m["over_limit_judged"]) + } + } +}