"""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"), } # OPT-15: with battery modes ON the guard no longer reads "no seconds left" on # a quarter's edge tick (it projects the quarter it is in), so the 1 s 0 W # command at each edge is gone and every run differs from 5a4cdc2's. Printed by # `python3 tools/safety81_sim.py` on the OPT-15 head (BASE above stays as the # reference). Modes OFF stays byte-identical (section 1b). OPT15 = { "const4500": "ed2f4ef9a74172ada2f00707a09ad839743449642f61dc6583ace44bac33caf4", "const4500_m0": "63d4d62643b9d39697b16078603576d180d90090117a497122e77c33f3db2227", "sine4500": "a034af0a896d55552915f1e4816055e26c006d4df0a86d1c9d9c3a625faf747f", "edge4600": "74e03c7182469715c1517765c2f53f1d68d7342aed4a8ad388878eac24964020", "edge4600_m0": "93e4402b6b879a44da5ecc28fb3b51b16c0b0f701396e00a9703803273dbd18e", "burst_end": "8dc4001e06ad8fa562caea02d755227cdfa16396708fa986aef7a5704e22a691", "noise5000_m50": "71e29a970837211835e56636280c29f70d7c2d3f3062cfac2b940ea30fa66424", "noise5000_m0": "a47988b7bbee629a6f8722588f325a4c87d7dcb143bba549b6df452664b30251", "step6500": "a7356fe0f8c00ac4d16dd4f60b054af7336ceb2eedcd76911d30138df4b457b8", "miss5560": "2b0afb600fe1cfd0bfe4b28fbfeeff73fc7e875e3d5f377ae7c2a489e20abf53", "ramp6500": "ae3838aa8f2af9458f73b540eeb76cb5fa25ace0f1ea06df2b4f6d9201907522", "step7500_reserve": "21eb9c07a4582f47f347084d1c75a28a5d767bc068bc3f74f32433d3239e9ccb", "sine4500_fast": "47ed9d02a4f7afbc50905f44519b14364f262ba793aae80f3cd077eb6f600b8b", "twice7000": "b89396022f589bbb90bc592ece121cf2a0bf3a4f04316da36891ac1a2b6ae18e", "step6500_gap": "a7356fe0f8c00ac4d16dd4f60b054af7336ceb2eedcd76911d30138df4b457b8", } 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]} (OPT-15 pin; modes off in 1b)", digest == OPT15[k]) # ============================================================================ # print("1b. battery modes off: every command byte-identical to release/1.0 e3e4ba6") # Printed on e3e4ba6 (this sim copied in, `modes=False`), in the CI image. OFF = { "const4500": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3", "edge4600": "4f8f943f5a4e9b218c2e8bc6a95d20934ec19f1bebfbdc5748ca52e753e5afa0", "step6500": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3", "burst_end": "4ca4ba895afe7dacfe3952743e13d21607e690dfec39029fd5c5745fe03af5d7", "twice7000": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3", "sine4500_fast": "3e41ad417880a4ebb63ae3d6826f413fb779c18dfb677207775b1c980133423f", } for k, want in OFF.items(): digest = sim.run(modes=False, **sim.SCENARIOS[k])[1] check(f"{k} (modes off): digest {digest[:12]}", digest == want) # ============================================================================ # 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 (OPT-15: with modes on, by not grading # a projection that rolled into the new quarter before its first sample). for k, want in (("step6500", [1211.0, 1811.0]), ("miss5560", [1213.0, 1811.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, 1811.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] == [1317.0, 1811.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_rolled=False, _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")