Files
goodwe-addon/goodwe_controller/test_safety128.py
T

496 lines
23 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 p1, units in ((2.0, ("a", "b")), (2.0, ("a",)), (1.0, ("a", "b")),
(5.0, ("a", "b"))):
house = 1800.0 if len(units) == 2 else 1500.0
for lag, span in CASES:
off = ON + span
seen = []
new = R.Rig(house=house, lag_s=lag, lag_on=ON, lag_off=off,
units=units, p1_s=p1)
new.run(off + 120.0, lambda rig, c, t: seen.append(
(t, c.answer.late_seen)) if c.answer.late_seen else None)
old = R.Rig(house=house, lag_s=lag, lag_on=ON, lag_off=off,
units=units, p1_s=p1)
no_gate(lambda: old.run(off + 120.0))
n = f"N={len(units)}, {p1:.0f} s meter, {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
red = min(r[1] for r in wo) < -1000.0
found = min([t for t, _l in seen] or [None])
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, found late "
f"{None if found is None else found - ON} s after the start, "
f"settled {settled_at(new.rows, off) - off:.0f} s after")
if len(units) == 2 and p1 == 2.0:
check(f"{n}: without the gate the export goes below -1 kW and the "
f"swing grows (red on 77fe31b)",
red and swing(old.rows, half, off) > swing(old.rows, ON, half))
if red:
check(f"{n}: found late while the lag lasts", found is not None
and found < off)
if p1 < 5.0:
# Found within about a minute: at 2 s the brief's bounds; at 1 s
# the swing grows faster before it is found, so twice them.
lim = 250.0 if p1 == 2.0 else 500.0
check(f"{n}: with it the command stays within {lim:.0f} W of the "
f"last good one and no export below -{lim - 50:.0f} W",
all(abs(r[2] - good) <= lim for r in wn)
and min(r[1] for r in wn) >= -(lim - 50.0))
elif red:
# 5 s meter: only the batteries' fit runs (too few meter values),
# so it is found later; the export is cut to under a third.
check(f"{n}: with it the export is under a third of the run "
f"without the gate",
min(r[1] for r in wn) >= min(r[1] for r in wo) / 3.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 ''}")
_ok_texts = {str(messages.row_readings_late(None))} | {
str(messages.row_readings_late(d)) for d in range(20, 33)}
check("found late within 70 s of the lag starting, and an amber row says "
"so in plain words, EN and NL, with the batteries' delay once they have "
"shown it (20-32 s for 30 s late; never a figure from the meter alone)",
first is not None and first - ON <= 70.0
and all(r["warn"] and not r["ok"] for _t, r in rows_seen)
and {str(r["text"]) for _t, r in rows_seen} <= _ok_texts
and rows_seen[-1][1]["text"].nl.startswith(
"Metingen uit Home Assistant tonen vermogensvraag"))
rows5 = []
late5 = R.Rig(lag_s=30.0, lag_on=ON, lag_off=ON + 300.0, p1_s=5.0)
late5.run(ON + 300.0, lambda rig, c, t: rows5.extend(
str(r["text"]) for r in c.checks() if r.get("id") == "late"))
print(f" 5 s meter, 30 s late: {sorted(set(rows5))}")
# SAFETY-129: the 5 s meter's fit finds it first (no figure); the hold then
# leaves the batteries' fit nothing to measure, so the row names no delay.
check("at a 5 s meter the row says the readings are late, with no figure "
"(the meter alone found it)",
rows5 and set(rows5) == {str(messages.row_readings_late(None))})
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))
for on, off in ((45, 60), (30, 30)):
house = (lambda t, on=on, off=off:
400.0 + (2000.0 if t % (on + off) < on else 0.0))
oven = R.Rig(house=house, noise=80.0)
oven.run(1500.0)
oven0 = R.Rig(house=house, noise=80.0)
no_gate(lambda: oven0.run(1500.0))
check(f"an oven cycling 2 kW {on} s on, {off} s off (2 s meter, readings "
f"on time): never taken for late readings ({oven.c.answer.fits} "
"fits), the same commands as without the gate",
oven.c.answer.fits > 100 and not any(
"readings late" in e for e in oven.c.events)
and [r[2] for r in oven.rows] == [r[2] for r in oven0.rows])
# One battery that empties to its cut-out level under an oven (1 s meter):
# its fit lined the old movement up as 85 s late (round 2, before the fix).
emp = R.Rig(house=lambda t: 400.0 + (2000.0 if t % 105 < 45 else 0.0),
noise=150.0, soc=10.6, units=("a",), p1_s=1.0)
emp.run(1800.0)
check("one battery emptying to its cut-out level under a cycling oven (1 s "
"meter): not taken for late readings",
not any("readings late" in e for e in emp.c.events))
emp2 = R.Rig(house=lambda t: 400.0 + (2000.0 if t % 60 < 30 else 0.0),
noise=150.0, soc=10.2, units=("a",), p1_s=2.0)
emp2.run(1800.0)
check("one empty battery under an oven cycling 30/30 s (2 s meter): its "
"requests do not start, but it gives nothing, so that is no evidence "
"for the meter fit",
not any("readings late" in e for e in emp2.c.events))
# The recorded house (2026-09-02, its first 2 hours, modes off) at a 2 s
# meter: the meter fit alone read it as up to 65 s late here (review, B1).
from datetime import datetime as _dt, timezone as _tz # noqa: E402
_tick, rp.TICK = rp.TICK, 2.0
_late = []
try:
_st, _rc = rp.run("modes_off", rp.load_house(["2026-09-02"]),
rp.epex_points(),
_dt.fromisoformat("2026-09-02T00:00:00+02:00")
.astimezone(_tz.utc), 7200.0,
check=lambda c, h, now, pr: _late.append(
c.answer.late_seen))
finally:
rp.TICK = _tick
check(f"the recorded house, 2 hours at a 2 s meter: {_rc.answer.fits} fits, "
"never taken for late readings",
_rc.answer.fits > 100 and not any(_late))
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("late from ON_S 9.5 s, on time again under OFF_S 6.5 s",
(lateness.ON_S, lateness.OFF_S) == (9.5, 6.5))
def fit(delay, *, follows=True, step_every=20, dur=300, tau=0.0, units="ab"):
"""`Delay` on batteries whose readings, published every 10 s at their own
phase, show each request `delay` s late (through a `tau` s lag), or never
(`follows` False)."""
import math
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 + (600.0 if sum(e <= t for e in edges) % 2 else 0.0)
act, shown = 1500.0, {}
for t in range(0, dur):
want = cmd(t - delay) if t >= delay else 1500.0
act = want if not tau else act + (want - act) * (1 - math.exp(-1 / tau))
for k, u in enumerate(units):
if (t + 4 * k) % 10 == 0:
shown[u] = round(act + rng.uniform(-3, 3)) if follows else 900
d.add(float(t), u, cmd(t), shown.get(u))
return d.estimate()
got = {k: fit(k) for k in (0, 6, 10, 30, 60)}
print(f" fitted delays: {got}; one battery {fit(10, units='a')}")
check("the fit finds the readings' delay (0, 6, 10, 30, 60 s) to within 2 s "
"(it reads the request over the 2 s before), also with one battery",
all(v is not None and abs(v - k) <= 2.0 for k, v in got.items())
and abs((fit(10, units="a") or 0.0) - 10.0) <= 2.0)
slow = [fit(0, tau=tau, step_every=s) for tau in (3.0, 10.0, 20.0)
for s in (10, 20, 40)]
check("a battery that does not follow gives no fit, nor does a request that "
f"does not move, and a slow battery (3, 10, 20 s lag) is never late "
f"({slow})",
fit(10, follows=False) is None and fit(10, step_every=10_000) is None
and all((d or 0.0) < lateness.ON_S for d in slow))
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.late_seen = late_seen
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")