SAFETY-119 round 2: one settle window per run of hand-backs
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:
co-authored by
Claude
parent
b9ff6acea4
commit
e76c70ecb0
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user