From 8274e40465d37e5ad31549623f89d39607648f94 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 17:47:20 +0100 Subject: [PATCH 1/2] test(site): the rig expects the room the fuse rating gives S1 targets floor(fuse - house) in 1 A steps (8 970 W on 16 A with a 3 A house, was 8 280 W). S12 counts a cut on room alone while every phase is under fuse - 0.1 A and records fuse_limit with 6 A of room under the fuse. The random judge measures Charge now room against the fuse, with the car's 6 A minimum as the threshold. The stack pins safety_margin_a to 0 so the rig does not depend on Core's default. This tightens the rig: it passes only once Core works to the rating. Co-Authored-By: Claude Opus 5.5 --- go/test/site/README.md | 2 +- go/test/site/random_test.go | 7 ++-- go/test/site/rig_unit_test.go | 77 ++++++++++++++++++++++++++++++++++ go/test/site/scenarios_test.go | 8 ++-- go/test/site/stack_test.go | 4 +- go/test/site/verdicts_test.go | 5 ++- 6 files changed, 92 insertions(+), 11 deletions(-) diff --git a/go/test/site/README.md b/go/test/site/README.md index b650a9e04..b1a75ca3c 100644 --- a/go/test/site/README.md +++ b/go/test/site/README.md @@ -85,7 +85,7 @@ tick as 2 s more. |---|---|---|---| | `s0-25` Idle charger | 25 A, 3 A per phase, a charger with no car, heartbeat 60 s, 300 s | Home use `measured` in 95 % of samples; no OCPP disconnect | yes | | `s0b-25` | as s0, Core's heartbeat | as s0 | | -| `s1-16`, `s1-25` Charge now | 3 A per phase, Charge now 16 A for 180 s | the room the fuse gives (25 A: 11 040 W) within 60 s; held 95 %; fuse holds; on 25 A no `fuse_limit` | yes | +| `s1-16`, `s1-25` Charge now | 3 A per phase, Charge now 16 A for 180 s | the room the fuse gives (16 A: 8 970 W; 25 A: 11 040 W) within 60 s; held 95 %; fuse holds; on 25 A no `fuse_limit` | yes | | `s2-16` Charge now during export | 16 A, 1 A house, 7 kW PV, full battery: 9.1 A export per phase | car starts within 60 s (**known**: export counted as import); fuse holds | | | `s3-16`, `s3-25` Load step | car and a 3 kW battery hold charging, then 13 A (16 A) or 21 A (25 A) per phase for 180 s | fuse holds; over the limit at most 7 s after the step | yes | | `s4-25`, `s4b-25` Saved unlock | `surplus_unlock_bat_soc` 0.9 saved, not armed; Charge now 3p, and bare (s4b) | no stops; no offer drops to 0 W; full power over the last minute | | diff --git a/go/test/site/random_test.go b/go/test/site/random_test.go index b1b71e6c5..537ac7cca 100644 --- a/go/test/site/random_test.go +++ b/go/test/site/random_test.go @@ -367,9 +367,10 @@ func judgeInvariants(d *runData) (metrics, []criterion) { for _, hw := range holds { run4 = 0 for i, r := range d.window(hw.from+grace, hw.to) { - // Room below fuse - 1 A, the car's line in Core. - roomA := fuse - 1 - (maxAbsI(r) - carW(r, 0)/(3*volt)) - bad := carW(r, 0) < 0.5*1380 && roomA >= 7 && !inFault(r.T) && !nearRestart(r.T) + // 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) if bad && i > 0 { run4++ idle = math.Max(idle, run4) diff --git a/go/test/site/rig_unit_test.go b/go/test/site/rig_unit_test.go index 17dae5d8c..6ceab9e1c 100644 --- a/go/test/site/rig_unit_test.go +++ b/go/test/site/rig_unit_test.go @@ -184,3 +184,80 @@ func TestInvariantsJudgeAtTheFuse(t *testing.T) { } } } + +// S1 expects the room the fuse rating gives: on 16 A with a 3 A house that +// is 13 A per phase, 8 970 W. The old line at fuse - 1 A (8 280 W) falls +// short of it. +func TestJudgeS1ExpectsTheRoomAtTheFuse(t *testing.T) { + const name = "target within 60 s" + for _, c := range []struct { + carW float64 + want bool + }{{8970, true}, {8280, false}} { + d := synth(181, map[string]float64{"charge_now": 0, "end": 180}, map[string]any{"fuse": 16.0, "house_a": 3}, + func(i int, r *sample) { + if i >= 10 { + r.CarW = []float64{c.carW} + } + }) + m, cs := judgeS1(d) + if m["target_w"] != 8970.0 { + t.Fatalf("target_w = %v, want 8970", m["target_w"]) + } + if got := verdictsOf(cs)[name]; got != c.want { + t.Errorf("car at %v W: %q = %v, want %v", c.carW, name, got, c.want) + } + } +} + +// S12 counts a cut as one on room alone while every phase is under the fuse +// by more than 0.1 A, and records fuse_limit while the worst phase has 6 A. +func TestJudgeS12MeasuresRoomAtTheFuse(t *testing.T) { + const fuse = 20.0 + for _, c := range []struct { + name string + a float64 // the worst phase when the order drops + cuts, limit int + }{ + {"at the fuse", fuse, 0, 0}, + {"0.1 A under", fuse - 0.1, 0, 0}, + {"0.5 A under", fuse - 0.5, 1, 0}, + {"5.5 A under", fuse - 5.5, 1, 0}, + {"6.5 A under", fuse - 6.5, 1, 1}, + } { + d := synth(121, map[string]float64{"plan": 0, "ready": 100, "end": 120}, map[string]any{"fuse": fuse, "plan_wh": 6600.0}, + func(i int, r *sample) { + r.PhaseA = [3]float64{c.a, c.a - 1, c.a - 2} + if i == 30 { + r.LPs = []ftwLoadpoint{{CommandedReason: "fuse_limit"}} + } + }) + d.frames = []frame{profileAt(10, 16), profileAt(20, 10)} + m, _ := judgeS12(d) + if m["cuts_with_room"] != c.cuts || m["fuse_limit_with_room_s"] != c.limit { + t.Errorf("%s: cuts %v, fuse_limit %v; want %d, %d", c.name, m["cuts_with_room"], m["fuse_limit_with_room_s"], c.cuts, c.limit) + } + } +} + +// During Charge now an idle car is a fault once the house leaves the car's +// 6 A minimum under the fuse; with less room it is not. +func TestInvariantsChargeNowRoomAtTheFuse(t *testing.T) { + const fuse = 25.0 + const name = "Charge now charges when there is room" + for _, c := range []struct { + houseA float64 + want bool + }{{fuse - 6, false}, {fuse - 5.5, true}} { + d := synth(201, map[string]float64{"start": 0, "end": 200}, map[string]any{"fuse": fuse}, + func(_ int, r *sample) { + r.PhaseA = [3]float64{c.houseA, c.houseA, c.houseA} + r.Bat.SoCPct = 50 + }) + d.allMarks = []markLine{{T: 1, Label: "ftw:charge_now"}, {T: 200, Label: "end"}} + _, cs := judgeInvariants(d) + if got := verdictsOf(cs)[name]; got != c.want { + t.Errorf("house %v A: %q = %v, want %v", c.houseA, name, got, c.want) + } + } +} diff --git a/go/test/site/scenarios_test.go b/go/test/site/scenarios_test.go index 82629c5a8..13e672eaa 100644 --- a/go/test/site/scenarios_test.go +++ b/go/test/site/scenarios_test.go @@ -712,10 +712,10 @@ func judgeS12(d *runData) (metrics, []criterion) { if p.Limit > 0 && p.phasesOr3() == 1 { ones++ } - // A lower order while every phase has room is a cut on room alone, - // unless the plan itself asked for less (a pause between slots). + // A lower order while every phase is under the fuse is a cut on room + // alone, unless the plan itself asked for less (a pause between slots). if i > 0 && p.offerW() < prs[i-1].offerW()-1 { - if r := d.at(t0 + p.T); r != nil && maxAbsI(r) < fuse-1-0.5 { + if r := d.at(t0 + p.T); r != nil && maxAbsI(r) < fuse-0.1 { if after := d.at(t0 + p.T + 2); after == nil || firstLP(after).CommandedReason != "plan" { cuts++ } @@ -726,7 +726,7 @@ func judgeS12(d *runData) (metrics, []criterion) { // recorded, not judged, since a slow charger's pending order may hold it. limited := 0 for _, r := range w { - if r.LPs != nil && firstLP(r).CommandedReason == "fuse_limit" && maxAbsI(r) < fuse-1-6 { + if r.LPs != nil && firstLP(r).CommandedReason == "fuse_limit" && maxAbsI(r) < fuse-6 { limited++ } } diff --git a/go/test/site/stack_test.go b/go/test/site/stack_test.go index 11c6b55be..ac597c872 100644 --- a/go/test/site/stack_test.go +++ b/go/test/site/stack_test.go @@ -558,7 +558,9 @@ func (st *stack) ftwConfig() (string, error) { cfg := map[string]any{ "site": map[string]any{"name": "rig", "control_interval_s": 2, "grid_target_w": 0, "grid_tolerance_w": 42, "watchdog_timeout_s": 60}, - "fuse": map[string]any{"max_amps": st.spec.fuseA, "phases": 3, "voltage": 230}, + // safety_margin_a pinned at 0 so the rig judges Core at the rating + // whatever the default. + "fuse": map[string]any{"max_amps": st.spec.fuseA, "phases": 3, "voltage": 230, "safety_margin_a": 0}, "drivers": []map[string]any{ {"name": "meter", "lua": "drivers/sdm630.lua", "is_site_meter": true, "capabilities": modbus("meter")}, {"name": "sungrow", "lua": "drivers/sungrow.lua", "battery_capacity_wh": 10000, "capabilities": modbus("inverter")}, diff --git a/go/test/site/verdicts_test.go b/go/test/site/verdicts_test.go index a621c463b..d26bfc9bd 100644 --- a/go/test/site/verdicts_test.go +++ b/go/test/site/verdicts_test.go @@ -45,8 +45,9 @@ func judgeS1(d *runData) (metrics, []criterion) { fuse := d.metaF("fuse") target := maxW if fuse < 25 { - // Room on a 16 A fuse: fuse - 1 A margin - house, in 1 A steps. - target = math.Min(maxW, math.Floor(fuse-1-d.metaF("house_a"))*volt*3) + // Room on a 16 A fuse: fuse - house, in 1 A steps. Core works to + // the rating; safety_margin_a is a site's opt-in and the rig sets 0. + target = math.Min(maxW, math.Floor(fuse-d.metaF("house_a"))*volt*3) } m := d.carSummary(t0, t1, target) m["target_w"] = target From b899d5261b9dcacf7b2a661bc3a232dc8edfb6f1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 17:56:16 +0100 Subject: [PATCH 2/2] test(site): say S1-16's target is a floor while the battery covers the house A run on a Core that stops at fuse - 1 A gave the car 9 590 W with the battery at -5 000 W, above the 8 970 W target. S1-16 does not catch the old line. Co-Authored-By: Claude Opus 5.5 --- go/test/site/verdicts_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go/test/site/verdicts_test.go b/go/test/site/verdicts_test.go index d26bfc9bd..428f985c8 100644 --- a/go/test/site/verdicts_test.go +++ b/go/test/site/verdicts_test.go @@ -47,6 +47,8 @@ func judgeS1(d *runData) (metrics, []criterion) { if fuse < 25 { // Room on a 16 A fuse: fuse - house, in 1 A steps. Core works to // the rating; safety_margin_a is a site's opt-in and the rig sets 0. + // The target is a floor, not the line: the battery (SoC 50) covers + // the house, so a Core that stops at fuse - 1 A also passes. target = math.Min(maxW, math.Floor(fuse-d.metaF("house_a"))*volt*3) } m := d.carSummary(t0, t1, target)