SAFETY-96 review: keep the refusal count over a refused write; test a fast board
checks / unit (pull_request) Successful in 31m53s
infra-pin / fresh (pull_request) Successful in 2s

F1: resetting the count on each refused write hid a battery that really
refuses behind a link refusing 1 write in 3 (never learned; learned at
358 s with the count kept). F2: a board at 0 W before its link is stale
learned no refusal untested (mutation R7 survived); now a 1 s-lag case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-06 05:46:32 +02:00
co-authored by Claude Opus 5.5
parent 88b9cd470a
commit 956711b801
3 changed files with 46 additions and 5 deletions
@@ -35,7 +35,9 @@ take over one slew step a cycle. At a rejoin it enters at its reading.
(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum: (`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum:
it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30) it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30)
is pinned at 0 W and stays in the sum. New: no refusal is learned on a is pinned at 0 W and stays in the sum. New: no refusal is learned on a
battery whose writes are refused; its board going to 0 W is no refusal. battery whose writes are refused; its board going to 0 W is no refusal. Its
count is kept, not reset, so a link that refuses 1 write in 3 does not hide
a battery that really refuses (review).
**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not **Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not
about the remaining battery's own lag): about the remaining battery's own lag):
+2 -1
View File
@@ -1402,7 +1402,8 @@ class Controller:
if inv.rejected_since is not None: if inv.rejected_since is not None:
# SAFETY-96: its writes are refused, so its board may have # SAFETY-96: its writes are refused, so its board may have
# gone to 0 W on its own; that is no refusal by the battery. # gone to 0 W on its own; that is no refusal by the battery.
inv.ref_count = 0 # The count is kept, as across a gap, so a link that refuses
# now and then does not hide a battery that does refuse.
continue continue
sign = self._gave_nothing(inv) sign = self._gave_nothing(inv)
if sign: if sign:
+41 -3
View File
@@ -76,18 +76,22 @@ def without(fn):
def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None, def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None,
soc=None, reject=None, back=None, wd=None, before=None, lag=10.0, soc=None, reject=None, back=None, wd=None, before=None, lag=10.0,
blind=None): blind=None, flaky=None, dead=None):
"""Rows of one run. Writes to the units in `reject` (name -> from s) are """Rows of one run. Writes to the units in `reject` (name -> from s) are
refused from that time until `back` s; the add-on sees the refusal at refused from that time until `back` s; the add-on sees the refusal at
once (a heartbeat write). A refused unit holds its last command, or with once (a heartbeat write). A refused unit holds its last command, or with
`wd` its board goes to 0 W `wd` s after the first refused write. B's `wd` its board goes to 0 W `wd` s after the first refused write. B's
reading is missing in the `blind` (from, to) window. reading is missing in the `blind` (from, to) window. `flaky(u, t)`:
writes to u refused at t, each cycle's verdict seen (a heartbeat).
`dead` (u, from s): u gives nothing from then on, whatever it is told.
Each row: (t, plant commands, add-on commands, loop reason, site target, Each row: (t, plant commands, add-on commands, loop reason, site target,
grid, circulation tripped).""" grid, circulation tripped)."""
reject = reject or {} reject = reject or {}
real_p, real_s, real_tick = rig._Hass.power, rig._Hass.set_number, rig.TICK real_p, real_s, real_tick = rig._Hass.power, rig._Hass.set_number, rig.TICK
def refused(u, t): def refused(u, t):
if flaky is not None:
return flaky(u, t)
return u in reject and t >= reject[u] and (back is None or t < back) return u in reject and t >= reject[u] and (back is None or t < back)
def power(self, u): def power(self, u):
@@ -98,6 +102,8 @@ def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None,
c = self.cmd[u] c = self.cmd[u]
if wd is not None and refused(u, now) and now - reject[u] >= wd: if wd is not None and refused(u, now) and now - reject[u] >= wd:
c = 0.0 c = 0.0
if dead is not None and u == dead[0] and now >= dead[1]:
c = 0.0
d = c - tgt d = c - tgt
tgt += math.copysign(min(abs(d), 80.0), d) tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / lag)) a += (tgt - a) * (1 - math.exp(-0.1 / lag))
@@ -114,7 +120,10 @@ def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None,
def hook(c, t): def hook(c, t):
for inv in c.inverters: for inv in c.inverters:
if refused(inv.name[-1], t) and inv.rejected_since is None: if flaky is not None:
inv.note_write(not flaky(inv.name[-1], t),
rig.BASE + timedelta(seconds=t))
elif refused(inv.name[-1], t) and inv.rejected_since is None:
inv.rejected_since = rig.BASE + timedelta(seconds=t) inv.rejected_since = rig.BASE + timedelta(seconds=t)
if before is not None: if before is not None:
before(c, t) before(c, t)
@@ -323,6 +332,35 @@ for v, w in (STUCK, BOARD):
>= 1000.0) >= 1000.0)
# Review: a battery that does refuse, behind a link that refuses one write
# in three, is still found out (the count is kept over a refused write).
_fl_rows, _fl = run(dur=700.0, dead=("a", 300.0),
flaky=lambda u, t: u == "a" and t >= 300.0
and int(t / 2) % 3 == 0)
_fl_at = next((r[0] for r in _fl_rows if r[2][0] == 0.0 and r[0] > 300.0),
None)
_fl_end = first((r[2] for r in _fl_rows[-1:]), "the last row")
print(f" A gives nothing from 300 s, 1 write in 3 refused: A's share is "
f"taken off at {_fl_at} s; A {_fl.inverters[0].refused}, last "
f"commands {_fl_end}")
check("⭐ a battery that refuses behind a link refusing 1 write in 3: its "
"refusal is still learned and B carries the house",
_fl.inverters[0].refused == 1 and _fl_at is not None
and _fl_at <= 400.0 and present(_fl_end)
and abs(_fl_end[1] - 2410.0) <= 20.0)
# Review: a fast battery whose board goes to 0 W 1 s after the first refused
# write reads ~0 W long before its link is stale (30 s): no refusal either.
_ref5 = []
_e5_rows, _e5 = run(reject={"a": 300.0}, back=420.0, wd=1.0, lag=1.0,
dur=700.0,
before=lambda c, t: _ref5.append(c.inverters[0].refused))
check("fast battery, board 0 W 1 s after the first refused write: no "
"refusal is learned, A is back in the split within 4 s of its link "
"returning",
not any(_ref5) and rejoined(_e5_rows) <= 424.0)
_seen = [] _seen = []