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..7a2f93145 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,9 +63,16 @@ 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 + 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 { @@ -107,6 +116,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 +140,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 +401,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..062214b29 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" @@ -212,96 +214,520 @@ 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 +} + +// 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) (at, from 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) (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 at, 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 +} + +// 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) + 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) + + // 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 + 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) { + 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 + // 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, chargerOut []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 + until = 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": + 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 + } + } + w.plain = until == 0 + 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, 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 { + // 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 + // 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. + chargerLever := func(t float64) float64 { + left := 0.0 + for _, w := range chargerOut { if t >= w.from && t <= w.to { - best, ok = math.Max(best, w.to), true + left = math.Max(left, w.to-t) } } - return best, ok + 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 and its + // spare power covers the overage; otherwise the charger. At its floor + // 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 || during(inverterOut, t) || (r.Bat.Running != nil && !*r.Bat.Running) { + return chargerLever(t) + } + spare := rigBatteryMaxW + r.BatW // discharge more, or charge less + switch { + case export: + spare = rigBatteryMaxW - r.BatW + case r.Bat.SoCPct <= floorPct: + spare = math.Max(0, r.BatW) + } + if spare < overA*3*volt { + return chargerLever(t) + } + return 2 + } + until := func(w stepWindow, at, overA float64, export bool) float64 { + if w.untilT > 0 { + return w.untilT + } + lv := w.leverS + 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 } // 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. A stretch + // runs on across dips under the fuse while the heat stays over it. blownAny := anyBlown(run) + // 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 && dir*(p.offerW()-prev) > 1 && (!over || maxAbsI(d.at(p.T)) > fuse) { + return true + } + prev, ok = p.offerW(), true + } + return false + } + // 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 { + continue + } + if r.T > b { + 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 && dir*(w-prev) >= 0.5*3*volt && running && (!over || maxAbsI(r) > fuse) { + return true + } + prev, ok = w, true + } + return false + } + // ownRaise is whether FTW pushed a stretch from s to end over the fuse + // 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 { + dir = -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. + 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 - var start *sample - 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 - } + 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 + // 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 && !ownRaise(w.from+2, s, end, export) { + to, explained = math.Max(to, x), true + } + } + if !explained { // no change outside FTW: its own, import or export + own, to = true, c+3 + } + if !own && c > s { // Core answers once the heat says so + to = math.Max(to, c+lever(c, overA, export)+2+3) + } + // 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) + } + // 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 { + if w.from < s || w.from > math.Min(end, brokeAt(to)) || (own && w.from <= to) { + continue + } + if w.plain && !raisedAt(w.from) { + continue + } + if w.from > to { + capT = math.Max(capT, heatCap(w.from)) } - } else if end-start.T > 3 { - longestOwn = math.Max(longestOwn, end-start.T) - if firstBad == 0 { - firstBad = start.T + 3 + if x := math.Min(until(w, w.from, overA, export), capT); x > to { + to, grown, own = x, true, false } } - start = nil + } + 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) } } - m["over_limit_after_steps_s"], m["over_limit_own_s"] = round(unexplained, 1), round(longestOwn, 1) - - // 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 + from, last := -1, -1 + for i, r := range run { + if maxAbsI(r) > fuse { + if from < 0 { + from = i } + last = i } - return false + 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 + } + } + 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 { return within(faults, t, 30) } restartAt := []float64{t0} for _, mk := range d.allMarks { if mk.Label == "ftw:up" { @@ -346,12 +772,27 @@ 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. + // 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 + 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 +803,49 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } } } - grace := 60 + h.commandS + h.startupS + 10 + // 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" { + 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}) + } + // 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) { - // 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) + ws := d.window(hw.from, hw.to) + for i, r := range ws { + if i > 0 && regrace(ws[i-1].T, r.T) { + run4 = 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++ + 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 +853,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 +865,19 @@ 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 (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 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 @@ -422,6 +895,45 @@ func judgeInvariants(d *runData) (metrics, []criterion) { } } m["raises_on_a_stale_meter"] = raises + + // 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. 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, 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) + 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) + 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{} @@ -434,10 +946,11 @@ 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), + crit("no forced battery mode FTW does not dispatch", pastS == 0), } } @@ -490,6 +1003,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 new file mode 100644 index 000000000..116d54032 --- /dev/null +++ b/go/test/site/random_unit_test.go @@ -0,0 +1,1263 @@ +//go:build unix + +package site + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "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}, + {"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, + // 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 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 + 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}, + // 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 { + 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 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 + 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, [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, [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, [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. + 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} + } + 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...) + 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) + } + } +} + +// 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, 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 + 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 := 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) + } + } +} + +// 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 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 _, 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 c.event != "" { + d.events = []houseEventLine{{T: 51, Event: houseEvent{Do: c.event}}} + } + if v := judged(d); v[limitOwn] { + t.Errorf("%s: FTW's raise held 10 s at 1.24 x In and passed", c.name) + } + } +} + +// 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) + } + } +} + +// 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" + 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). + {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 (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: 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}, + {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}, + // 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) + } + } +} + +// 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 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, 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 := 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) + } +} + +// 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) + } + // 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 + // 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) { + 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) + } +} + +// 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) + } + } +} + +// 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) + } + } +} + +// 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"]) + } +} + +// 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"]) + } + } +} 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..fff3d3251 100644 --- a/go/test/site/rig_unit_test.go +++ b/go/test/site/rig_unit_test.go @@ -107,20 +107,25 @@ 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 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 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 +137,49 @@ 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 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) + 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) + } } } // 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,