Files
goodwe-addon/goodwe_controller/test_safety119.py
T
glenn schrooyenandClaude b0c2e5977d
checks / unit (pull_request) Successful in 41m47s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-119: re-pin test_safety84/87 digests (log only); prove it
The re-pinned digests differ only in OUTPUT_CLAMP log rows; every command
and claim is the same as without the fix (test_safety119.py part 5).
DECISIONS: the delay at a 5 s meter and the open swap case.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 15:30:18 +02:00

388 lines
15 KiB
Python

"""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.
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, not lost).
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
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
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, *, 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."""
extra = dict(extra or {})
s84.OPT = dict(OPT0, peak_cap_w=int(CAP), 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:
return s84.run(1, load=load, cheap=cheap,
store=(s79.store_charging(1) if charging
else rig._Store()),
soc=lambda u, t: 60.0, dur=dur, cut=cut,
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):
"""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]
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. the rule at its boundaries")
class _C:
_settling = Controller._settling
def __init__(self):
self._wire_lost, self._handback = False, None
def seq(c, steps):
return [c._settling(o, t) for o, t in steps]
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 window",
_out == [False, True, True, False, True, True]
and _c._handback == (8.0, 2))
_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)
# 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("\n5. 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")