SAFETY-97: centre the band on the idle reading clamped to 75 W
checks / unit (pull_request) Successful in 29m26s
infra-pin / fresh (pull_request) Failing after 2s

Owner decision 2026-10-04 (review R1). A wrong idle reading more than
about 100 W off hid every refusal on that battery. Clamped under the band,
the band keeps 0 W +-25 W, so a battery reading about 0 W while refusing is
caught whatever was learned or restored. Samples are still taken to 250 W.
Beyond about +-170 W of idle a battery behaves as in 0.14.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 16:57:16 +02:00
co-authored by Claude Opus 5.5
parent 88ebf3a5a6
commit 7d06aa674d
3 changed files with 103 additions and 31 deletions
@@ -1,8 +1,9 @@
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading # SAFETY-97 - "gives nothing" is graded against each battery's idle reading
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for **Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
review.** Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`). Closes review.** Owner decision 2026-10-04: the band's centre is clamped to
SAFETY-94 review finding F1. +-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`).
Closes SAFETY-94 review finding F1.
## The defect ## The defect
@@ -27,10 +28,11 @@ B sat on the band's edge and never latched:
than 50 W, and while refused it does not learn, so its refusal cannot be than 50 W, and while refused it does not learn, so its refusal cannot be
learned as idle. A capped battery under a big ask is not sampled either. learned as idle. A capped battery under a big ask is not sampled either.
- **How:** the first value is the mean of 120 s of samples; then it follows - **How:** the first value is the mean of 120 s of samples; then it follows
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_MAX_W`) drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`)
is dropped (a register held at a giving value); the value is clamped to it. is dropped (a register held at a giving value). The band is centred on
the value clamped to 75 W (`IDLE_MAX_W`; owner decision below).
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each - **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
5 W move) and restored, clamped. Without it a battery refusing from the 5 W move) and restored, clamped to 250 W. Without it a battery refusing from the
start is never quiet, so never learned, so never caught. start is never quiet, so never learned, so never caught.
- **Until learned the idle reading is 0 W**: the rule as it was. - **Until learned the idle reading is 0 W**: the rule as it was.
@@ -70,16 +72,29 @@ Every test of "gives nothing / gives / takes" now uses `reading - idle`:
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone | | SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) | | SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
## Bounds ## Bounds: owner decision 2026-10-04 (review R1)
- Band: `|reading - idle| < 100 W`, `|idle| <= 250 W`. Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W.
- A correct idle reading: a refusing B behaves as a refusing A.
- A wrong one (worst, 250 W off): a refusing battery reading outside the band - **Why a clamp under the band.** A wrong idle reading (taught during a
is not refused by this rule; a frozen register is still caught by the stale rest while the reading shows something else, e.g. a grid top-up of a unit
gate at 35 s of asking. Learning only at a ~0 W command for 2 min keeps a held at 0 W, or restored onto a swapped inverter) more than about 100 W off
wrong value unlikely. Once learned, a 10 min transient moves it by 28 %; hid every refusal on that battery, at N=1 and N=2; with the first +-250 W
the first value (a 2 min mean) is the exposed one. clamp the rig missed a refusing A whenever the error was 110-250 W. At
- An idle reading beyond 250 W is never learned: such a unit keeps 0 W. +-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W
while refusing is caught whatever was learned or restored, and a wrong idle
cannot refuse a healthy 0 W battery (its ask is over 200 W).
- **The trade-off.** Options: keep +-250 W (covers idle readings to about
+-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen);
"old band or centred band" (rejected: a correct B would need to give 200 W
to answer its 200 W probe).
- **Cost.** A battery whose idle reading is beyond about +-170 W is centred
at most 75 W out, so its refusal falls outside the band: it behaves as in
0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its
clamped centre. B is caught across -150..+90 W of error in its idle
reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2.
- A frozen register is still caught by the stale gate at 35 s of asking.
- Recovery from a wrong value needs quiet time: tau 30 min.
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W) ## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
+12 -8
View File
@@ -9,16 +9,18 @@ Learned per unit, never configured:
- only while control runs, the unit is not refused, stale or tripped, and - only while control runs, the unit is not refused, stale or tripped, and
its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery
is asked more than that, so it cannot teach its refusal as idle); is asked more than that, so it cannot teach its refusal as idle);
- a reading beyond IDLE_MAX_W is not a sample, and the value is clamped to it; - a reading beyond IDLE_SAMPLE_W is not a sample;
- first value: the mean of IDLE_FIRST_S of samples; then a slow follow - first value: the mean of IDLE_FIRST_S of samples; then a slow follow
(IDLE_TAU_S); (IDLE_TAU_S);
- persisted per unit and restored, clamped, at start. - persisted per unit and restored, clamped to IDLE_SAMPLE_W, at start.
Until learned the idle reading is 0 W, the rule as it was. The band is centred on it clamped to IDLE_MAX_W (owner, 2026-10-04). Until
learned the idle reading is 0 W, the rule as it was.
""" """
import math import math
IDLE_MAX_W = 250.0 # |idle| bound: a band centred further out hides too much IDLE_MAX_W = 75.0 # |idle| used: under the 100 W band, so it keeps 0 W +-25 W
IDLE_SAMPLE_W = 250.0 # a reading beyond this is not a sample
IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0" IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0"
IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W
IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value
@@ -41,7 +43,7 @@ class IdleReading:
def __init__(self, saved=None): def __init__(self, saved=None):
v = _num(saved) v = _num(saved)
self.value = (None if v is None self.value = (None if v is None
else max(-IDLE_MAX_W, min(IDLE_MAX_W, v))) else max(-IDLE_SAMPLE_W, min(IDLE_SAMPLE_W, v)))
self.saved = self.value self.saved = self.value
self.quiet_since = None self.quiet_since = None
self.last = None self.last = None
@@ -50,7 +52,10 @@ class IdleReading:
@property @property
def w(self) -> float: def w(self) -> float:
return 0.0 if self.value is None else self.value """The centre used: the learned value clamped to IDLE_MAX_W."""
if self.value is None:
return 0.0
return max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value))
def observe(self, reading, standing_w, now_mono, may_learn) -> bool: def observe(self, reading, standing_w, now_mono, may_learn) -> bool:
"""One read. Returns True when the value should be persisted.""" """One read. Returns True when the value should be persisted."""
@@ -66,7 +71,7 @@ class IdleReading:
self.quiet_since = now self.quiet_since = now
b = _num(reading) b = _num(reading)
if (now - self.quiet_since < IDLE_QUIET_S or b is None if (now - self.quiet_since < IDLE_QUIET_S or b is None
or abs(b) > IDLE_MAX_W or dt <= 0.0): or abs(b) > IDLE_SAMPLE_W or dt <= 0.0):
return False return False
if self.value is None: if self.value is None:
self.acc_w += b * dt self.acc_w += b * dt
@@ -76,7 +81,6 @@ class IdleReading:
self.value = self.acc_w / self.acc_s self.value = self.acc_w / self.acc_s
else: else:
self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S) self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S)
self.value = max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value))
if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W: if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W:
self.saved = self.value self.saved = self.value
return True return True
+62 -9
View File
@@ -169,7 +169,7 @@ def small_ask():
b, b_old = small_ask(), without_fix(small_ask) b, b_old = small_ask(), without_fix(small_ask)
check("SAFETY-42: B giving nothing at a small ask, idle -100 W, is parked " check("SAFETY-42: B giving nothing at a small ask, idle -100 W, is parked "
"(not latched as not delivering in 700 s; with idle 0 W it is)", "(not latched as not delivering in 700 s; with idle 0 W it is)",
not b.delivery_wd.tripped and b.idle_w == -100.0 not b.delivery_wd.tripped and b.idle.value == -100.0
and b_old.delivery_wd.tripped) and b_old.delivery_wd.tripped)
# ============================================================================ # # ============================================================================ #
@@ -213,8 +213,11 @@ check("⭐ a charge reversing into a discharge (lag 10 and 20 s, 1 s meter) "
print("\n3. learning the idle reading") print("\n3. learning the idle reading")
_t, r = caught(2, "b", 1.0) _t, r = caught(2, "b", 1.0)
c = r["c"] c = r["c"]
check("learned during the rest: A about 0 W, B about -100 W, and saved", check("learned during the rest: A about 0 W, B about -100 W (used: -75 W), "
abs(c.inverters[0].idle_w) < 5 and abs(c.inverters[1].idle_w + 100) < 5 "and saved",
abs(c.inverters[0].idle_w) < 5
and abs(c.inverters[1].idle.value + 100) < 5
and c.inverters[1].idle_w == -idle.IDLE_MAX_W
and abs(c.store.data.get(unit_key("inverter-b", "idle_w"), 0) + 100) and abs(c.store.data.get(unit_key("inverter-b", "idle_w"), 0) + 100)
< 5) < 5)
@@ -250,10 +253,11 @@ try:
finally: finally:
rig.Plant = real_plant rig.Plant = real_plant
b = r["c"].inverters[1] b = r["c"].inverters[1]
print(f" refusing B reading -160 W: refused at {_t} s, idle {b.idle_w:.1f}") print(f" refusing B reading -160 W: refused at {_t} s, idle "
f"{b.idle.value:.1f}")
check("⭐ not learned while refused: 800 s held at 0 W reading -160 W leaves " check("⭐ not learned while refused: 800 s held at 0 W reading -160 W leaves "
"B's idle reading at -100 W", "B's idle reading at -100 W",
_t is not None and b.refused and abs(b.idle_w + 100.0) < 1.0) _t is not None and b.refused and abs(b.idle.value + 100.0) < 1.0)
# Not learned while control is stopped (another owner may drive the battery) # Not learned while control is stopped (another owner may drive the battery)
# or while the unit's sign watchdog has latched. # or while the unit's sign watchdog has latched.
@@ -319,12 +323,58 @@ for t in range(0, 600):
check("a reading beyond 250 W is not a sample (-300 W: never learned; a " check("a reading beyond 250 W is not a sample (-300 W: never learned; a "
"register held at 2000 W then -240 W: -240 W)", "register held at 2000 W then -240 W: -240 W)",
o.value is None and p.value is not None and abs(p.value + 240.0) < 1e-6) o.value is None and p.value is not None and abs(p.value + 240.0) < 1e-6)
check("a saved value is clamped to 250 W, and garbage is ignored", check("a saved value is clamped to 250 W, the band's centre to 75 W, and "
idle.IdleReading(900).w == idle.IDLE_MAX_W "garbage is ignored",
idle.IdleReading(900).value == idle.IDLE_SAMPLE_W
and idle.IdleReading(900).w == idle.IDLE_MAX_W
and idle.IdleReading(-900.0).w == -idle.IDLE_MAX_W and idle.IdleReading(-900.0).w == -idle.IDLE_MAX_W
and idle.IdleReading(-60.0).w == -60.0
and idle.IdleReading("x").value is None and idle.IdleReading("x").value is None
and idle.IdleReading(float("nan")).value is None) and idle.IdleReading(float("nan")).value is None)
# ============================================================================ #
print("\n3b. a wrong idle reading (owner, 2026-10-04: centre clamped to 75 W)")
def wrong_idle(n, unit, err, restore):
"""Seconds from the cut to the refusal when the idle reading is `err` W
off: taught wrong during the rest, or restored wrong."""
cu = "a" if n == 1 else unit
true = -100.0 if unit == "b" else 0.0
if restore:
return caught(n, unit, 1.0, store={
unit_key(f"inverter-{cu}", "idle_w"): true + err})[0]
class _W(rig.Plant):
def advance(self, t):
rig.OFFSET[cu] = true + (err if t < REST else 0.0)
super().advance(t)
saved, real = dict(rig.OFFSET), rig.Plant
rig.Plant = _W
try:
return caught(n, unit, 1.0)[0]
finally:
rig.Plant = real
rig.OFFSET.clear()
rig.OFFSET.update(saved)
A_WRONG = {(n, e, rs): wrong_idle(n, "a", e, rs) for n in (1, 2)
for e, rs in ((-250.0, False), (-150.0, False), (150.0, False),
(250.0, False), (-250.0, True), (200.0, True),
(250.0, True))}
print(f" A (reads about 0 W) refusing, idle off by E: {A_WRONG}")
check("⭐ a battery reading about 0 W while refusing is caught whatever idle "
"reading was learned or restored (A, E -250..+250 W, N=1 and N=2)",
all(v is not None and v <= 32.0 for v in A_WRONG.values()))
check("⭐ ...including a wrong saved idle_w of +200 W on A",
all(A_WRONG[(n, 200.0, True)] is not None for n in (1, 2)))
B_WRONG = {(n, e): wrong_idle(n, "b", e, False) for n in (1, 2)
for e in (-150.0, -50.0, 90.0)}
print(f" B refusing, idle taught wrong by E: {B_WRONG}")
check("B is caught across -150..+90 W of error in its idle reading",
all(v is not None and v <= 32.0 for v in B_WRONG.values()))
# ============================================================================ # # ============================================================================ #
print("\n4. the rules at their edges") print("\n4. the rules at their edges")
@@ -378,8 +428,11 @@ check("compute(): a charge refusal ends when the battery takes 100 W more "
and control.compute(actual_w=-205.0, idle_w=-100.0, **args).refused and control.compute(actual_w=-205.0, idle_w=-100.0, **args).refused
== 0 == 0
and control.compute(actual_w=-110.0, **args).refused == 0) and control.compute(actual_w=-110.0, **args).refused == 0)
check("the idle bound is under the band's reach of a real delivery: 250 W", check("the band's centre stays within 75 W of 0 W, so the band keeps 0 W "
idle.IDLE_MAX_W == 250.0 and idle.IDLE_ASK_W < control.PROBE_W) "+-25 W; samples to 250 W",
idle.IDLE_MAX_W == 75.0 and idle.IDLE_SAMPLE_W == 250.0
and control.REFUSED_BELOW_W - idle.IDLE_MAX_W >= 25.0
and idle.IDLE_ASK_W < control.PROBE_W)
print() print()
if fails: if fails: