A meter reading late near a quarter's end left seconds the projection did not count (SAFETY-99), and charge_headroom_w() read them as room: the cheap run (and price trading, and the N=2 maintenance charge) went back to full power in the quarter's last seconds and the quarter could land over a banked month peak or the cap. peakguard.charge_room_w() counts those seconds at the held reading for the charge only; the guard's floor is unchanged. The guard row said "idle, heading for 5 814 W, under this month's 5 413 W peak". Over the limit at the current rate the row and the month-peak line now say so, and that the grid charge is cut back. Version 0.19.2, CHANGELOG, DECISIONS. test_safety125.py new; the lag pins in test_price_run_fixes.py and test_opt20.py and the AST pin in test_arbitrage.py follow the change (see the pull request). Co-Authored-By: Claude <noreply@anthropic.com>
410 lines
18 KiB
Python
410 lines
18 KiB
Python
"""Runnable check for SAFETY-125. `python3 test_safety125.py`
|
|
|
|
A meter reading that is late near a quarter's end left seconds the
|
|
projection did not count (SAFETY-99), and `charge_headroom_w()` read them as
|
|
room: a grid charge went back to full power in the quarter's last seconds and
|
|
the billed quarter went over the aim, above a banked month peak or the cap.
|
|
And the peak guard's row said "under" while the period was over the limit at
|
|
the current rate. DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md.
|
|
|
|
Drives the REAL Controller (read_inputs + cycle, the tariff task on a
|
|
day/night profile, a P1 meter on a simulated clock) in the live shape: modes
|
|
on, inverter-a Cheap hours at 5000 W charging harder than asked (as it did
|
|
live), inverter-b Solar first, a house of 1 kW, cap 3500 W, margin 50 W.
|
|
|
|
1. `charge_room_w()` on its own.
|
|
2. A reading 30 s late at each quarter's end: every quarter at or under the
|
|
aim, with a month peak banked above the cap and with none, with the P1
|
|
projection and with a `peak_forecast_entity`; the charge is cut late in
|
|
the quarter and back in the next one.
|
|
3. The same for a price trade with modes off, and a maintenance charge at N=2.
|
|
4. A healthy meter: the quarters as before, and the row and the month-peak
|
|
line say what is true.
|
|
5. The words on their own.
|
|
|
|
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import types
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
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 opt11_identity as rig # noqa: E402
|
|
import opt11p3_replay as rp # noqa: E402
|
|
import safety84_identity as s84 # noqa: E402
|
|
import app.main as m # noqa: E402
|
|
import app.p1 as p1mod # noqa: E402
|
|
from named_absence import first, present # noqa: E402
|
|
from app import homeview, messages, peakguard, tariff # noqa: E402
|
|
from app import price as _price, static_tariff as _st # noqa: E402
|
|
from app.main import Controller # noqa: E402
|
|
from app.p1 import make_sample, split_signed # noqa: E402
|
|
from app.store import unit_key # noqa: E402
|
|
from app.tariff import QUARTER_S, Projection # noqa: E402
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
PEAK = 5413.17 # October's banked peak on the live site
|
|
AIM_PEAK = PEAK - 50.0
|
|
AIM_CAP = 3500.0 - 50.0
|
|
TOL = 10.0 # W over the aim a quarter may land (SAFETY-81)
|
|
FC = "sensor.piek_prognose_kwartier"
|
|
L = timezone(timedelta(hours=1))
|
|
START = datetime(2026, 10, 29, 0, 0, tzinfo=L).astimezone(timezone.utc)
|
|
PROFILE = ('{"periods": ['
|
|
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
|
' "eur_per_kwh": 0.2333},'
|
|
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
|
' "eur_per_kwh": 0.2801}]}')
|
|
|
|
|
|
def opts(*, fc=False, modes=True, arb=False):
|
|
o = dict(rp.options(modes, arb, 2))
|
|
o.update(cycle_cost_threshold_eur=0.03, static_profile=PROFILE,
|
|
peak_cap_w=3500, peak_margin_w=50)
|
|
o["inverters"][0].update(max_w=5000, capacity_kwh=15.36)
|
|
o["inverters"][1].update(max_w=4600, capacity_kwh=20.48)
|
|
if fc:
|
|
o["peak_forecast_entity"] = FC
|
|
return o
|
|
|
|
|
|
class Harder(rp.Plant):
|
|
"""Charges harder than asked, as inverter-a did live (asked -4271 W,
|
|
measured about -4750 W; asked -5000 W, about -5210 W)."""
|
|
|
|
def step(self, dt, floor):
|
|
c = self.cmd
|
|
if c < 0:
|
|
self.cmd = max(c * 1.11, -5216.0)
|
|
try:
|
|
super().step(dt, floor)
|
|
finally:
|
|
self.cmd = c
|
|
|
|
|
|
async def _no_fetch(*a, **k):
|
|
raise AssertionError("the tariff task reached for a price feed")
|
|
|
|
|
|
def drive(o, *, peak=None, late=0.0, minutes=31, every=0):
|
|
"""The real Controller from 00:00 local for `minutes` at a 1 s cycle
|
|
(OPT-15's lag). `late`: no P1 telegram in each quarter's last `late` s.
|
|
-> (controller, rows, {quarter start: true grid average}, samples), the
|
|
samples taken every `every` cycles from the second quarter (the first
|
|
starts before the first reading): (projection, ceiling, engaged,
|
|
peak_guard row, month-peak line)."""
|
|
store = rp._Store()
|
|
for u, mode in (("a", "cheap_hours"), ("b", "solar_first")):
|
|
store.data[unit_key(f"inverter-{u}", "pack_use")] = True
|
|
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
|
|
store.data[unit_key(f"inverter-{u}", "pack_sell")] = False
|
|
rows, samples, sums = [], [], {}
|
|
|
|
async def go():
|
|
lag = rp.Lag()
|
|
hass = rp.Hass(("a", "b"), lambda t: 1000.0, {"a": 11.0, "b": 10.0},
|
|
limits={"a": (-5000.0, 5000.0),
|
|
"b": (-4600.0, 4600.0)}, lag=lag)
|
|
hass.plants["a"].__class__ = Harder
|
|
fc_kw = [0.0]
|
|
|
|
async def state(e):
|
|
# The HA quarter projection, in kW like the live template.
|
|
return None if e != FC else {
|
|
"state": f"{fc_kw[0]:.3f}",
|
|
"attributes": {"unit_of_measurement": "kW"}}
|
|
hass.state = state
|
|
tmp = tempfile.mkdtemp(prefix="s125-")
|
|
db = os.path.join(tmp, "tariff.db")
|
|
if peak is not None:
|
|
tariff.PeakStore(db)._db.execute(
|
|
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
|
|
(tariff.month_key(START), peak,
|
|
"2026-10-21T20:15:00+00:00")).connection.commit()
|
|
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
|
|
peak_db_path=db,
|
|
price_history_path=os.path.join(tmp, "ph.json"))
|
|
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
|
|
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
|
|
c.auto = True
|
|
real = m.datetime, homeview.datetime, p1mod.time
|
|
clock, simt = [START], [0.0]
|
|
|
|
class _DT(real[0]):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return clock[0] if tz is None else clock[0].astimezone(tz)
|
|
m.datetime = homeview.datetime = _DT
|
|
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
|
|
real_load = _price.load_prices, _st.load_prices
|
|
_price.load_prices = _st.load_prices = _no_fetch
|
|
task = asyncio.create_task(c.run_tariff_refresh())
|
|
try:
|
|
hass.advance(0.0, 10.0)
|
|
t, n = 0.0, 0
|
|
while t < minutes * 60:
|
|
now = START + timedelta(seconds=t)
|
|
hass.t, simt[0], clock[0] = t, t, now
|
|
if t == 0.0:
|
|
for _ in range(500):
|
|
if c.tariff_plan is not None:
|
|
break
|
|
await asyncio.sleep(0)
|
|
q = int(now.timestamp() // QUARTER_S)
|
|
acc = sums.setdefault(q, [0.0, 0])
|
|
g_now = hass.grid()
|
|
fc_kw[0] = (acc[0] + g_now * (QUARTER_S - acc[1])) \
|
|
/ QUARTER_S / 1000.0
|
|
if now.timestamp() % QUARTER_S < QUARTER_S - late \
|
|
and t >= lag.p1_s:
|
|
imp, exp = split_signed(hass._past(lag.meter_s
|
|
+ lag.p1_s)[0])
|
|
c.p1.submit(make_sample(
|
|
"ha_signed", imp, exp, phases=1,
|
|
ingest_ts=now - timedelta(seconds=lag.p1_s),
|
|
ingest_mono=1e6 + t))
|
|
await c.read_inputs(now_mono=t)
|
|
await c.cycle()
|
|
hass.advance(lag.tick, 10.0)
|
|
acc[0] += max(0.0, hass.grid())
|
|
acc[1] += 1
|
|
rows.append((now, c.target, hass.plants["a"].p,
|
|
getattr(c.arb, "action", None)))
|
|
pg = c.peak_guard
|
|
if every and n % every == 0 and t >= QUARTER_S \
|
|
and pg is not None and pg.projected_w is not None:
|
|
row = first((r["text"] for r in c.checks(now)
|
|
if r["id"] == "peak_guard"), "guard row")
|
|
samples.append((pg.projected_w, pg.ceiling_w, pg.engaged,
|
|
row, homeview.month_peak(c, now)["line"]))
|
|
t += lag.tick
|
|
n += 1
|
|
finally:
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
m.datetime, homeview.datetime, p1mod.time = real
|
|
_price.load_prices, _st.load_prices = real_load
|
|
return c
|
|
c = asyncio.run(go())
|
|
full = {datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"):
|
|
s / k for q, (s, k) in sorted(sums.items()) if k >= QUARTER_S}
|
|
return c, rows, full, samples
|
|
|
|
|
|
def into(now):
|
|
return now.timestamp() % QUARTER_S
|
|
|
|
|
|
# ============================================================================ #
|
|
print("1. charge_room_w(): a late reading's seconds count at that reading")
|
|
_dec = peakguard.decide(
|
|
Projection(5800.0, 5800.0, 450.0, 1.0, True, START, 5800.0),
|
|
batt_w=-4800.0, cap_w=3500, margin_w=50,
|
|
reading=types.SimpleNamespace(is_observed=True, peak_w=PEAK),
|
|
achievable_w=9600.0, soc=50.0, soc_floor=20.0)
|
|
_room = peakguard.charge_headroom_w(_dec)
|
|
_late = Projection(5800.0, 5800.0, 450.0, 430.0 / 450.0, False, START,
|
|
5800.0)
|
|
print(f" headroom {_room:.0f} W; 20 s late, 450 s left: "
|
|
f"{peakguard.charge_room_w(_dec, _late):.0f} W")
|
|
check("with every second counted it is charge_headroom_w() exactly",
|
|
peakguard.charge_room_w(
|
|
_dec, Projection(5800.0, 5800.0, 450.0, 1.0, True, START, 5800.0))
|
|
== _room and _room > 0.0)
|
|
check("20 s late at a 5800 W reading with 450 s left: 5800 x 20 / 450 W "
|
|
"less room",
|
|
abs(peakguard.charge_room_w(_dec, _late)
|
|
- (_room - 5800.0 * 20.0 / 450.0)) < 1e-6)
|
|
check("never below 0 W, and an export held counts nothing",
|
|
peakguard.charge_room_w(_dec, Projection(
|
|
5800.0, 5800.0, 880.0, 850.0 / 880.0, False, START, 5800.0)) == 0.0
|
|
and peakguard.charge_room_w(_dec, Projection(
|
|
5800.0, 5800.0, 450.0, 430.0 / 450.0, False, START, -900.0))
|
|
== _room)
|
|
check("None, blind (0 W) and no projection pass through",
|
|
peakguard.charge_room_w(None, _late) is None
|
|
and peakguard.charge_room_w(_dec, None) == _room
|
|
and peakguard.charge_room_w(
|
|
peakguard.PeakDecision(engaged=True, floor_w=0.0, ceiling_w=3500.0,
|
|
ceiling_from="cap", blind="frozen"),
|
|
_late) == 0.0)
|
|
|
|
|
|
# ============================================================================ #
|
|
def graded(label, full, aim):
|
|
print(f" {label}: quarters "
|
|
f"{ {k: round(v, 1) for k, v in full.items()} } W, aim {aim:.0f}")
|
|
return len(full) >= 2 and all(v <= aim + TOL for v in full.values())
|
|
|
|
|
|
def cut_and_back(rows):
|
|
"""The charge is off in each quarter's last 5 s, and back (A charging
|
|
over 2 kW) within the next quarter's first minute."""
|
|
ends = [r for r in rows if into(r[0]) >= QUARTER_S - 5]
|
|
starts = {}
|
|
for r in rows:
|
|
if into(r[0]) < 60:
|
|
q = int(r[0].timestamp() // QUARTER_S)
|
|
starts[q] = starts.get(q, False) or r[2] < -2000.0
|
|
return (present(first(iter(ends), "a quarter end"))
|
|
and all(r[1] > -1.0 for r in ends)
|
|
and len(starts) >= 2 and all(starts.values()))
|
|
|
|
|
|
print("\n2. a reading 30 s late at each quarter's end, the live shape")
|
|
GAP = {}
|
|
for name, kw, aim in (
|
|
("month peak 5413 W banked, P1 projection",
|
|
dict(peak=PEAK), AIM_PEAK),
|
|
("month peak banked, peak_forecast_entity (kW)",
|
|
dict(peak=PEAK, fc=True), AIM_PEAK),
|
|
("no peak banked (aim = cap - margin), P1 projection",
|
|
dict(), AIM_CAP),
|
|
("no peak banked, peak_forecast_entity", dict(fc=True), AIM_CAP)):
|
|
fc = kw.pop("fc", False)
|
|
_c, _rows, _full, _s = drive(opts(fc=fc), late=30.0, **kw)
|
|
GAP[name] = (_c, _rows, _full)
|
|
check(f"{name}: every quarter at or under the aim "
|
|
f"({aim:.0f} W, +{TOL:.0f} W landing)", graded(name, _full, aim))
|
|
check(f"{name}: the charge is off in the quarter's last seconds and "
|
|
f"back in the next quarter", cut_and_back(_rows))
|
|
_c_fc = GAP["month peak banked, peak_forecast_entity (kW)"][0]
|
|
check("the forecast entity is read, in kW, as the live template (SAFETY-118), "
|
|
"and the cheap run's charge does not depend on it",
|
|
_c_fc.peak_fc is not None and _c_fc.peak_fc > 1000.0
|
|
and [r[1] for r in GAP["month peak banked, peak_forecast_entity (kW)"]
|
|
[1]] == [r[1] for r in GAP[
|
|
"month peak 5413 W banked, P1 projection"][1]])
|
|
|
|
|
|
# ============================================================================ #
|
|
print("\n3. the same for a price trade (modes off) and a maintenance charge")
|
|
_ca, _ra, _fa = drive(opts(modes=False, arb=True), peak=PEAK, late=30.0)[:3]
|
|
check("modes off, price trading on, a reading 30 s late: every quarter at "
|
|
"or under the aim", graded("trade", _fa, AIM_PEAK)
|
|
and any(r[3] == "ARBITRAGE_CHARGE" for r in _ra))
|
|
|
|
|
|
def late_feed(gap):
|
|
"""safety84's cheap-window feed, no telegram in each quarter's last
|
|
`gap` s."""
|
|
feed = s84.feed(True)
|
|
|
|
def before(c, t):
|
|
if (rig.BASE.timestamp() + t) % QUARTER_S < QUARTER_S - gap \
|
|
or not getattr(c.hass, "_s84", False):
|
|
feed(c, t)
|
|
return before
|
|
|
|
|
|
for peak, house, aim in ((5413.0, 4000.0, 5363.0), (None, 2500.0, AIM_CAP)):
|
|
_mr, _mc = s84.run(2, load=lambda t, h=house: h, dur=2700.0,
|
|
month_peak=peak, before=late_feed(30.0))
|
|
_qs = {}
|
|
for r in _mr:
|
|
q = int((rig.BASE.timestamp() + r[0]) // QUARTER_S)
|
|
a = _qs.setdefault(q, [0.0, 0])
|
|
a[0] += max(0.0, house - sum(v for _u, v in r[2]))
|
|
a[1] += 1
|
|
_mf = {datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"): s / k
|
|
for q, (s, k) in sorted(_qs.items())
|
|
if k * rig.TICK >= QUARTER_S}
|
|
check(f"N=2 maintenance charge, house {house:.0f} W, "
|
|
f"{'peak banked' if peak else 'no peak banked'}, a reading 30 s "
|
|
f"late: every quarter at or under the aim",
|
|
graded("maintenance", _mf, aim)
|
|
and any(mm.phase == "charge" for mm in _mc.maints))
|
|
|
|
|
|
# ============================================================================ #
|
|
print("\n4. a healthy meter: the quarters, and the words")
|
|
for name, kw, aim, kw_text in (("month peak banked", dict(peak=PEAK),
|
|
AIM_PEAK, "5.4 kW"),
|
|
("no peak banked", dict(), AIM_CAP, "3.5 kW")):
|
|
_c, _rows, _full, _s = drive(opts(), every=10, **kw)
|
|
check(f"{name}: every quarter at or under the aim",
|
|
graded(name, _full, aim))
|
|
over = [s for s in _s if not s[2] and s[0] > s[1]]
|
|
under = [s for s in _s if not s[2] and s[0] <= s[1]]
|
|
_o, _u = first(iter(over), "an over sample"), \
|
|
first(iter(under), "an under sample")
|
|
print(f" over the limit at this rate ({len(over)} samples): "
|
|
f"{_o[3] if present(_o) else _o!s} / "
|
|
f"{_o[4] if present(_o) else _o!s}")
|
|
print(f" under it ({len(under)}): "
|
|
f"{_u[3] if present(_u) else _u!s} / "
|
|
f"{_u[4] if present(_u) else _u!s}")
|
|
check(f"{name}: over the limit at this rate the guard row never says "
|
|
f"'under'; it says the grid charge is cut",
|
|
present(_o) and all(
|
|
"under" not in s[3] and "Peak guard: cutting the grid charge, "
|
|
"at this rate" in s[3] and " over " in s[3] for s in over))
|
|
check(f"{name}: ...and the month-peak line says the rate, and that the "
|
|
f"grid charge is cut back to stay under {kw_text}",
|
|
present(_o) and all(
|
|
s[4].startswith("At this rate this 15-minute period reaches")
|
|
and s[4].endswith("The grid charge is cut back to stay "
|
|
f"under {kw_text}.") for s in over))
|
|
check(f"{name}: under the limit both read as before",
|
|
present(_u) and all(
|
|
s[3].startswith("Peak guard: idle, heading for")
|
|
and " under " in s[3]
|
|
and s[4].startswith("This 15-minute period is heading for")
|
|
and s[4].endswith(f"The batteries step in above {kw_text}.")
|
|
for s in under))
|
|
|
|
|
|
# ============================================================================ #
|
|
print("\n5. the words on their own")
|
|
_en = messages.row_peak_guard_idle(5814.0, 5413.0, month_peak=True)
|
|
_cut = messages.row_peak_guard_idle(5814.0, 5413.0, month_peak=True,
|
|
cut=True)
|
|
_ok = messages.row_peak_guard_idle(5200.0, 5413.0, month_peak=True)
|
|
print(f" {_en}\n {_cut}\n {_ok}")
|
|
check("over, nothing cut: 'idle, at this rate ..., over ...'",
|
|
_en == "Peak guard: idle, at this rate 5 814 W, over this month's "
|
|
"5 413 W peak")
|
|
check("over, a grid charge cut",
|
|
_cut == "Peak guard: cutting the grid charge, at this rate 5 814 W, "
|
|
"over this month's 5 413 W peak")
|
|
check("under: the 0.19.1 sentence, unchanged",
|
|
_ok == "Peak guard: idle, heading for 5 200 W, under this month's "
|
|
"5 413 W peak")
|
|
check("the Dutch forms say the same",
|
|
_cut.nl == "Piekbewaking: schroeft het laden van het net terug, aan "
|
|
"dit tempo 5 814 W, boven de maandpiek van 5 413 W"
|
|
and _en.nl.startswith("Piekbewaking: in rust, aan dit tempo"))
|
|
check("the month-peak line in shadow mode says 'would'",
|
|
homeview.line_period(5814.0, 5413.0, True, cut=True).endswith(
|
|
"The grid charge would be cut back to stay under 5.4 kW.")
|
|
and homeview.line_period(5814.0, 5413.0, False).endswith(
|
|
"The batteries step in above 5.4 kW."))
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED:")
|
|
for f in fails:
|
|
print(f" - {f}")
|
|
sys.exit(1)
|
|
print("all checks passed")
|