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
409 lines
18 KiB
Python
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")
|