SAFETY-94: a healthy battery that lags or reverses is not flagged as frozen or refusing
checks / unit (pull_request) Successful in 28m10s
infra-pin / fresh (pull_request) Failing after 2s

The stale-reading gate counted its 35 s from the register's last change, so
after a rest the first read after a new command tripped it (SAFETY-58; live
2026-10-03, four trips 1 s after the command). It now counts only the time
the command asked the register to move.

At N=1 the refusal engaged after 12 frozen cycles if the reading was under
100 W at that moment, which a battery reversing through its lag passes at a
1 s meter. The refusal now needs the N>1 evidence at every roster size: under
100 W, one way, for 12 cycles spanning 22 s, restarting when the reading
moves 50 W towards the ask.

DECISIONS/SAFETY-94-lagging-battery.md; test_safety94.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 00:15:42 +02:00
co-authored by Claude Opus 5.5
parent 02ed1200dc
commit 6759d21ea5
11 changed files with 922 additions and 93 deletions
+7 -4
View File
@@ -439,7 +439,7 @@ class _Hass:
return True
def wired(grid, cycles=40, batt=0.0, track=True, **over):
def wired(grid, cycles=40, batt=0.0, track=True, tick=None, **over):
async def go():
hass = _Hass(grid, batt=batt, track=track)
c = Controller(dict(
@@ -455,8 +455,11 @@ def wired(grid, cycles=40, batt=0.0, track=True, **over):
# hysteresis clears it a few cycles later - so it is captured live
# rather than looked for after the run, when it is correctly gone.
c.red_snapshot = []
for _ in range(cycles):
await c.read_inputs()
for i in range(cycles):
# `tick`: simulated seconds per cycle. SAFETY-94: a refusal needs
# its evidence to span REFUSED_SPAN_S, which back-to-back cycles
# on the real clock never do.
await c.read_inputs(now_mono=None if tick is None else i * tick)
await c.cycle()
if c.clamp_wd.active and not c.red_snapshot:
c.red_snapshot = c.checks()
@@ -474,7 +477,7 @@ def clamp_notes(hass):
# unclamped integrator climbed ~130 W per 4 s to over 10 kW and, pinned at the
# 5 kW rail, reported 14 768 W." `track=False` is the inverter switched off: it
# reports 0 W however hard it is commanded, so the error never closes.
hass_r, ctl_r = wired(6000.0, cycles=60, track=False)
hass_r, ctl_r = wired(6000.0, cycles=60, track=False, tick=2.0)
emitted = [abs(w) for w in hass_r.writes]
# What the SAME error would have done to an accumulator with no bound - the