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
+53 -7
View File
@@ -87,6 +87,10 @@ when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
AND |standing command now - standing command when the reading
last changed| >= MOVE_W
SAFETY-94: "unchanged for" counts only the reads at which both command terms
held, so a register that rested is not judged before it could answer.
DECISIONS/SAFETY-94-lagging-battery.md.
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
@@ -216,6 +220,8 @@ change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
property of this battery's firmware. It is not a property of this code, and the
add-on must not depend on it.
SAFETY-94: many were the gate judging a rested register on its first read
(SAFETY-58). Counted from the ask, the same days give 59 trips.
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
exit, not how long the block lasts":
@@ -372,7 +378,9 @@ BOUNDS = {
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
"standing command below 250 W, because at 0 W the register "
"is obliged to nothing and the verdict cannot be tested "
"again from inside the state it creates"),
"again from inside the state it creates. SAFETY-94: the "
"35 s counts only reads at which the command asks the "
"register to move"),
"soc": Bound(
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
unchanged_s=float("inf"),
@@ -416,11 +424,9 @@ class Freshness:
new grading window at that instant. The 1 Hz oscillation the latch exists to
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
no re-trip can land sooner than 35 s after a release, rather than on the
next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
the site rests for longer than the window before it asks for anything, the
window is already old and the re-trip lands on the first read at which the
command travels 250 W again. That is survivor 5's shape, it predates this
card, and its cost is one `stale_input_s` hold.
next read. SAFETY-94: the 35 s counts only while the command asks the
register to move, so a site that rests after a release is not re-tripped
on its first ask.
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
@@ -451,6 +457,11 @@ class Freshness:
# the change-stamps; `_release()` is where they diverge.
self.graded_from = None
self.standing_at_grade = None
# SAFETY-94. Seconds of this window during which the register was
# obliged to move (the command at least `move_w` and `move_w` away
# from `standing_at_grade`), and the instant of the last read.
self.obliged_acc = 0.0
self.last_read = None
# The instant the standing command last FELL below `move_w`, or None
# while an obligation is standing. The release timer runs off this.
self.rested_since = None
@@ -499,6 +510,7 @@ class Freshness:
# it reads in the order the state machine moves - withdraw, then
# re-grade - not because the code depends on it.
self._release(standing_w, now_mono)
self._oblige(standing_w, now_mono)
self._grade(standing_w, now_mono)
def _open_window(self, standing_w, now_mono) -> None:
@@ -510,6 +522,35 @@ class Freshness:
self.graded_from = float(now_mono)
self.standing_at_grade = (None if standing_w is None
else float(standing_w))
# The read that opens a window is never an ask (no travel yet).
self.obliged_acc = 0.0
def _obliged(self, standing_w) -> bool:
"""Is the register asked to move: the command is at least `move_w` and
has travelled `move_w` since the window opened?"""
if standing_w is None or self.standing_at_grade is None:
return False
standing_w = float(standing_w)
return (abs(standing_w) >= self.bound.move_w
and abs(standing_w - self.standing_at_grade)
>= self.bound.move_w)
def _oblige(self, standing_w, now_mono) -> None:
"""SAFETY-94. Count only the time the register was asked to move.
A register that held still while nothing asked it to move has not
failed, so time before the command moved is not evidence. The time
since the previous read counts when the obligation stands at this
read; a command flapping in and out of the obligation still adds up.
"""
now_mono = float(now_mono)
if self.last_read is not None and self._obliged(standing_w):
self.obliged_acc += max(0.0, now_mono - self.last_read)
self.last_read = now_mono
def obliged_s(self) -> float:
"""Seconds of the current window the register was obliged to move."""
return self.obliged_acc
def _release(self, standing_w, now_mono) -> None:
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
@@ -543,7 +584,12 @@ class Freshness:
if self.stale or self.graded_from is None:
return
held_s = self.graded_s(now_mono)
if held_s < self.bound.unchanged_s:
# SAFETY-94 (closes SAFETY-58): the bound counts only the time the
# command asked the register to move, not the time since it last
# changed. A register that rested for minutes was otherwise judged on
# the first read after a new command, before it could answer (live,
# 2026-10-03: 1 s after the command).
if self.obliged_s() < self.bound.unchanged_s:
return
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`