Owner 2026-10-29: fix N=1 in this hotfix. _charge_headroom() no longer returns early at one inverter, so the N=1 cheap-window maintenance charge is held to charge_room_w() as at N=2; SAFETY-84's site-stop rule stays N>=2 only (at N=1 such a stop outranks the shaping anyway). The guard no longer steps in and gives power at each quarter's end during that charge. test_safety125.py: N=1 maintenance on 5 s and 10 s meters with late readings, with and without a banked peak; the row in an N=2 maintenance charge and in shadow mode; edge cases (blind past the hold, a start mid-quarter, the summer-time change, the forecast in W, a 10 s meter, the last second). test_safety84/87: three N=1 pins move. test_safety119: its quarter-edge hand-back runs keep the old N=1 sizing as a fixture, plus a check that with the new sizing nothing hands back. CHANGELOG, DECISIONS, DOCS follow. Co-Authored-By: Claude <noreply@anthropic.com>
566 lines
26 KiB
Python
566 lines
26 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.
|
|
6. One inverter: the maintenance charge is sized to the room too (owner
|
|
2026-10-29), on a 5 s and a 10 s meter, with and without a banked peak
|
|
and with late readings; the guard no longer steps in during it.
|
|
7. The row during an N=2 maintenance charge and in shadow mode.
|
|
8. Edge cases: a reading past its 30 s hold (blind), a start mid-quarter,
|
|
the summer-time change, the forecast entity in W, a 10 s meter, the
|
|
quarter's last second.
|
|
|
|
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, nth, 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")
|
|
|
|
|
|
class OnItsOwn(rp.Plant):
|
|
"""Charges 5000 W whatever it is asked (shadow mode: nothing is
|
|
written, the inverter charges on its own)."""
|
|
|
|
def step(self, dt, floor):
|
|
self.cmd = -5000.0
|
|
super().step(dt, floor)
|
|
|
|
|
|
def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None,
|
|
period=0.0, fc_unit="kW", plant=Harder):
|
|
"""The real Controller from `start` (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; `period`: one telegram every `period` s (0: every cycle).
|
|
-> (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 = [], [], {}
|
|
t0 = start or START
|
|
|
|
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__ = plant
|
|
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}" if fc_unit == "kW"
|
|
else f"{fc_kw[0] * 1000.0:.0f}"),
|
|
"attributes": {"unit_of_measurement": fc_unit}}
|
|
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(t0), 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 = [t0], [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 = t0 + 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 \
|
|
and (not period or t % period < 1e-9):
|
|
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, every=0.0, look=None):
|
|
"""safety84's cheap-window feed, no telegram in each quarter's last
|
|
`gap` s, and one only every `every` s (0: every cycle). `look(c, t)`
|
|
runs first, on the state the previous cycle left."""
|
|
feed = s84.feed(True)
|
|
|
|
def before(c, t):
|
|
if look is not None:
|
|
look(c, t)
|
|
if ((rig.BASE.timestamp() + t) % QUARTER_S < QUARTER_S - gap
|
|
and (not every or t % every < 1e-9)) \
|
|
or not getattr(c.hass, "_s84", False):
|
|
feed(c, t)
|
|
return before
|
|
|
|
|
|
def rig_quarters(rows, load):
|
|
"""The safety84 rig's whole quarters: the true average import (W)."""
|
|
qs = {}
|
|
for r in rows:
|
|
q = int((rig.BASE.timestamp() + r[0]) // QUARTER_S)
|
|
a = qs.setdefault(q, [0.0, 0])
|
|
a[0] += max(0.0, load(r[0]) - sum(v for _u, v in r[2]))
|
|
a[1] += 1
|
|
return {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}
|
|
|
|
|
|
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))
|
|
_mf = rig_quarters(_mr, lambda t, h=house: h)
|
|
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 limited",
|
|
present(_o) and all(
|
|
"under" not in s[3] and "Peak guard: grid charge limited, "
|
|
"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 5 413 W")
|
|
check("over, a grid charge cut",
|
|
_cut == "Peak guard: grid charge limited, at this rate 5 814 W, "
|
|
"over 5 413 W")
|
|
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: laden beperkt, aan dit tempo 5 814 W, "
|
|
"boven 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("\n6. one inverter: the maintenance charge is sized to the room too")
|
|
# The start-up quarter on a 10 s meter: the charge's first step is not seen
|
|
# until the second reading, so it may land up to START_TOL over the aim; the
|
|
# same at N=2 and before this fix. Every later quarter is graded at TOL.
|
|
START_TOL = 15.0
|
|
N1 = {}
|
|
for peak, house, aim in ((5413.0, 4000.0, 5363.0), (None, 2500.0, AIM_CAP)):
|
|
for late, every in ((0.0, 0.0), (30.0, 0.0), (20.0, 10.0)):
|
|
_r, _c = s84.run(1, load=lambda t, h=house: h, dur=2700.0,
|
|
month_peak=peak, before=late_feed(late, every))
|
|
_q = rig_quarters(_r, lambda t, h=house: h)
|
|
N1[peak, late, every] = (_r, _c)
|
|
_vals = list(_q.values())
|
|
_tag = (f"N=1, {'peak banked' if peak else 'no peak banked'}, "
|
|
f"{'10 s' if every else '5 s'} meter, "
|
|
f"{'late ' + format(late, '.0f') + ' s' if late else 'healthy'}")
|
|
print(f" {_tag}: quarters "
|
|
f"{ {k: round(v, 1) for k, v in _q.items()} } W, aim {aim:.0f}")
|
|
check(f"{_tag}: every quarter at or under the aim",
|
|
len(_vals) == 3
|
|
and all(v <= aim + TOL for v in _vals[1:])
|
|
and nth(_vals, 0, "a first quarter")
|
|
<= aim + (START_TOL if every else TOL)
|
|
and any(mm.phase == "charge" for mm in _c.maints))
|
|
_hr, _hc = N1[5413.0, 0.0, 0.0]
|
|
_eng = sum(1 for r in _hr if r[-1][1] and r[-1][1][0])
|
|
print(f" healthy, peak banked: guard engaged on {_eng} of {len(_hr)} "
|
|
f"cycles; A charges on "
|
|
f"{sum(1 for r in _hr if dict(r[2])['a'] < -1.0)} cycles")
|
|
check("N=1: the guard no longer steps in during the charge (no hand-back), "
|
|
"and the battery charges on every cycle, at no more than its rate",
|
|
_eng == 0 and all(-2000.0 <= dict(r[2])["a"] < -1.0 for r in _hr)
|
|
and not any("suspended" in e for e in _hc.events))
|
|
|
|
|
|
# ============================================================================ #
|
|
print("\n7. the row during an N=2 maintenance charge and in shadow mode")
|
|
_rows7 = []
|
|
|
|
|
|
def _look(c, t):
|
|
pg = c.peak_guard
|
|
if t >= QUARTER_S and pg is not None and pg.projected_w is not None \
|
|
and not pg.engaged and pg.projected_w > pg.ceiling_w:
|
|
_rows7.append(first((r["text"] for r in c.checks()
|
|
if r["id"] == "peak_guard"), "guard row"))
|
|
|
|
|
|
def _steps(t):
|
|
"""4000 W, 800 W more for the first 30 s of every minute."""
|
|
return 4000.0 + (800.0 if t % 60.0 < 30.0 else 0.0)
|
|
|
|
|
|
_m2r, _m2c = s84.run(2, load=_steps, dur=1800.0, month_peak=5413.0,
|
|
before=late_feed(0.0, look=_look))
|
|
_m2q = rig_quarters(_m2r, _steps)
|
|
print(f" N=2 maintenance charge, house steps 4000/4800 W: "
|
|
f"{len(_rows7)} over-the-limit rows, e.g. "
|
|
f"{first(iter(_rows7), 'a row')}; quarters "
|
|
f"{ {k: round(v, 1) for k, v in _m2q.items()} } W")
|
|
check("N=2 maintenance charge, over the limit at this rate: the row says the "
|
|
"grid charge is limited",
|
|
present(first(iter(_rows7), "a row")) and all(
|
|
r.startswith("Peak guard: grid charge limited, at this rate")
|
|
for r in _rows7)
|
|
and all(v <= 5363.0 + TOL for v in _m2q.values()))
|
|
_sh_o = dict(opts(), shadow_mode="on")
|
|
_csh, _rsh, _fsh, _ssh = drive(_sh_o, peak=PEAK, minutes=17, every=10,
|
|
plant=OnItsOwn)
|
|
_sh_over = [s for s in _ssh if not s[2] and s[0] > s[1]]
|
|
_sh1 = first(iter(_sh_over), "an over sample")
|
|
print(f" shadow mode, the inverter charging 5000 W on its own: "
|
|
f"{len(_sh_over)} over samples, e.g. "
|
|
f"{_sh1[3] if present(_sh1) else _sh1!s} / "
|
|
f"{_sh1[4] if present(_sh1) else _sh1!s}")
|
|
check("shadow mode: nothing is cut on the wire, so the row says 'idle', "
|
|
"never 'grid charge limited', and the line says 'would'",
|
|
_csh.shadow and present(_sh1) and all(
|
|
s[3].startswith("Peak guard: idle, at this rate")
|
|
and "would" in s[4] for s in _sh_over))
|
|
|
|
|
|
# ============================================================================ #
|
|
print("\n8. edge cases")
|
|
_e45 = drive(opts(), peak=PEAK, late=45.0)
|
|
check("a reading 45 s late, past the 30 s hold (the guard goes blind): every "
|
|
"quarter at or under the aim", graded("late 45 s", _e45[2], AIM_PEAK))
|
|
_e07 = drive(opts(), peak=PEAK, late=30.0, minutes=24,
|
|
start=START + timedelta(minutes=7))
|
|
check("a start at 00:07, mid-quarter, then 30 s late: the next quarter at or "
|
|
"under the aim",
|
|
len(_e07[2]) == 1 and all(v <= AIM_PEAK + TOL
|
|
for v in _e07[2].values()))
|
|
print(f" start at 00:07: {_e07[2]}")
|
|
_dst = drive(opts(), peak=PEAK, late=30.0,
|
|
start=datetime(2026, 10, 25, 0, 45, tzinfo=timezone.utc))
|
|
check("across the end of summer time (2026-10-25 01:00 UTC), 30 s late: "
|
|
"every quarter at or under the aim", graded("DST", _dst[2], AIM_PEAK))
|
|
_ew = drive(opts(fc=True), peak=PEAK, late=30.0, fc_unit="W")
|
|
check("the forecast entity in W: read as W, and the charge as with kW",
|
|
_ew[0].peak_fc is not None and _ew[0].peak_fc > 1000.0
|
|
and [r[1] for r in _ew[1]] == [r[1] for r in GAP[
|
|
"month peak banked, peak_forecast_entity (kW)"][1]])
|
|
_e10 = drive(opts(), peak=PEAK, period=10.0)
|
|
_e10v = list(_e10[2].values())
|
|
print(f" 10 s meter: {_e10[2]}")
|
|
check("a 10 s meter: every quarter after the start-up one at or under the "
|
|
"aim, and that one within START_TOL",
|
|
len(_e10v) == 2 and all(v <= AIM_PEAK + TOL for v in _e10v[1:])
|
|
and nth(_e10v, 0, "a first quarter") <= AIM_PEAK + START_TOL)
|
|
_p_end = Projection(5800.0, 5800.0, 899.5, 898.5 / 899.5, False, START,
|
|
5800.0)
|
|
check("0.5 s left and 1 s late: finite, the formula, at its floor of 0 W",
|
|
peakguard.charge_room_w(_dec, _p_end) == max(
|
|
0.0, _room - 5800.0 * 1.0 / 0.5))
|
|
_d_end = peakguard.decide(
|
|
Projection(5800.0, 5800.0, 900.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)
|
|
check("no second left: no room is measured (None), and nothing divides by 0",
|
|
peakguard.charge_room_w(_d_end, Projection(
|
|
5800.0, 5800.0, 900.0, 890.0 / 900.0, False, START, 5800.0))
|
|
is None)
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED:")
|
|
for f in fails:
|
|
print(f" - {f}")
|
|
sys.exit(1)
|
|
print("all checks passed")
|