496 lines
23 KiB
Python
496 lines
23 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 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))}")
|
|
# SAFETY-129: the 5 s meter's fit finds it first (no figure); the hold then
|
|
# leaves the batteries' fit nothing to measure, so the row names no delay.
|
|
check("at a 5 s meter the row says the readings are late, with no figure "
|
|
"(the meter alone found it)",
|
|
rows5 and set(rows5) == {str(messages.row_readings_late(None))})
|
|
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")
|