"""Runnable check for SAFETY-81, no false "Load too high for battery" alarms. `python3 test_safety81.py` Drives the REAL Controller through tools/safety81_sim.py (a 1 s cycle on a plant shaped like the SAFETY-79 rig, one sample in ten late). DECISIONS/SAFETY-81-load-too-high.md has the record. 1. Every command is byte-identical to release/1.0 5a4cdc2. 2. No alarm on the quarter-edge spike, the last seconds, noise (margin 50 and 0) or a burst the quarter absorbs. 3. Real overloads still alarm, ALARM_HOLD_S after 5a4cdc2. 4. peakguard.load_too_high() on its own. 5. The hold across a cycle with no battery reading (review). Mutations (MUTATION.md, purged copies) are listed in the pull request. """ import asyncio import os import sys from datetime import datetime, timedelta, timezone from types import SimpleNamespace HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "tools")) import safety81_sim as sim # noqa: E402 from app.main import Controller # noqa: E402 from app.peakguard import load_too_high # noqa: E402 fails = [] def check(name, cond): print(f" {'ok ' if cond else 'FAIL'} {name}") if not cond: fails.append(name) # Printed by `python3 tools/safety81_sim.py` on release/1.0 5a4cdc2 (the tool # copied in, nothing else changed), in the CI image: (alarm times, digest). BASE = { "const4500": ([2699.0], "6ba6b8634f0df9526adc04f03c82db7d083973268deda0d1f07c5519bc2e5e9d"), "const4500_m0": ([2699.0], "2bede28f3ea6ba9294be134c9b9733e43d22a7c24deac3bf8210bbbbe09b2039"), "sine4500": ([2699.0], "bf70c2c23a0eede40c34478d1354b7daa1858e0a0f920693e2dd15fb5046120f"), "edge4600": ([2.0, 902.0, 1802.0, 2699.0], "3cc0c6fec0c70f138530955babc2b416fe7307373037c1b2e927b6ccdaad3a4d"), "edge4600_m0": ([2.0, 902.0, 1802.0, 2699.0], "ba9ba2c042ee42b043315b996ece38b3478dc9866aaf833256c9c29c02f0fe9f"), "burst_end": ([881.0, 1781.0, 2681.0], "5f70e1eb4244820f1d44b494cb80e51f1a4486dee03357c9a8e8f7d371c5d9af"), "noise5000_m50": ([292.0, 2551.0, 2698.0], "48b446e1a78e6d6032b31cb68a4eadb0f74e3aec4cc1e184162e9c65449bfdc5"), "noise5000_m0": ([292.0, 2552.0, 2698.0], "1a0527dee6c2b03dc95a90b4682858c7bf5edf7205c2aced249112f04328c960"), "step6500": ([1201.0, 1801.0], "95e5c90e4a6814526cb4f3bea76286063b449d86a420f888c014531fb6e913a3"), "miss5560": ([1201.0, 1801.0], "cd991cfe1685750598236701a5a2f2f0342d66645609c536d42ab7ba07216e6c"), "ramp6500": ([1296.0, 1801.0], "8db7675535f1a01abf38bd6bfb996a6fe57711d3ecca53541bb1c0870748c46b"), "step7500_reserve": ([], "21eb9c07a4582f47f347084d1c75a28a5d767bc068bc3f74f32433d3239e9ccb"), # Added in review, printed the same way. "sine4500_fast": (["112, about one per 30 s"], "710008fd8f2d33a0e2cbb9a626bedafd6d87bc23712bbc5b74a4af9b3ec21897"), "twice7000": ([1001.0, 1502.0, 2699.0], "1cddda8cf35859efb2ad503f1683f744cb812a6325a390c4fa3df19d717af043"), "step6500_gap": ([1201.0, 1801.0], "95e5c90e4a6814526cb4f3bea76286063b449d86a420f888c014531fb6e913a3"), } OUT = {k: sim.run(**v) for k, v in sim.SCENARIOS.items()} RUN = {k: v[:2] for k, v in OUT.items()} ERR = {k: v[4] for k, v in OUT.items()} # ============================================================================ # print("1. every command byte-identical to release/1.0 5a4cdc2") check("the same scenarios as 5a4cdc2", set(RUN) == set(BASE)) for k, (alarms, digest) in RUN.items(): check(f"{k}: digest {digest[:12]}", digest == BASE[k][1]) # ============================================================================ # print("2. no false alarm (5a4cdc2's are listed)") for k in ("edge4600", "edge4600_m0", "const4500", "const4500_m0", "sine4500", "noise5000_m50", "noise5000_m0", "burst_end", "sine4500_fast"): check(f"{k}: none (5a4cdc2: {BASE[k][0]})", RUN[k][0] == []) # ============================================================================ # print("3. real overloads still alarm, ALARM_HOLD_S after 5a4cdc2") # ALARM_HOLD_S (10 s) plus up to 2 s of late meter samples; at 1800 s the # quarter-edge tick restarts the hold. for k, want in (("step6500", [1211.0, 1813.0]), ("miss5560", [1213.0, 1813.0])): print(f" {k}: load steps up at 1200 s; alarms at {RUN[k][0]}, " f"5a4cdc2 {BASE[k][0]}") check(f"{k}: after the hold, and again only at the next quarter", RUN[k][0] == want) check("step6500_gap: cycles stopped by a lost battery reading do not " "restart the hold", RUN["step6500_gap"][0] == [1215.0, 1813.0]) check("twice7000: notified once in the quarter (5a4cdc2: " f"{BASE['twice7000'][0]})", RUN["twice7000"][0] == [1011.0]) check("twice7000: the ERROR line for both overloads", ERR["twice7000"] == [1011.0, 1512.0]) # By design: until 1306 s (then the hold) this quarter would end at most # ALARM_MISS_W over the real 3500 W cap; 5a4cdc2 alarmed at the aim (cap - 50). print(f" ramp6500: alarms at {RUN['ramp6500'][0]}, " f"5a4cdc2 {BASE['ramp6500'][0]}") check("ramp6500: alarms once the quarter will end over the real cap by " "more than ALARM_MISS_W", RUN["ramp6500"][0] == [1316.0, 1813.0]) check("step7500 under the peak reserve: not this alarm (the guard is not " "allowed to discharge)", RUN["step7500_reserve"][0] == []) # ============================================================================ # print("4. load_too_high()") def proj(elapsed, done_avg, held): """A projection `elapsed` s in, averaging `done_avg` so far.""" r = 900.0 - elapsed return SimpleNamespace(elapsed_s=elapsed, held_w=held, projected_w=(done_avg * elapsed + max(held, 0) * r) / 900.0) def ltl(p, batt, cap=3500.0, rail=2000.0): return load_too_high(p, batt_w=batt, ceiling_w=cap, achievable_w=rail) check("load 6000 W vs 3500 + 2000: yes", ltl(proj(300, 3450, 4000), 2000)) check("already over, the battery can carry the load but not catch up: yes", ltl(proj(450, 7200, 7200), 0, rail=5000)) check("the last second, a 7 W miss (under ALARM_MISS_W): no", not ltl(proj(899, 3509, 2500), 1000)) check("a miss of exactly ALARM_MISS_W: no", not ltl(proj(450, 3520, 3500), 2000)) check("...and 1 W more: yes", ltl(proj(450, 3522, 3500), 2000)) check("over cap + battery, but the quarter lands under the cap: no", not ltl(proj(880, 3450, 4000), 2000)) check("...and when it does not: yes", ltl(proj(700, 3450, 4000), 2000)) check("no battery reading: no", not ltl(proj(300, 3450, 5000), None)) check("no seconds left: no", not ltl(proj(900, 4000, 5000), 2000)) check("a quarter already over is not won back by export: yes", ltl(proj(800, 3950, 1000), 0)) # ============================================================================ # print("5. the hold across a cycle with no battery reading") # A reading the stale detector drops (STALE_READING) reaches the alarm as None # while the cycle still runs. Unknown is not "fine": it must not restart the # hold. Driven on Controller.check_peak_saturated() itself, with stubs. Q0 = datetime(2026, 9, 20, 11, 0, tzinfo=timezone.utc) notes = [] async def _call(domain, service, data): notes.append(data.get("notification_id")) stub = SimpleNamespace( peak_guard=SimpleNamespace(saturated=True, ceiling_w=3500.0, reason="PEAK_GUARD: fixture"), site_rating_w=2000.0, shadow=False, events=[], _peak_batt_prev=None, _peak_alarm_block=None, _peak_high_since=None, _peak_saturated=False, hass=SimpleNamespace(call=_call), log_event=lambda msg, hint=None: None) first = None for sec in range(0, 30): stub.site_batt_w = None if 4 <= sec < 8 else 2000.0 p = proj(300 + sec, 3450, 6000) p.block_start = Q0 stub._peak_proj = p asyncio.run(Controller.check_peak_saturated(stub)) if notes and first is None: first = sec check(f"a 4 s gap in the battery reading: alarm at {first} s, not 18 s", first == 10) print() if fails: print(f"FAILED: {len(fails)} check(s)") sys.exit(1) print("all checks passed")