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
This commit is contained in:
co-authored by
Claude
parent
77fe31b161
commit
09e1fbb898
@@ -0,0 +1,408 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user