239 lines
9.4 KiB
Python
239 lines
9.4 KiB
Python
"""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")
|