"""Runnable check for SAFETY-119: no "Battery is not keeping up" when the loop takes the wire back from another owner. `python3 test_safety119.py` During a maintenance charge with no usable peak forecast, the measured peak guard steps in during a quarter's last seconds and hands back. On the cycle the loop gets the wire back, its integrator is re-seeded from the other owner's command and kicked by a meter reading taken before the battery moved. 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. 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 forecast), 2 h, three seeds: no notification, the cap held, every command 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). 3. With a usable forecast: the same commands and the same alarms as without the fix. 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. """ import logging import math import os import random import sys import time from datetime import timedelta HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) sys.path.insert(0, os.path.join(HERE, "tools")) logging.disable(logging.WARNING) import opt11_identity as rig # noqa: E402 import safety79_identity as s79 # noqa: E402 import safety84_identity as s84 # noqa: E402 import app.main as app_main # noqa: E402 from app import messages # noqa: E402 from app.main import Controller # noqa: E402 from app.p1 import make_sample, split_signed # noqa: E402 from named_absence import first, nth, present # noqa: E402 fails = [] OPT0 = dict(s84.OPT) CAP = 2500.0 QUARTER = 900.0 def check(name, cond): print(f" {'ok ' if cond else 'FAIL'} {name}") if not cond: fails.append(name) async def _call(self, *a): """The rig's Home Assistant, recording each notification (time, id).""" if len(a) >= 3 and a[1] == "create" and isinstance(a[2], dict): self.__dict__.setdefault("notes", []).append( (self.t, a[2].get("notification_id"))) return True def _power(self, u, lag=1.0): """The TEST-23 plant's battery: 800 W/s ramp, 1 s first-order lag.""" st = self.__dict__.setdefault("_plant", {}) a, tgt, tl = st.get(u, (0.0, 0.0, self.t)) for _ in range(int(round((self.t - tl) / 0.1))): d = self.cmd[u] - tgt tgt += math.copysign(min(abs(d), 80.0), d) a += (tgt - a) * (1 - math.exp(-0.1 / lag)) st[u] = (a, tgt, self.t) if self.cut(u, self.t) and self.cmd[u] >= -4.0: return -4.0 return float(round(a)) _real_number, _real_power = rig._Hass.number, rig._Hass.power _real_feed = s79._feed async def _number(self, e, invert=False): """sensor.fc: a usable demand forecast of 800 W (part 3).""" if e == "sensor.fc": return 800.0 return await _real_number(self, e, invert) rig._Hass.call, rig._Hass.power, rig._Hass.number = _call, _power, _number rig.TICK = 1.0 DELAY = [1] def _feed(c, t): """The P1 meter `DELAY` ticks behind the battery, as on the rig (the meter reached HA after the battery reading). Records the true grid.""" h = c.hass.__dict__.setdefault("grid_true", []) h.append((t, c.hass.grid())) g = h[max(0, len(h) - 1 - DELAY[0])][1] imp, exp = split_signed(g) c.p1.submit(make_sample("ha_signed", imp, exp, phases=1, ingest_ts=rig.BASE + timedelta(seconds=t), ingest_mono=time.monotonic())) s79._feed = _feed def house(seed, base=800.0, step=None): """800 W +- 30 W, a new value each second; `step` = (t, W) from then.""" rnd = random.Random(seed) noise = [rnd.uniform(-30.0, 30.0) for _ in range(4000)] def load(t): if step is not None and t >= step[0]: return step[1] return base + noise[int(t) % 4000] return load 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 * n), peak_margin_w=50, maintenance_charge_w=2000) if not charging: extra.update(maintenance_enabled=False, peak_cap_w=20000) for k in [k for k in extra if k in s84.OPT]: s84.OPT[k] = extra.pop(k) DELAY[0] = delay try: 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 def patched(obj, name, value): def wrap(fn): real = getattr(obj, name) setattr(obj, name, value) try: return fn() finally: setattr(obj, name, real) return wrap no_fix = patched(Controller, "_settling", lambda self, owns, now: False) def notes(c): return [t for t, i in getattr(c.hass, "notes", []) if i == messages.ID_LOOP_BOUND] 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 / tick] def commands(rows): return [(r[0], r[1], r[2]) for r in rows] def guard_steps(c): return sum(1 for e in c.events if "PEAK_GUARD:" in e) # ============================================================================ # print("1. a maintenance charge with no forecast, the guard steps in at each " "quarter's end, 2 h") NEW, OLD = {}, {} for seed, delay in ((1, 1), (2, 1), (3, 2)): NEW[seed] = run(house(seed), delay=delay) OLD[seed] = no_fix(lambda: run(house(seed), delay=delay)) print(f" seed {seed} meter {delay} s behind: notifications " f"{notes(NEW[seed][1])} (without the fix {notes(OLD[seed][1])}), " f"highest quarter {max(quarters(NEW[seed][1])):.0f} W, guard " f"step-ins in the last 40 log rows {guard_steps(NEW[seed][1])}") check("⭐ no 'Battery is not keeping up' notification, cycle or episode", all(notes(c) == [] and c.clamp_wd.bound == 0 and c.clamp_wd.bound_episodes == 0 for _r, c in NEW.values())) check("⭐ the peak cap is held: every quarter's average is under 2500 W", all(len(quarters(c)) == 8 and max(quarters(c)) < CAP for _r, c in NEW.values())) check("⭐ control is unchanged: every command the same as without the fix", all(commands(NEW[k][0]) == commands(OLD[k][0]) for k in NEW)) check("...the charge ran and the guard stepped in near each quarter's end", all(any(r[1] == -2000.0 for r in rows) and any( r[-1][1] and r[-1][1][0] and r[0] % QUARTER >= QUARTER - 40 for r in rows) for rows, _c in NEW.values())) check("non-vacuous: without the fix every run warns, at a quarter's edge", all(len(notes(c)) >= 1 for _r, c in OLD.values()) and all(min(t % QUARTER, QUARTER - t % QUARTER) <= 40 for _r, c in OLD.values() for t in notes(c))) # ============================================================================ # print("\n2. a genuine integrator bound still warns") _rail = run(house(1, base=4000.0), dur=600.0, charging=False) _rail_old = no_fix(lambda: run(house(1, base=4000.0), dur=600.0, charging=False)) print(f" loop in charge, house 4000 W over the 2500 W rating: " f"{notes(_rail[1])}, without the fix {notes(_rail_old[1])}") check("⭐ the loop in charge at its rail warns (log and notification), at " "the same cycle as before", len(notes(_rail[1])) >= 1 and notes(_rail[1]) == notes(_rail_old[1]) and _rail[1].clamp_wd.bound == _rail_old[1].clamp_wd.bound and any("OUTPUT_CLAMP episode" in e for e in _rail[1].events)) # The charge sets the wire until 600 s, then a solar surplus of 6000 W: the # loop takes the wire back and stands at its -2500 W rail. _hb = run(house(1, step=(600.0, -6000.0)), dur=1200.0, extra={"peak_cap_w": 20000}) _hb_old = no_fix(lambda: run(house(1, step=(600.0, -6000.0)), dur=1200.0, extra={"peak_cap_w": 20000})) _first = notes(_hb[1])[:1] print(f" hand-back to a loop at its rail at 600 s: first notification " f"{_first}, without the fix {notes(_hb_old[1])[:1]}; command at the " f"end {_hb[0][-1][1]}") check("⭐ the loop at its rail after a hand-back still warns, once the " "settle has passed (12 cycles, 22 s): delay, not loss", _first and 600.0 + app_main.SHORT_SPAN_S <= _first[0] <= 640.0 and _hb[0][-1][1] == -2500.0 and _hb[1].clamp_wd.bound > 100) check("...without the fix it warned at once (non-vacuous)", notes(_hb_old[1])[:1] and notes(_hb_old[1])[0] < 600.0 + app_main.SHORT_SPAN_S) # The same charge with no forecast; at 1795 s, under the guard's step-in at # the quarter's end, the house goes to 4000 W, over the 2500 W rating. _up = run(house(1, step=(1795.0, 4000.0)), dur=1900.0) _up_old = no_fix(lambda: run(house(1, step=(1795.0, 4000.0)), dur=1900.0)) print(f" guard step-in, then the house over the rating from 1795 s: " f"{notes(_up[1])}, without the fix {notes(_up_old[1])}") check("⭐ a genuine bound in the rig's own scenario still warns, within the " "settle of the guard's hand-back", notes(_up[1]) and 1795.0 <= notes(_up[1])[0] <= 1795.0 + 40.0 and any("OUTPUT_CLAMP episode" in e for e in _up[1].events)) # ============================================================================ # print("\n3. with a usable forecast (800 W): unchanged") FC = {"peak_forecast_entity": "sensor.fc"} _fc = run(house(1), extra=FC) _fc_old = no_fix(lambda: run(house(1), extra=FC)) print(f" notifications {notes(_fc[1])} (without the fix " f"{notes(_fc_old[1])}), highest quarter " f"{max(quarters(_fc[1])):.0f} W, charge " f"{min(r[1] for r in _fc[0][:300]):.0f} W") check("⭐ the same commands and the same alarms as without the fix, and " "the cap held", commands(_fc[0]) == commands(_fc_old[0]) and notes(_fc[1]) == notes(_fc_old[1]) and _fc[1].clamp_wd.engagements == _fc_old[1].clamp_wd.engagements and max(quarters(_fc[1])) < CAP) 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. 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, self._away = False, None, None self.loop_clamped = (False, "", False) def seq(c, 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 _c = _C() check("from the start, owned every cycle: never settling (no hand-back)", seq(_c, [(True, float(i)) for i in range(30)]) == [False] * 30) _c = _C() _out = seq(_c, [(False, 0.0)] + [(True, 2.0 * i) for i in range(1, N + 3)]) check(f"after another owner: settling for {N - 1} owned cycles, then not " f"(12 cycles at 2 s span {SPAN:.0f} s)", _out == [False] + [True] * (N - 1) + [False] * 3) _c = _C() _out = seq(_c, [(False, 0.0)] + [(True, 0.5 * i) for i in range(1, 60)]) _end = _out.index(False, 1) check(f"at a 0.5 s meter the span decides: settling until {SPAN:.0f} s", _end == 1 + int(SPAN / 0.5) and all(_out[1:_end])) _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 settle (but not the run)", _out == [False, True, True, False, True, True] 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, _o0 = first(t for t, h in _o if not h), nth(_o, 0)[0] 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)", present(_first, _o0) and _o0 + SPAN <= _first <= _o0 + 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) _ctl._wire_lost, _ctl._handback = True, None class _Res: winner, bound_by, target_w = "loop", "", 0.0 unit_bounds = {} class _D: held, target_w = False, 0.0 _ctl._loop_decision, _ctl.allocation = _D(), None _ctl._loop_alarm_input(_Res()) check("an over-rating on a hand-back cycle still counts (bug=True kept)", _ctl.loop_clamped == (True, "x", True) and _ctl._handback is not None) _ctl.loop_clamped = (True, "y", False) _ctl._loop_alarm_input(_Res()) check("...while the integrator bound on the next cycle is not counted", _ctl.loop_clamped[0] is False) _ctl._wire_lost, _ctl._handback = True, (1.0, 3) _ctl._loop_decision = None # stopped or blind _ctl._loop_alarm_input(_Res()) check("a stop clears it: the next start is not a hand-back", (_ctl._wire_lost, _ctl._handback) == (False, None)) # ============================================================================ # 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 rig._Hass.number = _real_number import safety87_identity as s87 # noqa: E402 RUNS = dict({f"s84 {k}": (lambda v=v: s84.run(**v)) for k, v in s84.SCENARIOS.items()}, **{f"s87 {k}": (lambda v=v: s87_run(v)) for k, v in s87.SCENARIOS.items()}) def s87_run(v): v = dict(v) return s84.run(1, dur=s87.DUR, store=s87.store(v.pop("store", None)), **v) _moved, _gone = [], 0 for k, fn in RUNS.items(): new, old = fn()[0], no_fix(fn)[0] same = all(a[:-1] == b[:-1] and a[-1][:4] == b[-1][:4] for a, b in zip(new, old)) and len(new) == len(old) logs = {e for r in old for e in [r[-1][5]] if e} - { e for r in new for e in [r[-1][5]] if e} _gone += len(logs) if not same or any("OUTPUT_CLAMP episode" not in e for e in logs): _moved.append(k) print(f" {k}: commands and claims " f"{'the same' if same else 'DIFFER'}, log rows differ {len(logs)}, " f"digest {s84.digest(new)[:16]}") check("⭐ in all twelve, every command, loop field, claim, guard decision " "and phase is the same as without the fix; the only log rows that " "differ are OUTPUT_CLAMP rows (gone, or a lower count)", not _moved and _gone > 0) print() if fails: print(f"FAILED: {len(fails)} check(s)") for f in fails: print(f" - {f}") sys.exit(1) print("all SAFETY-119 checks passed")