From bc6d744c6cfaffe2c46e8c40f79941e3947c0d9c Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Wed, 30 Sep 2026 13:41:47 +0200 Subject: [PATCH] SAFETY-83 review: banked month peak and no false alarm during a drain test_safety83 part 1: under a month peak banked above the limit the drain is lifted to peak - margin, and a drain the guard raises (steady or with 5 s spikes that saturate it) gives no 'Load too high' alarm; the part 3 overload still does. Co-Authored-By: Claude Opus 5.5 --- goodwe_controller/test_safety83.py | 54 ++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/goodwe_controller/test_safety83.py b/goodwe_controller/test_safety83.py index 10c77f1..bd020ff 100644 --- a/goodwe_controller/test_safety83.py +++ b/goodwe_controller/test_safety83.py @@ -9,7 +9,8 @@ harness on a P1 meter). DECISIONS/SAFETY-83-peak-over-drain.md has the record. 1. A house of 5000, 6000 and 7000 W during a drain, limits 2500 and 3500, margins 0 and 50: every quarter banks at or under the aim, the drain keeps - running, and without the fix the same runs go over. + running, no 'Load too high' alarm, and without the fix the same runs go over. + Under a month peak banked above the limit the aim is that peak - margin. 2. The drain never makes the banked quarter worse than no drain, below and above the drain rate; below it the rest is still exported. 3. The drain still ends, and the order holds: a lost reading parks the @@ -35,6 +36,7 @@ logging.disable(logging.WARNING) import opt11_identity as rig # noqa: E402 import safety78_identity as s78 # noqa: E402 import safety79_identity as s79 # noqa: E402 +from app import tariff # noqa: E402 from app.arbiter import (Claim, P_MAINTENANCE, P_PEAK, P_SAFETY, # noqa resolve) from app.main import Controller # noqa: E402 @@ -51,13 +53,19 @@ def check(name, cond): def run(house, cap, margin, drain_w, *, maint=True, dur=2700.0, soc=None, - before=None, store=None, **extra): - """N=1 on a P1 meter; returns (rows, controller, banked quarters).""" + before=None, store=None, month_peak=None, **extra): + """N=1 on a P1 meter; returns (rows, controller, banked quarters). + `month_peak` banks that quarter earlier in the month.""" opts = dict(s79.P1, peak_cap_w=cap, peak_margin_w=margin, **extra) if maint: opts.update(maintenance_enabled=True, maintenance_start_hour=11, maintenance_discharge_w=drain_w) db = os.path.join(tempfile.mkdtemp(prefix="s83-"), "tariff.db") + if month_peak is not None: + tariff.PeakStore(db)._db.execute( + "INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)", + (tariff.month_key(rig.BASE), month_peak, + "2026-09-02T10:00:00+00:00")).connection.commit() real = rig.Controller rig.Controller = functools.partial(real, peak_db_path=db) try: @@ -85,6 +93,11 @@ def cmd(r): return dict(r[2])["a"] +def alarmed(c): + """Did SAFETY-81's 'Load too high' alarm fire on this run?""" + return any("PEAK_GUARD_SATURATED" in e for e in c.events) + + # ============================================================================ # print("1. banked quarters during a drain, N=1") CASES = [(cap, drain, m, h) for cap, drain in ((2500, 2000), (3500, 2500)) @@ -116,6 +129,39 @@ check("non-vacuous: without the fix the same runs bank over the limit " all(max(b.values()) > k[0] + 100.0 for k, (_r, _c, b) in OLD.items() if k[3] - k[1] > k[0]) and round(OLD[(2500, 2000, 50, 5000.0)][2]["11:15"]) == 3000) +# A hob cycling: one 5 s spike to 11000 W every 150 s on a 6000 W house. Each +# spike saturates the guard for one cycle, shorter than ALARM_HOLD_S. +_sat = [] + + +def _spiky_feed(c, t): + s79._feed(c, t) + _sat.append(bool(c.peak_guard is not None and c.peak_guard.saturated)) + + +_k_rows, _k, _kb = run(lambda t: 11000.0 if t % 150 < 5 else 6000.0, + 3500, 50, 2500, before=_spiky_feed) +check("⭐ a drain the guard raises gives no 'Load too high' alarm (SAFETY-81), " + "steady or with 5 s spikes that saturate the guard", + not any(alarmed(c) for _r, c, _b in RES.values()) + and any(_sat) and not alarmed(_k)) + +# Owner (SAFETY-79): under a month peak banked above the limit, the aim is that +# peak - margin, during a drain too. Owner's settings, peak banked at 4500 W. +BANK = {h: (run(h, 3500, 50, 2500, month_peak=4500.0), + without_fix(lambda: run(h, 3500, 50, 2500, month_peak=4500.0))) + for h in (6500.0, 7500.0)} +for h, (new, old) in BANK.items(): + print(f" month peak 4500, house {h:.0f}: banked " + f"{[round(v) for v in new[2].values()]} W (before the fix " + f"{[round(v) for v in old[2].values()]} W)") +check("⭐ under a month peak of 4500 W: a 7500 W house banks at 4450 W " + "(peak - margin, not the 3450 W cap aim), was 5000 W; a 6500 W house " + "stays under the drain alone at 4000 W", + all(4430.0 <= v <= 4450.0 + TOL for v in BANK[7500.0][0][2].values()) + and max(BANK[7500.0][1][2].values()) > 4900.0 + and all(round(v) in (4000, 4014) + for v in BANK[6500.0][0][2].values())) # ============================================================================ # print("\n2. the drain never makes the banked quarter worse, N=1") @@ -177,6 +223,8 @@ check("⭐ the rating caps the floor without a clash: at max_w 3000 the " all(cmd(r) >= 2000.0 for r in _m_rows) and cmd(_m_rows[-1]) == 3000.0 and not any("CONTRADICTION" in (r[6] or "") for r in _m_rows)) +check("...and there, with the battery at its rating and the house still over, " + "the 'Load too high' alarm does fire", alarmed(_m)) # ============================================================================ # print("\n4. only the cycles where the guard wanted more change")