"""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, as fast as on 5a4cdc2. 4. peakguard.load_too_high() on its own. Mutations (MUTATION.md, purged copies) are listed in the pull request. """ import os import sys 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.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"), } RUN = {k: sim.run(**v)[:2] for k, v in sim.SCENARIOS.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"): check(f"{k}: none (5a4cdc2: {BASE[k][0]})", RUN[k][0] == []) # ============================================================================ # print("3. real overloads still alarm, as fast as 5a4cdc2") for k in ("step6500", "miss5560"): print(f" {k}: load steps up at 1200 s; alarms at {RUN[k][0]}, " f"5a4cdc2 {BASE[k][0]}") check(f"{k}: the same second as 5a4cdc2, and again only at the next " f"quarter (at most once per quarter)", RUN[k][0] == BASE[k][0]) # By design: until 1306 s this quarter would end at most ALARM_MISS_W over the # real 3500 W cap; 5a4cdc2 alarmed when it crossed 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] == [1306.0, 1801.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)) print() if fails: print(f"FAILED: {len(fails)} check(s)") sys.exit(1) print("all checks passed")