"""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))}") check("at a 5 s meter (the batteries' fit) the row names the delay: about " "20-32 s for 30 s late", rows5 and set(rows5) <= _ok_texts and any("about" in x for x in rows5)) 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")