Files
goodwe-addon/goodwe_controller/test_safety128.py
T
glenn schrooyenandClaude 09e1fbb898
checks / unit (pull_request) Successful in 19m36s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-128: control does not act on late Home Assistant readings
Readings that Home Assistant delivers late but still changing passed
every staleness gate, so the loop corrected again each cycle an error
its last command had already fixed (10 s late: the give run swung and
exported to -1.9 kW), and the peak guard pushed to the inverter limit
on the same late figures (OPT-34 F-2).

app/lateness.py fits how long after a power request the meter shows
it. Over 8 s the readings are late: a request the battery reading has
not answered holds the loop (it may not lead the reading by more than
100 W) and caps the peak guard at what stands; unanswered for 23 s it
holds, then 0 W after stale_input_s. An amber row and one log line per
episode say so. With readings on time nothing changes (digests equal).

DECISIONS/SAFETY-128-late-readings.md; test_safety128.py.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-11-01 23:45:03 +01:00

409 lines
18 KiB
Python

"""Runnable check for SAFETY-128: control does not act on late readings.
`python3 test_safety128.py`
When Home Assistant runs behind, its readings arrive late but still change, so
every staleness gate passes them. The loop corrected again, each cycle, an
error its last command had already fixed, and the evening give run swung with
growing export (SAFETY-127 rig: 10 s late, commands -450..+3 790 W, grid to
-1 947 W). The peak guard pushed to the inverter limit on the same late figures
(OPT-34 rig F-2). Now a command the battery reading has not answered holds the
loop and caps the peak guard (app/lateness.py), and readings found late hold,
then go to 0 W. DECISIONS/SAFETY-128-late-readings.md.
The REAL Controller on TEST-23's plant (tools/safety128_rig.py). "Without the
gate" is the same run with `Controller._readings_late` returning None, which
is the tree before this card (the rig runs unchanged on 77fe31b).
1. The give run (two batteries, house 1 800 W), readings 10 s late for 150 s
and 30 s late for 300 s: without the gate the swing grows to export below
-1 kW; with it the command stays near the last good one, no export below
-200 W, and it settles once the lag stops. N=1 too.
2. The peak guard in a quarter's last 30 s (a charge at the peak aim, readings
30 s late, the house steps): it does not go to the inverter limit.
3. Seen: the late hold says so on the page (EN and NL) and logs once per
episode.
4. Nothing else moves: no-lag runs at 1, 2 and 5 s meters, a load step on a
battery with a 6 s dead time (SAFETY-75), start-up, a battery at its
cut-out level: within the stated tolerance of the run without the gate.
5. `Answer` and `compute(hold=)` at their edges.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
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 opt11p3_replay as rp # noqa: E402
import safety128_rig as R # noqa: E402
import app.main as app_main # noqa: E402
from app import control, lateness, messages # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def no_gate(fn):
"""`fn()` with the answer gate off: the tree before SAFETY-128."""
real = app_main.Controller._readings_late
app_main.Controller._readings_late = lambda self, standing_w: None
try:
return fn()
finally:
app_main.Controller._readings_late = real
def window(rows, t0, t1):
return [r for r in rows if t0 <= r[0] < t1]
def last_good(rows, t):
"""The site command standing just before `t`."""
return [r for r in rows if r[0] < t][-1][2]
def settled_at(rows, t0, band=150.0):
"""First second from `t0` after which the grid stays within `band` W."""
out = None
for r in rows:
if r[0] < t0:
continue
if abs(r[1]) > band:
out = None
elif out is None:
out = r[0]
return out
def swing(rows, t0, t1):
w = window(rows, t0, t1)
return max(r[1] for r in w) - min(r[1] for r in w)
# --------------------------------------------------------------------------- #
print("1. the give run with readings late")
ON = 300.0
CASES = ((10.0, 150.0), (30.0, 300.0))
for units in (("a", "b"), ("a",)):
house = 1800.0 if len(units) == 2 else 1500.0
for lag, span in CASES:
off = ON + span
new = R.Rig(house=house, lag_s=lag, lag_on=ON, lag_off=off,
units=units)
new.run(off + 120.0)
old = R.Rig(house=house, lag_s=lag, lag_on=ON, lag_off=off,
units=units)
no_gate(lambda: old.run(off + 120.0))
n = f"N={len(units)}, {lag:.0f} s late for {span:.0f} s"
wo, wn = window(old.rows, ON, off), window(new.rows, ON, off)
good = last_good(new.rows, ON)
half = ON + span / 2
print(f" {n}: without the gate cmd {min(r[2] for r in wo):.0f}.."
f"{max(r[2] for r in wo):.0f} W, grid {min(r[1] for r in wo):.0f}"
f"..{max(r[1] for r in wo):.0f} W (swing {swing(old.rows, ON, half):.0f}"
f" then {swing(old.rows, half, off):.0f}); with it cmd "
f"{min(r[2] for r in wn):.0f}..{max(r[2] for r in wn):.0f} W "
f"(last good {good:.0f}), grid {min(r[1] for r in wn):.0f}.."
f"{max(r[1] for r in wn):.0f} W, settled "
f"{settled_at(new.rows, off) - off:.0f} s after")
if len(units) == 2:
check(f"{n}: without the gate the export goes below -1 kW and the "
f"swing grows (red on 77fe31b)",
min(r[1] for r in wo) < -1000.0
and swing(old.rows, half, off) > swing(old.rows, ON, half))
check(f"{n}: with it the command stays within 250 W of the last good "
f"one and no export below -200 W",
all(abs(r[2] - good) <= 250.0 for r in wn)
and min(r[1] for r in wn) >= -200.0)
check(f"{n}: settled within 30 s after the lag stops",
settled_at(new.rows, off) is not None
and settled_at(new.rows, off) - off <= 30.0)
# --------------------------------------------------------------------------- #
print("\n2. the peak guard in a quarter's last 30 s")
# A charge sized to the peak aim (arbitrage on a cheap hour, the shape of the
# owner's night charge), readings 30 s late from 300 s; the house steps
# +2 000 W 100 s before the quarter ends (900 s).
PTS = [(R.BASE + timedelta(hours=h), 3600, 0.02 if h < 2 else 0.40)
for h in range(-3, 30)]
def peak_run(lag, gate=True, step_at=800.0):
seen = []
def sample(rig, c, t):
now = rig.start + timedelta(seconds=t)
if int(t) % 300 == 0:
c.tariff_plan = rp.plan_at(now, PTS)
c.tariff_at = now
g = c.peak_guard
if g is not None and 870.0 <= t < 900.0 and g.engaged:
seen.append((t, g.saturated, float(g.floor_w or 0.0),
float(c.target)))
r = R.Rig(house=lambda t: 1000.0 + (2000.0 if t >= step_at else 0.0),
lag_s=lag, lag_on=ON, lag_off=2000.0, soc=30.0,
extra={"arbitrage_enabled": True,
"cycle_cost_threshold_eur": 0.03})
(lambda f: f() if gate else no_gate(f))(lambda: r.run(900.0, sample))
return seen, r
RATING = sum(R.MAXW.values())
for lag in (10.0, 30.0):
pg_new, _r = peak_run(lag)
pg_old, _r = peak_run(lag, gate=False)
top_new = max([s[2] for s in pg_new] or [0.0])
top_old = max([s[2] for s in pg_old] or [0.0])
print(f" {lag:.0f} s late: the guard's largest ask in the last 30 s "
f"without the gate {top_old:.0f} W ({sum(s[1] for s in pg_old)} "
f"cycles at the limit), with it {top_new:.0f} W "
f"({sum(s[1] for s in pg_new)})")
if lag == 30.0:
check("without the gate the guard goes to the inverter limit on the "
"late projection (red on 77fe31b)",
top_old >= RATING and any(s[1] for s in pg_old))
check(f"{lag:.0f} s late: with it the guard never goes to the limit and "
f"never says it is saturated",
top_new < RATING and not any(s[1] for s in pg_new))
# --------------------------------------------------------------------------- #
print("\n3. the page and the log say so")
rows_seen = []
def watch(rig, c, t):
rows_seen.extend((t, r) for r in c.checks() if r.get("id") == "late")
late = R.Rig(lag_s=30.0, lag_on=ON, lag_off=ON + 300.0)
c = late.run(ON + 300.0 + lateness.FIT_GONE_S + 60.0, watch)
log = [e for e in c.events if "readings late" in e or "readings on time" in e]
n_late = sum(1 for e in log if "readings late" in e)
n_back = sum(1 for e in log if "readings on time" in e)
first = min([t for t, _r in rows_seen] or [None])
lastr = max([t for t, _r in rows_seen] or [None])
print(f" 30 s late for 300 s: {n_late} late episode(s), {n_back} end(s), "
f"the row from {first} to {lastr} s ({len(rows_seen)} s); "
f"{rows_seen[0][1]['text'] if rows_seen else ''}")
_d = c.answer.delay_s
check("found late within a minute of the lag starting, and an amber row says "
"so with the delay it measured, in plain words, EN and NL",
first is not None and first - ON <= 60.0
and all(r["warn"] and not r["ok"] for _t, r in rows_seen)
and str(rows_seen[-1][1]["text"]) in {
str(messages.row_readings_late(d)) for d in range(20, 33)}
and rows_seen[-1][1]["text"].nl.startswith(
"Metingen uit Home Assistant komen zo'n "))
check("logged once for the episode, and once when it ends, not per cycle",
n_late == 1 and n_back == 1)
check("the row goes once a fit shows the readings on time, or 2 minutes "
"after the last fit, at most 160 s after the lag (a steady site shows "
"nothing to fit)",
lastr is not None
and lastr - (ON + 300.0) <= lateness.FIT_S + lateness.FIT_GONE_S
and not [r for r in c.checks() if r.get("id") == "late"])
# The late verdict through the real cycle(): readings found late (30 s), then
# the gate is frozen with its verdict 5 s old (held) or 20 s old (past
# stale_input_s, 15 s).
seen_zero = {}
def verdict(after_s):
def at(rig, c, t):
if t == 500.0:
c.answer.observe = lambda *a, **k: None
c.answer.late_seen, c.answer.late_at = True, c._now_mono - after_s
seen_zero[after_s] = [c.target]
if t == 506.0:
seen_zero[after_s] += [
c.target, c.reason, [r["text"] for r in c.checks()
if r.get("id") == "late"], c.why_words]
v = R.Rig(lag_s=30.0, lag_on=ON, lag_off=2000.0)
v.run(507.0, at)
verdict(5.0)
verdict(20.0)
held, zero = seen_zero[5.0], seen_zero[20.0]
print(f" late verdict 5 s old: {held[:3]}; 20 s old: {zero[:3]}")
check("the late verdict holds the request until stale_input_s, then every "
"inverter goes to 0 W, and the row says so (EN and NL)",
held[1] == held[0] and held[2].startswith("readings late")
and zero[1] == 0.0 and zero[4].startswith(
"Home Assistant readings arrive late: 0 W")
and len(zero[3]) == 1 and "every inverter is at 0 W" in zero[3][0]
and "omvormers op 0 W" in zero[3][0].nl)
on_time = R.Rig(house=lambda t: 600.0 + (2500.0 if t >= 200.0 else 0.0))
c = on_time.run(600.0)
check("readings on time, a load step: no late episode, no row",
not [e for e in c.events if "readings late" in e]
and not c.answer.late_seen)
# --------------------------------------------------------------------------- #
print("\n4. nothing else moves")
STEP = dict(house=lambda t: 600.0 + (2500.0 if t >= 200.0 else 0.0))
def compare(name, tol_w, **kw):
a = R.Rig(**kw)
a.run(500.0)
b = R.Rig(**kw)
no_gate(lambda: b.run(500.0))
diff = max(abs(x[2] - y[2]) for x, y in zip(a.rows, b.rows))
sa, sb = settled_at(a.rows, 200.0), settled_at(b.rows, 200.0)
print(f" {name}: largest command difference {diff:.0f} W, settled "
f"{sa - 200.0 if sa else None} s after the step (without the gate "
f"{sb - 200.0 if sb else None} s)")
return diff, sa, sb, a
for p1 in (1.0, 2.0, 5.0):
for units in (("a", "b"), ("a",)):
d, sa, sb, _a = compare(f"no lag, {p1:.0f} s meter, N={len(units)}, "
f"house 600 -> 3 100 W", 0.0, p1_s=p1,
units=units, **STEP)
check(f"{p1:.0f} s meter, N={len(units)}: settles within 10 s of the "
f"run without the gate",
sa is not None and sb is not None and sa - sb <= 10.0)
d, sa, sb, a75 = compare("SAFETY-75 shape, 6 s dead time", 0.0, dead_s=6.0,
**STEP)
check("6 s dead time (SAFETY-75): the same commands as without the gate, and "
"no late episode (the fit says 5-7 s, under 8 s)",
d == 0.0 and not any("readings late" in e for e in a75.c.events))
d, sa, sb, a0 = compare("start-up, the house at 2 000 W from the first second",
0.0, house=2000.0)
check("start-up: the first 120 s reach the house within 30 s, as without the "
"gate, and no late episode",
settled_at(a0.rows, 0.0) is not None and settled_at(a0.rows, 0.0) <= 30.0
and not any("readings late" in e for e in a0.c.events))
import safety94_rig as s94 # noqa: E402
slow = [(n, tau, tick) for n in (1, 2) for tau in (10.0, 20.0)
for tick in (1.0, 2.0)
if any("readings late" in e for e in s94.run(
n, s94.reversal(300.0, -2500.0, 800.0), 600.0, tau=tau,
tick=tick)["c"].events)]
check("a slow battery (10 or 20 s lag, SAFETY-85/94's rig) reversing out of a "
"charge, readings on time: never taken for late readings, N=1 and N=2, "
f"1 and 2 s meters (taken: {slow})", not slow)
for name, kw in (("at their cut-out level", dict(soc=10.5)),
("A refusing from 200 s",
dict(cut=lambda u, t: u == "a" and t >= 200.0))):
x = R.Rig(house=1800.0, **kw)
x.run(600.0)
y = R.Rig(house=1800.0, **kw)
no_gate(lambda: y.run(600.0))
check(f"readings on time, batteries {name}: not taken for late "
f"readings, the same commands as without the gate",
not any("readings late" in e for e in x.c.events)
and [r[2] for r in x.rows] == [r[2] for r in y.rows])
# --------------------------------------------------------------------------- #
print("\n5. the rules at their edges")
check("LATE_S is SAFETY-75's slowest healthy answer: 6 + 6 + 10 + 1 = 23 s",
lateness.LATE_S == 23.0)
check("ANSWER_W is a probe's answer (100 W)",
lateness.ANSWER_W == control.PROBE_ANSWER_W == 100.0)
check("DELAY_S is the plant's 6 s settle and one 2 s meter tick",
lateness.DELAY_S == 8.0)
def fit(delay, *, follows=True, step_every=20, noise=40.0, dur=300):
"""`Delay` on a meter that shows each command `delay` s later (a house
of 2 000 W with +-`noise`), or never (`follows` False)."""
import random
rng, d = random.Random(7), lateness.Delay()
edges = [0]
while edges[-1] < dur:
edges.append(edges[-1] + step_every * rng.uniform(0.5, 1.5))
cmd = lambda t: 1500.0 + (400.0 if sum(e <= t for e in edges) % 2 else 0.0)
for t in range(0, dur, 2):
shown = cmd(t - delay) if follows and t >= delay else 1500.0
d.add(float(t), cmd(t), 2000.0 - shown + rng.uniform(-noise, noise))
return d.estimate()
got = {k: fit(k) for k in (0, 6, 10, 30, 60)}
print(f" fitted delays: {got}")
check("the fit finds the meter's delay (0, 6, 10, 30, 60 s) to within 1 s",
all(v is not None and abs(v - k) <= 1.0 for k, v in got.items()))
check("a battery that does not follow gives no fit, and so does a command "
"that does not move",
fit(10, follows=False) is None and fit(10, step_every=10_000) is None)
def gate(steps, exempt=(), late_seen=True):
"""[(t, command, battery reading, meter)] through one Answer, its fit
replaced by `late_seen`; after each: (pending, hold, late s, peak cap)."""
a = lateness.Answer()
a.delay.estimate = lambda: (30.0 if late_seen else 0.0)
def one(t, cmd, batt, grid):
a.observe(float(t), cmd, batt, grid_w=grid, exempt=t in exempt)
return (a.ask is not None, a.hold, a.late_s(float(t)),
a.peak_cap(cmd))
return a, [one(*x) for x in steps]
STEP = [(0, 1000, 1000, 500), (2, 1500, 1000, 500)]
HELD = STEP + [(t, 1500, 1000, 500) for t in range(4, 40, 2)]
a, out = gate(HELD, late_seen=False)
check("readings on time: an unanswered ask holds nothing, caps nothing and "
"is never late (a battery at its limit)",
out[-1][0] and all(o[1] == 0 and o[2] is None and o[3] is None
for o in out))
a, out = gate(STEP + [(4, 1500, 1000, 500), (6, 1500, 1450, 60)])
check("readings late: the ask holds the loop up and caps the peak guard at "
"what stands until the battery reading answers",
out[1][1:] == (1, None, 1500.0) and out[2][1:] == (1, None, 1500.0)
and out[3] == (False, 0, None, None))
a, out = gate(HELD)
check("...and with no move towards it for 23 s it is late (control holds, "
"then 0 W)",
[o[2] for o in out].index(0.0) == len([t for t, *_ in HELD
if t - 2 < 23]))
a, out = gate(HELD + [(40, 1500, 1000, 380)])
check("...which ends when a reading starts towards the ask",
out[-2][2] is not None and out[-1][2] is None and out[-1][0])
a, out = gate(HELD, exempt=set(range(2, 40, 2)))
check("exempt (a battery gives nothing under its ask, or refuses): no hold "
"and no late verdict, the cap stays",
all(o[1] == 0 and o[2] is None for o in out) and out[-1][3] == 1500.0)
a, out = gate([(0, 1000, 1000, 500), (2, 1090, 1000, 500),
(4, 1000, 1000, 500)])
check("a move under 100 W is not an ask", not any(o[0] for o in out))
a, out = gate(STEP + [(4, 1050, 1000, 500)])
check("an ask taken back within 100 W of the reading is withdrawn",
out[2][0] is False)
T = control.Tuning(max_w=5000.0)
base = dict(prev_w=1500.0, grid_w=800.0, actual_w=1000.0, tuning=T,
i_w=1500.0)
check("compute(hold=+1): the loop may not push past the standing command, "
"but may come back",
control.compute(**base).target_w > 1500.0
and control.compute(**base, hold=1).target_w == 1500.0
and control.compute(**base, hold=1).reason == control.WAITING
and control.compute(**dict(base, grid_w=-800.0), hold=1).target_w
< 1500.0)
check("compute(hold=-1) mirrors it, and hold=0 is the law as it was",
control.compute(**dict(base, grid_w=-800.0), hold=-1).target_w == 1500.0
and control.compute(**base, hold=0) == control.compute(**base))
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")