Files
goodwe-addon/goodwe_controller/test_safety81.py
adminandClaude Opus 5.5 f967073cfe
checks / unit (pull_request) Successful in 20m31s
infra-pin / fresh (pull_request) Successful in 2s
SAFETY-81 review: 10 s hold, ERROR line per episode, more scenarios
- The alarm must hold for peakguard.ALARM_HOLD_S (10 s) on the projection's
  clock. A load that tops cap + battery for ~5 s every 30 s alarmed at every
  quarter start while the quarter landed under the aim (sine4500_fast).
- A cycle with no battery reading leaves the hold alone.
- Notified once per quarter, but the ERROR line is written per episode.
- New sim scenarios (sine4500_fast, twice7000, step6500_gap) with 5a4cdc2
  digests; control still byte-identical. Unit checks for export and the
  hold across a missing reading. test_peakguard's saturated fixture moves
  the hold start back instead of waiting it out.
- DECISIONS and DOCS updated: costs of the hold, tolerance reasoning, the
  double count in control left to a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:58:37 +02:00

172 lines
8.0 KiB
Python

"""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")