diff --git a/go/test/site/README.md b/go/test/site/README.md index 7d2ff120f..fc804b544 100644 --- a/go/test/site/README.md +++ b/go/test/site/README.md @@ -113,8 +113,8 @@ the fuse and 1.2 × the fuse as Core does: it counts only once its 60 s heat | `s14-25` One-phase car | an 11 kW charger with a one-phase car, a plan it cannot meet, 20 min | 3.4–3.8 kW over the last 10 min; never called not following | | | `r1-25` Meter silent, then offline | 25 A, 15 A per phase, battery at 50 %, Charge now 16 A; the site meter silent (Modbus timeouts) for 120 s, then gone for 90 s. **Skipped**: the pinned simulator cannot silence the site meter | the battery relieved the fuse (setup); while silent no offer rises and the battery order only moves toward 0 (**known**: A1); a 0 W order and the battery back on its own within 64 s; the meter never shows live; Charge now back within 95 s of each return and held until the next event; fuse holds | | | `r2-25`, `r2-60-25` Battery drops out | 25 A, 20 A per phase, the battery held at a 3 kW discharge, a car taking 10 A on Charge now, the charger at once or 60 s late; the inverter gone for 180 s. The site stays under 1.2 × the fuse, where Core cuts the car on its 60 s heat budget, not at once | the battery discharged (setup); fuse holds; over 1.2 × the fuse at most the charger's delay + 7 s; the 60 s heat under 1.13 × the fuse; no offer rises while it is gone; Home use shown as estimated throughout. The time over the fuse and when Core sends the cut are recorded | | -| `r3-25` Planner floor | 25 A, 15 A per phase, battery at 12 %, planner `soc_min` 10 %, inverter floor 5 %, charger 60 s late, Charge now 16 A for 600 s | the battery reaches the floor (setup); no discharge ordered below it (**known**: A4); the battery order swings at most 100 W over the last 5 min (**known**: A3, A4); over the fuse at most the delay + 7 s; fuse holds | | -| `r4-25` Hard kill | 25 A, 12 A per phase, Charge now 16 A; once the day's Home use is known and 10 min more, the battery held at a 3 kW discharge; 5 min later SIGKILL for 20 s, and 2 min after that for 120 s while the house steps to 16 A | Charge now back at its minimum within 60 s of each restart and held until the next kill or the end; the day's Home use kept within 1 % across the first kill; session energy counts again (**known**: E1); no state.db recovery in the log; fuse holds | | +| `r3-25` Planner floor | 25 A, 15 A per phase, battery at 12 %, planner `soc_min` 10 %, inverter floor 5 %, charger 60 s late, Charge now 16 A for 600 s | the battery reaches the floor (setup); no discharge ordered below it; the measured SoC stays at or above it; the battery order swings at most 100 W over the last 5 min; over the fuse at most the delay + 7 s; fuse holds | | +| `r4-25` Hard kill | 25 A, 12 A per phase, Charge now 16 A; once the day's Home use is known and 25 min more, the battery held at a 3 kW discharge; 5 min later SIGKILL for 20 s, and 2 min after that for 120 s while the house steps to 16 A | Charge now back at its minimum within 60 s of each restart and held until the next kill or the end; the day's Home use kept within 1 % across the first kill; session energy counts again; no state.db recovery in the log; fuse holds | | | `r5a-25` Start beside a charging car | 25 A, 14 A per phase, battery at its 5 % floor, a car charging on its own at 16 A: 30 A per phase for 60 s before Core starts | the car charged and the site was over the fuse (setup); under the fuse within 30 s of Core answering, and over it at most 3 s after; Home use shown within 30 s; fuse holds | | | `r5b-25` Start on a stale plan | a goal 40 → 60 % 8 h out, Core stopped for 20 min | no positive order in the first 30 s before a fresh plan; Home use shown within 30 s; fuse holds. It does not reach D6 on master: the saved plan is younger than 30 min and a fresh one lands within seconds | | | `r5c-25` Start into a new session | Charge now, Core stopped for 10 min while the car leaves and another plugs in | Core sees the car within 60 s; Home use shown within 30 s; fuse holds. Whether Charge now carries over is recorded, not judged: ev-088 waits for the owner | | diff --git a/go/test/site/scenarios_p0_test.go b/go/test/site/scenarios_p0_test.go index a2a68c4cf..571601d0b 100644 --- a/go/test/site/scenarios_p0_test.go +++ b/go/test/site/scenarios_p0_test.go @@ -24,14 +24,9 @@ func init() { scenario{name: "r2-60-25", dir: "r2-battery-offline-slow60-25A", minutes: 10, run: func(c *runCtx) (map[string]any, error) { return r2(c, 25, 60) }, judge: judgeR2}, scenario{name: "r3-25", dir: "r3-planner-floor-25A", minutes: 13, run: func(c *runCtx) (map[string]any, error) { return r3(c, 25) }, - judge: judgeR3, - known: map[string]string{ - "no discharge ordered below the planner floor": "A4: fuse relief discharges to 5 %, below planner.soc_min (dispatch.go:4113-4124, 4495-4499)", - "the battery order swings at most 100 W over the last 5 min": "A3, A4: at the inverter's empty 5 % Core keeps ordering 3.9-5 kW of discharge (the slew step reopens it, dispatch.go:2148-2219)", - }}, - scenario{name: "r4-25", dir: "r4-sigkill-25A", minutes: 40, run: func(c *runCtx) (map[string]any, error) { return r4(c, 25) }, - judge: judgeR4, - known: map[string]string{"session energy counts again after each restart": "E1: a 1.6 transaction is not taken up after a restart, so session_wh stays 0 (handlers.go:615-617, #1787)"}}, + judge: judgeR3}, + scenario{name: "r4-25", dir: "r4-sigkill-25A", minutes: 41, run: func(c *runCtx) (map[string]any, error) { return r4(c, 25) }, + judge: judgeR4}, scenario{name: "r5a-25", dir: "r5a-start-car-charging-25A", minutes: 5, run: func(c *runCtx) (map[string]any, error) { return r5a(c, 25) }, judge: judgeR5a}, scenario{name: "r5b-25", dir: "r5b-start-stale-plan-25A", minutes: 27, run: func(c *runCtx) (map[string]any, error) { return r5b(c, 25) }, @@ -158,7 +153,11 @@ func r3(c *runCtx, fuse float64) (map[string]any, error) { // 20 s; two minutes later the second lasts 120 s, and the house steps to // 16 A per phase while Core is gone. Like s7, the run first waits until the // day's Home use is known, and long enough after it that one restart stays -// under the 10 % unknown-time rule. Core holds a battery for at most +// under the 10 % unknown-time rule. The rule counts whole ledger minutes, +// so a restart whose unknown car reading crosses a minute boundary marks +// two. With the 42 s Core was down or starting, that came to 128 s of a +// 1 054 s day when the hold came 10 min after, and failed 1 run in 5. +// 25 min leaves room for it. Core holds a battery for at most // 1800 s, so the hold starts 300 s before the first kill and outlasts both. func r4(c *runCtx, fuse float64) (map[string]any, error) { var serial string @@ -177,7 +176,7 @@ func r4(c *runCtx, fuse float64) (map[string]any, error) { }) { st.mark("today_known") } - sleepS(600) + sleepS(1500) st.mark("battery_hold") if err := st.post("/api/battery/manual_hold", map[string]any{"direction": "discharge", "power_w": 3000, "hold_s": 1800}, nil); err != nil { return err diff --git a/go/test/site/scenarios_p0_unit_test.go b/go/test/site/scenarios_p0_unit_test.go index dfdb6624c..a23b905ca 100644 --- a/go/test/site/scenarios_p0_unit_test.go +++ b/go/test/site/scenarios_p0_unit_test.go @@ -181,6 +181,16 @@ func TestJudgeR3(t *testing.T) { if v["the battery order swings at most 100 W over the last 5 min"] { t.Fatal("an order hunting between 0 and -3 kW passes") } + // No order at all, as when Core hands the battery back to the inverter, + // while it runs on from 12 % to the inverter's 5 %. + v = run(func(i int, r *sample) { r.Bat.SoCPct = 12 - 7*float64(min(i, 200))/200 }) + if !v["no discharge ordered below the planner floor"] || v["the battery stays at or above the planner floor"] { + t.Fatalf("a battery left to run to 5 %%: %v", v) + } + v = run(func(i int, r *sample) { r.Bat.SoCPct = max(10, soc(i)) }) + if !v["the battery stays at or above the planner floor"] || !v["the battery reached the planner floor"] { + t.Fatalf("a battery held at the floor: %v", v) + } } func TestJudgeR4SessionEnergy(t *testing.T) { diff --git a/go/test/site/verdicts_test.go b/go/test/site/verdicts_test.go index 64d3fbb99..0c1564f97 100644 --- a/go/test/site/verdicts_test.go +++ b/go/test/site/verdicts_test.go @@ -648,7 +648,9 @@ func judgeR2(d *runData) (metrics, []criterion) { } // judgeR3: Core orders no discharge below the planner's floor, its battery -// order settles, and the car answers when the battery stops. +// order settles, and the car answers when the battery stops. The measured +// SoC must hold the floor too: an order of 0 W also reads from a battery +// handed back to the inverter, which would run on to its own 5 %. func judgeR3(d *runData) (metrics, []criterion) { fuse, delay, floor := d.metaF("fuse"), d.metaF("charger_delay_s"), d.metaF("planner_floor_pct") t0, tCN, t1 := d.marks["plug"], d.marks["charge_now"], d.marks["end"] @@ -681,6 +683,7 @@ func judgeR3(d *runData) (metrics, []criterion) { return m, []criterion{ crit("the battery reached the planner floor", len(run) > 0 && minSoC <= floor+0.5), // the setup crit("no discharge ordered below the planner floor", below <= 10), + crit("the battery stays at or above the planner floor", len(run) > 0 && minSoC >= floor-0.5), crit("the battery order swings at most 100 W over the last 5 min", len(last) > 0 && swing <= 100), crit("over the fuse no longer than the charger's delay + 7 s", over <= allowed), crit("fuse holds", !anyBlown(run)),