"""Runnable check for SAFETY-129: late Home Assistant readings found at a 5 s meter. `python3 test_safety129.py` SAFETY-128 fitted the meter's delay only at a meter of about 3.5 s or faster (16 new values in 60 s). The owner's P1 reports every 5 s, so there only the batteries' fit ran: with readings 30 s late the loop swung to -420 W export and was found after about 3 minutes (-1 325 W before the hold). `lateness.MeterFit` now also runs at a slow meter over 120 s, and calls a delay late only over `M_SLOW_LATE_S` on two fits in a row, with the same corroboration as before (an ask slow to start, no battery fit on time). DECISIONS/SAFETY-128-late-readings.md. The REAL Controller on TEST-23's plant (tools/safety128_rig.py). 1. 5 s meter, readings 30 s late (N=2 house 1 800 W, N=1 house 1 500 W): found within 100 s, and the export never goes below -200 W (red on c66aa7f: found after 190 s, export to -420 W). 2. 5 s meter, readings 10 s late: no swing, nothing to hold; and the limit that goes with it: a battery that answers in 10 s (R2-1) is still not read as late at a 5 s meter, which a lower slow-meter threshold would break. 3. No false holds at a slow meter: the owner's shape at 2, 5 and 10 s, HA restarts and meter pauses at 2 and 5 s, 8-10 s dead times: never late, so the commands are those of the run without the gate. 4. The slow-meter fit at its edges. Mutations (MUTATION.md, purged copies) are listed in the pull request. """ import logging import os import sys 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 safety128_rig as R # noqa: E402 import app.main as app_main # noqa: E402 from app import lateness # 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 run_late(**kw): """Run the rig; (rig, seconds the verdict stood, first second it stood).""" dur = kw.pop("dur") seen = [] r = R.Rig(**kw) r.run(dur, lambda rig, c, t: seen.append(t) if c.answer.late_seen else None) return r, len(seen), min(seen) if seen else None def grid_min(rows, t0, t1): return min(r[1] for r in rows if t0 <= r[0] < t1) AB, A = ("a", "b"), ("a",) ON = 300.0 # --------------------------------------------------------------------------- # print("1. 5 s meter, readings 30 s late for 300 s") for units, house in ((AB, 1800.0), (A, 1500.0)): n = f"N={len(units)}" r, secs, first = run_late(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.0, units=units, p1_s=5.0, dur=ON + 420.0) low = grid_min(r.rows, ON, ON + 300.0) print(f" {n}: found {None if first is None else first - ON:.0f} s " f"after the start, export to {low:.0f} W") check(f"{n}: found within 100 s of the lag starting", first is not None and first - ON <= 100.0) check(f"{n}: the export never goes below -200 W", low >= -200.0) if len(units) == 2: old = R.Rig(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.0, units=units, p1_s=5.0) no_gate(lambda: old.run(ON + 300.0)) check(f"{n}: without the gate the export goes below -1 kW", grid_min(old.rows, ON, ON + 300.0) < -1000.0) # --------------------------------------------------------------------------- # print("\n2. 5 s meter, readings 10 s late; a battery that answers in 10 s") r, secs, first = run_late(house=1800.0, lag_s=10.0, lag_on=ON, lag_off=ON + 150.0, units=AB, p1_s=5.0, dur=ON + 200.0) low = grid_min(r.rows, ON, ON + 150.0) print(f" 10 s late: export to {low:.0f} W, verdict stood {secs} s") check("10 s late: no swing, the export never goes below -200 W", low >= -200.0) def owner(t): w = 400.0 if 600 <= t < 780: w += 2200 if 1200 <= t < 2400 and ((t - 1200) % 105) < 45: w += 2000 return w for dead in (8.0, 10.0): r, secs, first = run_late(house=owner, noise=80.0, units=AB, p1_s=5.0, dead_s=dead, dur=800.0) print(f" {dead:.0f} s dead time, 5 s meter: late {secs} s") check(f"{dead:.0f} s dead time at a 5 s meter is not read as late " f"(R2-1 does not get worse)", secs == 0) # At a 6 s meter an 8 s dead time reads late on the slow fit alone; only the # slow-start and battery-fit check keeps it from a verdict (122 s late # without the check, from 1 084 s). r, secs, first = run_late(house=owner, noise=80.0, units=AB, p1_s=6.0, dead_s=8.0, dur=1500.0) print(f" 8 s dead time, 6 s meter: late {secs} s") check("an 8 s dead time at a 6 s meter is never late (the slow fit needs an " "ask slow to start and no battery fit on time)", secs == 0) # A 12 s dead time is already read as late (R2-1, SAFETY-128); the slow-meter # fit must not make that longer. The same run with the slow fit off is the # tree before this card. _slow = lateness.MeterFit.slow secs = {} for label in ("with", "without"): # 12 s dead time: late from about 140 s lateness.MeterFit.slow = _slow if label == "with" else (lambda self: False) try: secs[label] = run_late(house=owner, noise=80.0, units=A, p1_s=5.0, dead_s=12.0, dur=900.0)[1] finally: lateness.MeterFit.slow = _slow print(f" 12 s dead time, N=1, 5 s meter: late {secs['with']} s, with the " f"slow fit off {secs['without']} s") check("a 12 s dead time at a 5 s meter is late no longer than before " "(R2-1 does not get worse)", secs["with"] <= secs["without"]) # --------------------------------------------------------------------------- # print("\n3. no false holds") _view = R.Plant.view def frozen(windows, meter_only): def view(self, t, lag_s, lag_on, lag_off): cur = _view(self, t, lag_s, lag_on, lag_off) for a, b in windows: if a <= t < b: for row in reversed(self.hist): if row[0] <= a + 1e-9: old = row[1:] return (old[0], old[1], cur[2], cur[3]) \ if meter_only else old return cur return view for p1 in (2.0, 5.0, 10.0): kw = dict(house=owner, noise=80.0, units=AB, p1_s=p1) r, secs, first = run_late(dur=800.0, **kw) new = r old = R.Rig(**kw) no_gate(lambda: old.run(800.0)) same = max(abs(x[2] - y[2]) for x, y in zip(new.rows, old.rows)) print(f" owner shape, {p1:.0f} s meter: late {secs} s, command " f"difference {same:.0f} W") check(f"{p1:.0f} s meter, owner shape: never late, same commands", secs == 0 and same == 0) for label, windows, meter_only in ( ("HA restart", ((650.0, 710.0),), False), ("meter pause", ((640.0, 700.0),), True)): for p1 in (2.0, 5.0): R.Plant.view = frozen(windows, meter_only) try: r, secs, first = run_late(house=owner, noise=80.0, units=AB, p1_s=p1, dur=800.0) finally: R.Plant.view = _view check(f"{label} at a {p1:.0f} s meter: never late", secs == 0) # --------------------------------------------------------------------------- # print("\n4. the slow-meter fit at its edges") def meter_with(period, pause_after=None, pause_s=40.0, n=24): f = lateness.MeterFit() f.add(0.0, 100.0, 0.0) for k in range(1, n): gap = pause_s if pause_after is not None and k > pause_after else 0.0 f.add(period * k + gap, None, float(k)) return f check("a 5 s and a 10 s meter are slow", meter_with(5.0).slow() and meter_with(10.0).slow()) check("a 1 s, a 2 s and a 3 s meter are not slow", not any(meter_with(p, n=60).slow() for p in (1.0, 2.0, 3.0))) check("a pause in a 2 s meter does not make it slow", not meter_with(2.0, pause_after=10, n=40).slow()) def fed(values): """Answer.watch() with the slow fit reading `values`, one per fit.""" a = lateness.Answer() a.meter.slow = lambda: True a.meter.estimate = a.delay.estimate = lambda: None it = iter(values) a.meter_slow.estimate = lambda: next(it, None) for k in range(len(values)): a.watch(2.0 * k, [], 0.0, 0.0) return a check("one late slow-meter fit is no fit; two in a row are one, and " "one on time or in between is none", fed([30.0]).fits == 0 and fed([30.0, 30.0]).fits == 1 and fed([30.0, None, 30.0]).fits == 0 and fed([30.0, 12.0]).fits == 0 and fed([3.0, 3.0]).fits == 0 and not fed([30.0, 30.0]).late_seen) check("the slow fit asks for a longer window and a later delay than the " "fast one", lateness.M_SLOW_FIT_S > lateness.M_FIT_S and lateness.M_SLOW_LATE_S > lateness.M_LATE_S) check("the slow fit's late limit is above the fast one's and above a " "10 s lag", lateness.M_SLOW_LATE_S >= 16.0) print() if fails: print(f"{len(fails)} FAILED: " + ", ".join(fails)) sys.exit(1) print("all checks passed")