From 5c9f64d87a6ba9421cf5219bddd38277f20b2d4c Mon Sep 17 00:00:00 2001 From: SAFETY-84 review Date: Wed, 30 Sep 2026 20:36:46 +0200 Subject: [PATCH] SAFETY-84 review: check the charging pack gives under the guard, and no-P1 at N=2 A 6500 W house needs more than B alone: A must give (the N>=2 suspension), with a non-vacuous run without it (banks over the cap, 'Load too high'). Without P1 the charge is not sized, as at N=1: A charges at 2000 W. Co-Authored-By: Claude Opus 5.5 --- goodwe_controller/test_safety84.py | 55 ++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/goodwe_controller/test_safety84.py b/goodwe_controller/test_safety84.py index 09492fe..fcc0d4e 100644 --- a/goodwe_controller/test_safety84.py +++ b/goodwe_controller/test_safety84.py @@ -12,8 +12,9 @@ DECISIONS/SAFETY-84-charge-at-two.md has the record. 1. N=2 in a cheap window, house below, at and above the charge rate and a solar surplus: the charge runs, no opposite split, no refusal, and it ends. 2. The peak guard: the charge yields just enough to hold the aim (cap - - margin, and a banked month peak - margin); over the aim the guard wins - and nothing parks; no false 'Load too high'. + margin, and a banked month peak - margin); over the aim the guard wins, + the charging battery gives too, and nothing parks; no false 'Load too + high'. Without P1 the charge is not sized, as at N=1. 3. Outside a cheap window nothing changes. 4. A safety stop parks both batteries without a refusal, and the charge resumes after it. @@ -197,6 +198,56 @@ check("non-vacuous: charging at the full rate instead, the guard has to " "step in and the batteries give back on many cycles", _gave > 50) + +def peak_run(house): + rows, c = run(house, dur=1800.0) + return rows, c, [r for r in rows if r[-1][1] and r[-1][1][0]] + + +# A 6500 W house needs 3050 W off the meter: more than B's 2500 W alone. +_p_rows, _p, _p_eng = peak_run(6500.0) +print(f" house 6500: banked {[round(v) for v in banked(_p)]} W, A gives " + f"on {sum(cmd(r)['a'] > 0.0 for r in _p_eng)} of {len(_p_eng)} " + f"engaged cycles") +check("⭐ house 6500 W, more than B alone can cover: the charge is suspended " + "and A, the battery being filled, gives too; the quarter lands on the " + "aim, nothing is refused, no 'Load too high'", + len(_p_eng) > 100 and all(cmd(r)["a"] > 0.0 for r in _p_eng) + and all(v <= AIM + TOL for v in banked(_p)) + and refused(_p_rows) == 0 and not alarmed(_p)) +_real = s84.m.peakguard.shaping_suspended +s84.m.peakguard.shaping_suspended = lambda *a: False +try: + _ns_rows, _ns, _ns_eng = peak_run(6500.0) +finally: + s84.m.peakguard.shaping_suspended = _real +check("non-vacuous: with the suspension taken out, A is held at 0 W, the " + "quarter goes over the cap and 'Load too high' fires", + not any(cmd(r)["a"] > 0.0 for r in _ns_eng) + and max(banked(_ns)) > 3500.0 and alarmed(_ns)) + + +def prices_only(c, t): + """A cheap price and no P1 telegram (`meter_source: off`).""" + if not getattr(c.hass, "_px", False): + read, px = c.hass.number, {"sensor.pnow": 0.05, "sensor.pavg": 0.20} + + async def number(e, invert=False): + return px[e] if e in px else await read(e, invert) + c.hass.number, c.hass._px = number, True + + +_p1 = s84.s79.P1 +s84.s79.P1 = {"meter_source": "off", "peak_cap_w": 3500} +try: + _off_rows, _off = run(800.0, dur=600.0, before=prices_only) +finally: + s84.s79.P1 = _p1 +check("⭐ with no P1 meter nothing measures the quarter and the charge is not " + "sized, as at N=1: A charges at 2000 W on every cycle, nothing refused", + _off.peak_guard is None and all(cmd(r)["a"] == -2000.0 for r in _off_rows) + and refused(_off_rows) == 0) + # ============================================================================ # print("\n3. outside a cheap window nothing changes") _d = {h: run(h, cheap=False, dur=600.0) for h in (800.0, -1500.0)}