SAFETY-119 round 2: one settle window per run of hand-backs
checks / unit (pull_request) Failing after 42m32s
infra-pin / fresh (pull_request) Successful in 2s

A hand-back used to restart the window, so a guard and a charge taking
turns hid the loop's integrator bound for good (0 warnings in 2 h; 28
before the fix). A run of hand-backs (the wire away less than a settle
between them) now hides the bound for at most 11 bound cycles and 22 s in
all, then it counts. The quarter-edge kick stays hidden. No command or text
changes. Tests for the two reviewer mutants that only unit checks caught
(a restart counted as a hand-back; span only), DECISIONS gives the delay at
2 s, 5 s and 60 s. test_safety87.py:543 shortened.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-20 00:52:24 +02:00
co-authored by Claude
parent b9ff6acea4
commit e76c70ecb0
4 changed files with 269 additions and 44 deletions
@@ -17,21 +17,42 @@ until the plant's settle has passed (`SHORT_CYCLES` cycles and
counts as before. An over-rating (`clamped`, a bug) on a cycle the loop
decided always counts. No command changes, and no text was added or changed.
**One window per run of hand-backs (round 2).** A run is hand-backs with the
wire away less than a settle (12 cycles and 22 s) between them. It ends when
the loop holds the wire, or the wire is away, a whole settle in a row. Each
hand-back restarts the settle, but a run hides the bound for at most 11 bound
cycles and 22 s in all. Round 1 restarted the window at every hand-back, so
a guard and a charge taking turns hid the bound for good (0 warnings in 2 h
where 28 were raised before). Now a bound that stays warns after at most 11
of its cycles, however often the wire changes hands.
**Delay.** The first counted bound cycle, from the hand-back (worst case):
| meter | one hand-back | repeated hand-backs |
|---|---|---|
| 2 s | 22 s | 264 s |
| 5 s (default) | 55 s | 660 s |
| 60 s | 660 s | 7920 s |
One hand-back: 11 cycles, never less than 22 s. Repeated: the worst case is
one bound cycle per turn with the wire away 11 cycles between (22 at 1 s),
so 11 turns. It grows with the meter; it stays under a quarter (900 s) up
to a 5 s meter.
Measured: N=2, 5 s meter, cheap-window charge, no usable forecast, house 50 W
over the 5000 W rating: 14 warnings in 2 h (two in each quarter after the first), 0 with
round 1, 28 without any rule. The cap held.
**Kept.** The loop in charge at its rail warns on the same cycle as before.
A loop still at its rail after a hand-back warns 11 cycles later (at least
22 s): 22 s at a 2 s meter, 55 s at 5 s, 11 min at a 60 s cycle. The start
and a restart after a stop are not hand-backs.
The start and a restart after a stop are not hand-backs. The quarter-edge
kick of `test_safety119.py` part 1 stays hidden (0 warnings in 2 h).
**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ; commands and
claims are the same (`test_safety119.py` part 5).
claims are the same (`test_safety119.py` part 6).
**Open.** Each hand-back restarts the window, so a loop that loses the wire
again within 11 cycles is never counted. Seen in review: N=2 (2 x 2500 W), a
5 s meter, a cheap-window charge with no usable forecast, a house 50-100 W
over the 5000 W rating. The guard and the charge took the wire back at least
every 11 cycles. 0 notifications in 2 h, 28 without this rule. The cap held.
From 200 W over the rating it warns at least once a quarter.
**Not hidden.** A battery that gives less than another owner told it still
alarms through SAFETY-85 rule 2. "Always counts" for an over-rating holds on
cycles the loop decided; on others SAFETY-85 rule 1 already dropped it.
**Not changed.** The kick itself is control behaviour: for a few seconds at
a quarter's edge the loop asks up to its rail. In `test_safety119.py` part 1
+43 -18
View File
@@ -299,6 +299,10 @@ REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
# was told over the same span as above (the plant's measured settle time), so
# a healthy battery still settling on a new command is not short.
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
# SAFETY-119: a run of hand-backs (the wire away less than a settle between
# them) may hide the loop's bound for at most this many cycles and
# SHORT_SPAN_S, in all.
RUN_BOUND_CYCLES = SHORT_CYCLES - 1
def _refused_interval(sign: int) -> tuple:
@@ -1272,10 +1276,13 @@ class Controller:
self._loop_decision = None
self._asked_hist: dict = {}
self._short_run = 0
# SAFETY-119: did another owner set the wire last cycle, and since
# when (monotonic s, owned cycles) has the loop had it back.
# SAFETY-119: did another owner set the wire last cycle; the open run
# (monotonic s and count of the loop's cycles in a row, bound cycles
# hidden, monotonic s of the first); the wire's cycles away in a row
# (first s, count).
self._wire_lost = False
self._handback = None
self._away = None
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
@@ -5818,6 +5825,7 @@ class Controller:
self._short_run = 0
self._asked_hist = {}
self._wire_lost, self._handback = False, None
self._away = None
return
unmet = self.allocation.unmet_w if self.allocation is not None else 0
cut = (resolution.bound_by or "").split("(")[0]
@@ -5855,25 +5863,42 @@ class Controller:
self.loop_clamp_facts = ()
def _settling(self, owns, now) -> bool:
"""SAFETY-119: is the loop within the plant's settle (SHORT_CYCLES
cycles and SHORT_SPAN_S) of taking the wire back from another owner?
Its integrator is re-seeded from that owner's command and kicked by a
meter that does not show it yet, so its bound there is not counted.
DECISIONS/SAFETY-119-bound-after-hand-back.md."""
"""SAFETY-119: does the loop's bound on this cycle count? Not within
a run of hand-backs from another owner: its integrator is re-seeded
from that owner's command and kicked by a meter that does not show it
yet. A run opens at a hand-back and ends when the loop held the wire,
or the wire was away, a whole settle in a row (SHORT_CYCLES cycles
and SHORT_SPAN_S). Each hand-back restarts the settle, but the run
hides the bound for RUN_BOUND_CYCLES cycles and SHORT_SPAN_S in all,
so a bound that stays is counted after that, however often the wire
changes hands. DECISIONS/SAFETY-119-bound-after-hand-back.md."""
def aged(t0, span):
return now is None or t0 is None or now - t0 >= span
if not owns:
self._wire_lost, self._handback = True, None
return False
if self._wire_lost:
self._wire_lost, self._handback = False, (now, 0)
if self._handback is None:
return False
t0, n = self._handback
n += 1
self._handback = (t0, n)
if n >= SHORT_CYCLES and (now is None or t0 is None
or now - t0 >= SHORT_SPAN_S):
self._wire_lost = True
t0, k = self._away or (now, 0)
self._away = (t0, k + 1)
if k + 1 >= SHORT_CYCLES and aged(t0, SHORT_SPAN_S):
self._handback = None
return False
self._away = None
if self._wire_lost:
self._wire_lost = False
self._handback = (now, 0) + (self._handback or (0,) * 4)[2:]
if self._handback is None:
return False
tc, n, hid, tb = self._handback
n += 1
self._handback = (tc, n, hid, tb)
if aged(tc, SHORT_SPAN_S) and n >= SHORT_CYCLES:
self._handback = None
return False
if self.loop_clamped[0] and not self.loop_clamped[2]:
if hid >= RUN_BOUND_CYCLES and aged(tb, SHORT_SPAN_S):
return False
tb = tb if hid else now
self._handback = (tc, n, hid + 1, tb)
return True
async def check_output_clamp(self) -> None:
+197 -19
View File
@@ -9,8 +9,10 @@ It crossed its rail for one or two cycles, the loop's ask beat the one-sided
limit, so the cycle counted as the loop's own and raised
`goodwe_integrator_bound`. The battery gave what it was told. Now the loop's
bound does not count until the plant's settle (SHORT_CYCLES cycles and
SHORT_SPAN_S) has passed since the hand-back.
DECISIONS/SAFETY-119-bound-after-hand-back.md.
SHORT_SPAN_S) has passed since the hand-back. Round 2: one such window per
run of hand-backs, hiding at most RUN_BOUND_CYCLES bound cycles and
SHORT_SPAN_S in all, so a bound that stays warns when the guard and the
charge keep taking turns. DECISIONS/SAFETY-119-bound-after-hand-back.md.
1. The rig's shape (1 s meter that lags the battery, 1 s battery lag, house
800 W, rating 2500 W, limit 2500 W, margin 50, charge 2000 W, no
@@ -18,11 +20,14 @@ DECISIONS/SAFETY-119-bound-after-hand-back.md.
the same as without the fix; without the fix it warns.
2. A genuine integrator bound still warns: the loop in charge with the house
over the rating (at once, as before), and the loop at its rail after a
hand-back (after the settle, not lost).
hand-back (after the settle).
3. With a usable forecast: the same commands and the same alarms as without
the fix.
4. The rule at its boundaries (`Controller._settling`).
5. test_safety84's and test_safety87's re-pinned digests: the same commands
4. Repeated hand-backs (N=2, 5 s meter, the guard and the charge take turns):
it warns at least once per quarter; a start or a blind cycle is not a
hand-back; a slow meter delays by cycles, not seconds.
5. The rule at its boundaries (`Controller._settling`).
6. test_safety84's and test_safety87's re-pinned digests: the same commands
and claims; only OUTPUT_CLAMP log rows differ.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
@@ -126,11 +131,12 @@ def house(seed, base=800.0, step=None):
return load
def run(load, *, dur=7200.0, delay=1, extra=None, cheap=True, cut=None,
charging=True):
"""One N=1 run; `charging=False`: no maintenance, the loop in charge."""
def run(load, *, n=1, dur=7200.0, delay=1, extra=None, cheap=True, cut=None,
charging=True, blind=0.0):
"""One run of `n` inverters of 2500 W; `charging=False`: no maintenance,
the loop in charge; `blind`: no meter reading until that second."""
extra = dict(extra or {})
s84.OPT = dict(OPT0, peak_cap_w=int(CAP), peak_margin_w=50,
s84.OPT = dict(OPT0, peak_cap_w=int(CAP * n), peak_margin_w=50,
maintenance_charge_w=2000)
if not charging:
extra.update(maintenance_enabled=False, peak_cap_w=20000)
@@ -138,10 +144,16 @@ def run(load, *, dur=7200.0, delay=1, extra=None, cheap=True, cut=None,
s84.OPT[k] = extra.pop(k)
DELAY[0] = delay
try:
return s84.run(1, load=load, cheap=cheap,
store=(s79.store_charging(1) if charging
feed = s84.feed(cheap)
def before(c, t):
if t >= blind: # before that: no meter reading
return feed(c, t)
return s84.run(n, load=load, cheap=cheap,
store=(s79.store_charging(n) if charging
else rig._Store()),
soc=lambda u, t: 60.0, dur=dur, cut=cut,
before=before if blind else None,
extra=dict({"max_w": 2500, "slew_w": 0}, **extra))
finally:
s84.OPT = OPT0
@@ -166,12 +178,13 @@ def notes(c):
if i == messages.ID_LOOP_BOUND]
def quarters(c):
def quarters(c, tick=1.0):
"""Each whole quarter's true average import (W)."""
q = {}
for t, g in c.hass.grid_true:
q.setdefault(int(t // QUARTER), []).append(g)
return [sum(v) / len(v) for v in q.values() if len(v) == QUARTER]
return [sum(v) / len(v) for v in q.values()
if len(v) == QUARTER / tick]
def commands(rows):
@@ -272,18 +285,100 @@ check("...the forecast was used (the charge is shaped under -2000 W)",
-2000.0 < min(r[1] for r in _fc[0][:300]) < 0.0)
# ============================================================================ #
print("\n4. the rule at its boundaries")
print("\n4. repeated hand-backs: the guard and the charge take turns")
def round1(self, owns, now):
"""Round 1's rule, kept here to show what it hid: every hand-back
restarts the window, so a loss within the settle never lets it end."""
d = self.__dict__
if not owns:
d["_r1"], d["_r1_n"] = True, None
return False
if d.get("_r1"):
d["_r1"], d["_r1_n"] = False, (now, 0)
if d.get("_r1_n") is None:
return False
t0, n = d["_r1_n"]
d["_r1_n"] = (t0, n + 1)
if n + 1 >= app_main.SHORT_CYCLES and (
now is None or t0 is None or now - t0 >= app_main.SHORT_SPAN_S):
d["_r1_n"] = None
return False
return True
# N=2 (2 x 2500 W), a 5 s meter, a cheap-window charge, no usable forecast,
# the house 50 W over the 5000 W rating from 300 s: the loop never held the
# wire for more than 10 cycles. Round 1: no notification in 2 h.
rig.TICK = 5.0
_nv = run(house(1, step=(300.0, 5050.0)), n=2)
_nv_old = no_fix(lambda: run(house(1, step=(300.0, 5050.0)), n=2))
_nv_r1 = patched(Controller, "_settling", round1)(
lambda: run(house(1, step=(300.0, 5050.0)), n=2))
rig.TICK = 1.0
_per_q = [sum(1 for t in notes(_nv[1]) if int(t // QUARTER) == q)
for q in range(8)]
print(f" N=2, 5 s meter, house 5050 W: notifications {len(notes(_nv[1]))} "
f"(per quarter {_per_q}); round 1's rule {len(notes(_nv_r1[1]))}; "
f"without the fix {len(notes(_nv_old[1]))}; highest quarter "
f"{max(quarters(_nv[1], 5.0)):.0f} W")
check("⭐ it warns at least once in every quarter after the first warning "
"(the guard and the charge keep taking turns)",
len(notes(_nv[1])) >= 7 and all(_per_q[q] >= 1 for q in range(1, 8)))
check("non-vacuous: round 1's rule (a window per hand-back) never warned "
"here, and the fix still hides some of what was raised without it",
notes(_nv_r1[1]) == []
and _nv[1].clamp_wd.bound < _nv_old[1].clamp_wd.bound)
check("...the cap held and every command is the same as without the fix",
len(quarters(_nv[1], 5.0)) == 8
and max(quarters(_nv[1], 5.0)) < 5000.0
and commands(_nv[0]) == commands(_nv_old[0]))
# A start, or a blind cycle, is not a hand-back: the loop at its rail after
# 20 s of not running warns at once (R2: a restart counted as a hand-back).
_bl = run(house(1, base=4000.0), dur=120.0, charging=False, blind=20.0)
_bl_old = no_fix(lambda: run(house(1, base=4000.0), dur=120.0,
charging=False, blind=20.0))
print(f" the loop not running until 20 s, then at its rail: "
f"{notes(_bl[1])}, without the fix {notes(_bl_old[1])}")
check("⭐ a start after a blind spell is not a hand-back: it warns at the "
"same cycle as without the fix",
notes(_bl[1])[:1] and 20.0 <= notes(_bl[1])[0] <= 26.0
and notes(_bl[1]) == notes(_bl_old[1]))
# The settle is SHORT_CYCLES cycles, not only SHORT_SPAN_S (R4): at a 5 s
# meter the loop at its rail after a hand-back warns 11 cycles (55 s) later.
rig.TICK = 5.0
_sl = run(house(1, step=(600.0, -6000.0)), dur=900.0,
extra={"peak_cap_w": 20000})
rig.TICK = 1.0
_sl1 = [t for t in notes(_sl[1]) if t >= 600.0][:1]
print(f" 5 s meter, hand-back to a loop at its rail at 600 s: first "
f"notification {_sl1}")
check("⭐ at a 5 s meter the settle is 11 cycles: the first warning comes "
"55 s after the hand-back, not 22 s",
_sl1 and 600.0 + 11 * 5.0 <= _sl1[0] <= 600.0 + 11 * 5.0 + 10.0)
# ============================================================================ #
print("\n5. the rule at its boundaries")
class _C:
_settling = Controller._settling
def __init__(self):
self._wire_lost, self._handback = False, None
self._wire_lost, self._handback, self._away = False, None, None
self.loop_clamped = (False, "", False)
def seq(c, steps):
return [c._settling(o, t) for o, t in steps]
"""(owned, time[, the loop's bound that cycle]) -> does it count not."""
out = []
for st in steps:
c.loop_clamped = (len(st) > 2 and st[2], "", False)
out.append(c._settling(st[0], st[1]))
return out
N, SPAN = app_main.SHORT_CYCLES, app_main.SHORT_SPAN_S
@@ -303,14 +398,97 @@ check(f"at a 0.5 s meter the span decides: settling until {SPAN:.0f} s",
_c = _C()
_out = seq(_c, [(False, 0.0), (True, 2.0), (True, 4.0), (False, 6.0),
(True, 8.0), (True, 10.0)])
check("losing the wire again restarts the window",
check("losing the wire again restarts the settle (but not the run)",
_out == [False, True, True, False, True, True]
and _c._handback == (8.0, 2))
and _c._handback == (8.0, 2, 0, 0))
_c = _C()
check("no monotonic clock: the cycle count alone decides",
seq(_c, [(False, None)] + [(True, None)] * (N + 1))
== [False] + [True] * (N - 1) + [False] * 2)
# One run of hand-backs hides RUN_BOUND_CYCLES bound cycles and SHORT_SPAN_S,
# however often the wire changes hands. T = the loop's bound that cycle.
M = app_main.RUN_BOUND_CYCLES
def turns(c, tick, owned, lost, periods, first=0.0):
"""A hand-back, then `periods` x (`owned` bound cycles, `lost` cycles
away). Returns (time, hidden) of each owned bound cycle."""
t, out = first, []
for _ in range(lost):
c._settling(False, t)
t += tick
for _ in range(periods):
for _ in range(owned):
c.loop_clamped = (True, "", False)
out.append((t, c._settling(True, t)))
t += tick
for _ in range(lost):
c._settling(False, t)
t += tick
return out, t
_c = _C()
_o, _ = turns(_c, 5.0, 1, 3, 20)
_hid = [h for _t, h in _o]
check(f"at 5 s, one bound cycle per turn: {M} are hidden, the {M + 1}th and "
"every later one counts",
_hid == [True] * M + [False] * (20 - M))
_c = _C()
_o, _ = turns(_c, 1.0, 2, 1, 12)
_first = next(t for t, h in _o if not h)
check(f"at 1 s the span holds too: not counted before {SPAN:.0f} s after "
f"the first hidden one (counted at {_first:.0f} s)",
_o[0][0] + SPAN <= _first <= _o[0][0] + SPAN + 3.0
and sum(h for _t, h in _o) > M)
_c = _C()
_t = 0.0
for _ in range(20): # an over-rating: always counts
for _ in range(3):
_c._settling(False, _t)
_t += 5.0
_c.loop_clamped = (True, "", True)
_c._settling(True, _t)
_t += 5.0
for _ in range(3):
_c._settling(False, _t)
_t += 5.0
_c.loop_clamped = (True, "", False)
check("an over-rating does not use up what the run may hide",
_c._settling(True, _t) is True)
_c = _C()
_o1, _t = turns(_c, 2.0, 1, 3, 20)
for _ in range(N): # the wire away a whole settle
_c._settling(False, _t)
_t += 2.0
_o2 = []
for _ in range(20):
_c.loop_clamped = (True, "", False)
_o2.append(_c._settling(True, _t))
_c._settling(False, _t + 2.0)
_t += 4.0
check("a settle with the wire away ends the run: the next hand-back hides "
f"{M} again",
[h for _t2, h in _o1] == [True] * M + [False] * (20 - M)
and _o2 == [True] * M + [False] * (20 - M))
_c = _C()
_o1, _t = turns(_c, 2.0, 1, 3, 20)
for _ in range(N - 1 - 3): # 3 already: N - 1 away
_c._settling(False, _t)
_t += 2.0
_c.loop_clamped = (True, "", False)
check("...but not a shorter time away: the same run, nothing more hidden",
_c._settling(True, _t) is False)
_c = _C()
_o1, _t = turns(_c, 2.0, 1, 3, 20)
_ = [_c._settling(True, _t + 2.0 * i) for i in range(N + 1)]
_c._settling(False, _t + 40.0)
_c.loop_clamped = (True, "", False)
check("the loop holding the wire a whole settle ends the run too: the next "
"hand-back hides again",
_c._settling(True, _t + 42.0) is True)
# An over-rating (a command over `max_w`) is a bug, never settled away.
_ctl = _hb[1]
_ctl.loop_clamped = (True, "x", True)
@@ -342,7 +520,7 @@ check("a stop clears it: the next start is not a hand-back",
(_ctl._wire_lost, _ctl._handback) == (False, None))
# ============================================================================ #
print("\n5. the pinned digests of test_safety84 and test_safety87: only the "
print("\n6. the pinned digests of test_safety84 and test_safety87: only the "
"log changes")
# Those harnesses run at the 5 s tick with a battery that follows at once.
rig._Hass.power, rig.TICK, s79._feed = _real_power, 5.0, _real_feed
+3 -2
View File
@@ -540,8 +540,9 @@ _now = s87.digests()
for k in PINNED:
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
"log) equals b8ddfd7's (the log as re-pinned by SAFETY-119), under, crossing and above the reserve, in a "
"charge and a HOLD, and under a banked peak",
"log) equals b8ddfd7's (the log as re-pinned by SAFETY-119), under, "
"crossing and above the reserve, in a charge and a HOLD, and under a "
"banked peak",
_now == PINNED)
# ============================================================================ #