SAFETY-94: a healthy battery that lags or reverses is not flagged as frozen or refusing
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:
@@ -900,34 +900,51 @@ def _loop_series(day, sig):
|
||||
return out
|
||||
|
||||
|
||||
class _Before94(Freshness):
|
||||
"""The gate before SAFETY-94: the clock ran from the register's last
|
||||
change, not from when the command asked it to move. The history below is
|
||||
about that gate, so it is replayed on it."""
|
||||
|
||||
def obliged_s(self):
|
||||
return self.graded_s(self.last_read)
|
||||
|
||||
|
||||
def _replay16(cls):
|
||||
"""Every committed day at 1 Hz. Returns (trips, waits for the next move)."""
|
||||
waits, trips = [], 0
|
||||
for _day in _loop_days:
|
||||
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
|
||||
if not _b or not _s:
|
||||
continue
|
||||
_moves = [r[0] for i, r in enumerate(_b)
|
||||
if i == 0 or r[1] != _b[i - 1][1]]
|
||||
_f8b, _prev8b = cls(BOUNDS["batt"]), False
|
||||
_ib = _is = 0
|
||||
_t = max(_b[0][0], _s[0][0])
|
||||
_end = min(_b[-1][0], _s[-1][0])
|
||||
while _t <= _end:
|
||||
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
|
||||
_ib += 1
|
||||
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
|
||||
_is += 1
|
||||
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
|
||||
if _f8b.stale and not _prev8b:
|
||||
trips += 1
|
||||
_nxt = next((m for m in _moves if m > _t), None)
|
||||
if _nxt is not None:
|
||||
waits.append(_nxt - _t)
|
||||
_prev8b = _f8b.stale
|
||||
_t += 1.0
|
||||
return trips, sorted(waits)
|
||||
|
||||
|
||||
_t8b = time.monotonic()
|
||||
_waits, _trips8b = [], 0
|
||||
for _day in _loop_days:
|
||||
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
|
||||
if not _b or not _s:
|
||||
continue
|
||||
_moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]]
|
||||
_f8b, _prev8b = Freshness(BOUNDS["batt"]), False
|
||||
_ib = _is = 0
|
||||
_t = max(_b[0][0], _s[0][0])
|
||||
_end = min(_b[-1][0], _s[-1][0])
|
||||
while _t <= _end:
|
||||
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
|
||||
_ib += 1
|
||||
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
|
||||
_is += 1
|
||||
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
|
||||
if _f8b.stale and not _prev8b:
|
||||
_trips8b += 1
|
||||
_nxt = next((m for m in _moves if m > _t), None)
|
||||
if _nxt is not None:
|
||||
_waits.append(_nxt - _t)
|
||||
_prev8b = _f8b.stale
|
||||
_t += 1.0
|
||||
_waits.sort()
|
||||
_trips8b, _waits = _replay16(_Before94)
|
||||
_trips94, _waits94 = _replay16(Freshness)
|
||||
_p50 = nth(_waits, len(_waits) // 2)
|
||||
print(f" {len(_loop_days)} days replayed at 1 Hz in "
|
||||
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips")
|
||||
f"{time.monotonic() - _t8b:.1f}s (twice): {_trips8b} trips before "
|
||||
f"SAFETY-94, {_trips94} after")
|
||||
print(f" wait for the register's next change after a trip: "
|
||||
f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; "
|
||||
f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's "
|
||||
@@ -953,6 +970,17 @@ check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE
|
||||
"is `stale_input_s + release_s` = 32 s whatever the register does",
|
||||
sum(1 for w in _waits if w < 20.0) >= 20
|
||||
and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0)
|
||||
print(f" SAFETY-94: trips answered within 20 s: {sum(1 for w in _waits if w < 20.0)}"
|
||||
f" before, {sum(1 for w in _waits94 if w < 20.0)} after; quickest "
|
||||
f"answer after a trip {nth(_waits94, 0):.1f} s")
|
||||
check("⚠️ SAFETY-94 (SAFETY-58): MOST OF THOSE SHORT WAITS WERE FALSE TRIPS, "
|
||||
"NOT KEEP-ALIVE PULSES. The clock now counts only the time the command "
|
||||
"asked the register to move. The same sixteen days give 59 trips, not "
|
||||
"93, and only one of the 59 is answered within 20 s (21 before): the "
|
||||
"gate no longer judges a register on the first read after a rest",
|
||||
_trips94 == 59 and len(_waits94) == _trips94
|
||||
and sum(1 for w in _waits94 if w < 20.0) == 1
|
||||
and sum(1 for w in _waits if w < 20.0) == 21)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -1017,32 +1045,30 @@ check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
|
||||
"each unit's own command travel and each unit's own number",
|
||||
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
|
||||
|
||||
# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card.
|
||||
# The unchanged clock keeps running while the site rests, so the FIRST read
|
||||
# at which a command travels 250 W after a long quiet already satisfies all
|
||||
# three terms - the register is given no time at all to answer the new
|
||||
# command. Present on the shipped tree; the live capture in part 10 contains
|
||||
# one (10:48:14, cleared one second later).
|
||||
# S5. SAFETY-57's own FALSE POSITIVE, CLOSED BY SAFETY-94 (it was SAFETY-58).
|
||||
# The unchanged clock used to keep running while the site rested, so the
|
||||
# FIRST read at which a command travelled 250 W after a long quiet tripped
|
||||
# the gate. The clock now counts only time the register was asked to move.
|
||||
# Live 2026-10-03: four such trips, each 1 s after the command.
|
||||
_s5 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 300): # five minutes of a resting site
|
||||
_s5.submit(-3.0, 0.0, float(t))
|
||||
_s5_quiet = _s5.stale
|
||||
_s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W
|
||||
check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a "
|
||||
"long rest the unchanged clock is already past 35 s, so the very FIRST "
|
||||
"read carrying a travelled command trips the gate - before the 17.0 s in "
|
||||
"which a healthy register could have answered it. The gate accuses on "
|
||||
"read one and the register answers on read eleven",
|
||||
not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0)
|
||||
check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded "
|
||||
"here rather than fixed here: `cycle()` returns at `inputs missing "
|
||||
"{n}s` for the whole of it, so nothing is commanded differently, and the "
|
||||
"register's next publish clears it. Fixing it means measuring the "
|
||||
"unchanged clock from when the OBLIGATION appeared rather than from the "
|
||||
"register's last move, which re-derives the 35.0 s bound and is a "
|
||||
"different card",
|
||||
(lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5)
|
||||
and float(option_default("stale_input_s")) >= 5.0)
|
||||
_s5_first = _s5.stale
|
||||
for t in range(301, 334):
|
||||
_s5.submit(-3.0, 1500.0, float(t))
|
||||
_s5_before = _s5.stale
|
||||
_s5.submit(-3.0, 1500.0, 334.0)
|
||||
check("⚠️ SURVIVOR 5 IS CLOSED (SAFETY-94): after five minutes of rest the "
|
||||
"first read carrying a travelled command is NOT a trip, nor is any read "
|
||||
"before 35 s of standing command - and a register that still has not "
|
||||
"moved after 35 s of it IS",
|
||||
not _s5_quiet and not _s5_first and not _s5_before and _s5.stale
|
||||
and _s5.graded_s(334.0) == 334.0 and _s5.obliged_s() == 35.0)
|
||||
check("...and the evidence sentence still gives the register's own unchanged "
|
||||
"time (334 s), which an operator compares with a history graph",
|
||||
"unchanged for 334 s" in _s5.reason)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -1353,20 +1379,23 @@ print(f" after : held number reached a consumer on {sum(_exp57)} reads "
|
||||
|
||||
check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register "
|
||||
"that is dead for good is re-graded for ever: over 1201 reads the held "
|
||||
"number reaches a consumer on 630 of them against 35 before, and 17 "
|
||||
"number reaches a consumer on 631 of them against 36 before, and 17 "
|
||||
"releases are taken. This check exists so that cost cannot be quietly "
|
||||
"forgotten, and so a change that makes it worse has to move a number "
|
||||
"somebody reads",
|
||||
sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17
|
||||
sum(_expb) == 36 and sum(_exp57) == 631 and _dead.releases == 17
|
||||
and _deadb.releases == 0)
|
||||
check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER "
|
||||
"CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s "
|
||||
"grading window, and 35 < 75 (circulation_hold_s) < 85 "
|
||||
"CONSUMER'S HOLD. Every unbroken run of exposure after a release is "
|
||||
"exactly one 35.0 s window of standing command (the first is 36 s: the "
|
||||
"fixture's command starts one read late), and 36 < 75 "
|
||||
"(circulation_hold_s) < 85 "
|
||||
"(unit_sign_disagree_s) - so NEITHER detector can reach a verdict inside "
|
||||
"one, and the re-trip that ends each run hands them a None, which CLEARS "
|
||||
"their windows rather than accumulating into them",
|
||||
set(_runs57) == {BOUNDS["batt"].unchanged_s}
|
||||
and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold)
|
||||
set(_runs57[1:]) == {BOUNDS["batt"].unchanged_s}
|
||||
and nth(_runs57, 0) == BOUNDS["batt"].unchanged_s + 1.0
|
||||
and max(_runs57) < _circ_hold < _unit_hold)
|
||||
check("...and nothing SAFETY-37 detects is given back by that, which is the "
|
||||
"half that makes the trade a trade. A permanent None clears both windows "
|
||||
"for ever too, so on the shipped-before module the same two detectors are "
|
||||
@@ -1643,8 +1672,10 @@ check("...and a re-trip's reason names the TWO durations separately, each "
|
||||
[f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)],
|
||||
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
|
||||
f.stale and f.releases == 1
|
||||
and "unchanged for 92 s" in f.reason
|
||||
and "checked 2 times over a 35 s window since the last stop was "
|
||||
# SAFETY-94: the re-trip comes 35 s after the command returns at
|
||||
# 80 s (114 s), not 35 s after the release at 57 s.
|
||||
and "unchanged for 114 s" in f.reason
|
||||
and "checked 2 times over a 57 s window since the last stop was "
|
||||
"withdrawn" in f.reason)[-1])(
|
||||
Freshness(BOUNDS["batt"])))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user