Owner 2026-11-13 ("ok max", relayed by the manager): while the pause holds,
the pack in maintenance CHARGE gives at most the largest floor the guard
claimed in the quarter. The cap is now that pack's own part of the floor
(the floor less what the other units are placed at, split without the cap),
the largest in the 15-minute period, kept per unit (`_m28_hi`). 0 W while
the guard holds it at its reserve. test_maint28b: SoC check stated against
the owner-accepted give (0.70 point, measured 0.64); both-packs-able tests
(B on Cheap hours, B on Solar first); per-unit state and the one-slot
arbitrate. test_maint28 unchanged (5925 W). Pins: test_safety84
n2_charge_banked, test_packshare's one-resolve AST pin (a second call with
the same weights).
Co-Authored-By: Claude <noreply@anthropic.com>
430 lines
18 KiB
Python
430 lines
18 KiB
Python
"""Runnable check for MAINT-28 fix-forward. `python3 test_maint28b.py`
|
|
|
|
The rig failed MAINT-28 (TEST-23, f3f9483): the hold of the maintenance pause
|
|
ended at almost every guard step (F-1), and while it stuck, the pack in
|
|
maintenance CHARGE gave its full rating for the house below its peak reserve
|
|
(F-2). test_maint28.py missed both because its battery reading has no lag. Here
|
|
the battery register is published every 10 s and held (`Lag.batt_period_s`),
|
|
the meter reading is 2 s old, as on the rig; the rating is the rig's A (5 kW).
|
|
|
|
All on the real Controller through `cycle()` (tools/opt20_scen.py): cheap
|
|
window, cap 3500 / margin 50, a month peak of 6000 W banked (aim 5950 W), a
|
|
peak forecast like the live sensor, house +-50 W every 5 s, 3000 W then
|
|
6500 W for 13 min (over a quarter boundary), then 3000 W.
|
|
|
|
1. A real peak, A in CHARGE above its peak reserve: A flips charge/give a few
|
|
times (base 1b5f7c9: dozens), no release of the pause on an estimate that
|
|
mixes a new grid with an old battery reading, quarters at or under the aim,
|
|
A's charge back after the load.
|
|
2. F-2 and the cap (owner 2026-11-13): while the pause holds, A gives at most
|
|
the largest part of the guard's floor placed on it in the 15-min period,
|
|
at any SoC (never under its reserve; SoC drop within the owner-accepted
|
|
give); with both packs able, never over the period's largest floor, and
|
|
when B is placed part of it A's limit is its own part, not the whole floor;
|
|
one unit's limit is its own (two packs in CHARGE never share it); with the
|
|
other pack under its reserve, A still gives for the guard.
|
|
3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing
|
|
for the house.
|
|
4. Unchanged: the MAINT-24 live shape (A 1475 W, B no grid trade), with the
|
|
same lag on.
|
|
5. `peakguard.shaping_held()` with `settled`, and `Controller._batts_settled()`.
|
|
Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.
|
|
Read MUTATION.md before you report a survivor.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import random
|
|
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 opt20_scen as sc # noqa: E402
|
|
from app import (ctl_cycle, ctl_trade, freshness, maintenance, # noqa: E402
|
|
packmode, peakguard, tariff)
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
rp.RATING, rp.CAP_KWH = 5000.0, 15.36 # the rig's A
|
|
LAG = rp.Lag(dead_s=1.0, meter_s=2.0, batt_s=1.0, p1_s=0.35, tick=1.0,
|
|
tau_s=2.0, ramp_w_s=1e9, batt_period_s=10.0)
|
|
CH = sc.MAINT_CHARGE
|
|
MONTH_PEAK = 6000.0
|
|
AIM = MONTH_PEAK - 50.0
|
|
LINE = AIM - getattr(peakguard, "MAINT_RESUME_UNDER_W", 300.0)
|
|
T0 = sc.at(2.0) # a quarter boundary
|
|
PRE, LOAD, POST = 120.0, 780.0, 120.0 # s (CI time: short runs)
|
|
RESERVE = 20.0 # peak_soc_floor default
|
|
|
|
|
|
def stepped_house(base_w, hi_w):
|
|
rnd = random.Random(28)
|
|
out = [(sc.UTC0.timestamp() - 10, base_w)]
|
|
for t in range(0, int(PRE + LOAD + POST), 5):
|
|
w = hi_w if PRE <= t < PRE + LOAD else base_w
|
|
out.append((T0.timestamp() + t, w + rnd.uniform(-50.0, 50.0)))
|
|
return out
|
|
|
|
|
|
def scen(*, unit="a", soc=49.0, other=30.0, house=None, house_w=3000.0,
|
|
peak=MONTH_PEAK, dur=PRE + LOAD + POST, fc="quarter", n=2,
|
|
choice=None):
|
|
"""`unit` is in maintenance CHARGE (forced result), at `soc`, the other at
|
|
`other`. Rows: the plants' power and SoC, the pause flag, the add-on's house
|
|
estimate and the real house."""
|
|
rows = []
|
|
q = dict(q=None, s=0.0, last=None, start=None)
|
|
idx = 0 if unit == "a" else 1
|
|
o = "b" if unit == "a" else "a"
|
|
|
|
def patch(c):
|
|
c.maints[idx].tick = (
|
|
lambda now, soc_, blocked_by=None, gate_open=True: CH)
|
|
if peak is not None:
|
|
orig_peak = c.peak.peak
|
|
|
|
def peak_(now):
|
|
r = orig_peak(now)
|
|
return tariff.PeakReading(tariff.OBSERVED,
|
|
getattr(r, "month", ""), peak)
|
|
c.peak.peak = peak_
|
|
orig = c.cycle
|
|
|
|
async def cyc():
|
|
c.cheap = True
|
|
if fc and c.grid is not None:
|
|
g = float(c.grid)
|
|
now = ctl_cycle.datetime.now() # the replay's clock
|
|
qi = int(now.timestamp() // 900)
|
|
if q["q"] != qi:
|
|
q.update(q=qi, s=0.0, last=now, start=now)
|
|
el = (now - q["start"]).total_seconds()
|
|
q["s"] += g * (now - q["last"]).total_seconds()
|
|
q["last"] = now
|
|
c.peak_fc = (q["s"] + g * (900.0 - el)) / 900.0
|
|
await orig()
|
|
c.cycle = cyc
|
|
|
|
def cb(c, h, now, pr):
|
|
est = (None if c.grid is None or c.site_batt_w is None
|
|
else float(c.grid) + float(c.site_batt_w))
|
|
g = c.peak_guard
|
|
floor = (float(g.floor_w) if g is not None and g.engaged
|
|
and g.floor_w is not None else 0.0)
|
|
sh = (dict(c.allocation.shares) if c.allocation is not None
|
|
and c.allocation.shares else {})
|
|
au = c.actuator_of("maintenance", c.inverters[idx])
|
|
ao = c.actuator_of("maintenance", c.inverters[1 - idx])
|
|
rows.append(dict(
|
|
t=(now - T0).total_seconds(), grid=h.grid(), u=h.plants[unit].p,
|
|
o=h.plants[o].p, soc=h.plants[unit].soc, soc_o=h.plants[o].soc,
|
|
hold=bool(getattr(c, "_maint_peak_hold", False)), est=est,
|
|
floor=floor, share_u=float(sh.get(au, 0.0)),
|
|
hi_u=next((float(pl.hi) for pl in (c.allocation.plans
|
|
if c.allocation else ())
|
|
if pl.unit == au), None),
|
|
share_o=float(sh.get(ao, 0.0)),
|
|
house=h.grid() + sum(p.p for p in h.plants.values())))
|
|
|
|
src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)]
|
|
ch = dict(sc.CH_CH if choice is None else choice)
|
|
stats, c = rp.run("p3_ch_sell", src, sc.WPTS, T0, dur, check=cb, lag=LAG,
|
|
n=n, soc0={unit: soc, o: other}, choice=ch,
|
|
refuse=(), patch=patch,
|
|
extra={"cycle_cost_threshold_eur": 0.10})
|
|
return rows, stats, c
|
|
|
|
|
|
def flips(rs, k="u"):
|
|
n, s = 0, 0
|
|
for r in rs:
|
|
v = 1 if r[k] > 50 else -1 if r[k] < -50 else 0
|
|
if v and s and v != s:
|
|
n += 1
|
|
if v:
|
|
s = v
|
|
return n
|
|
|
|
|
|
def releases(rows):
|
|
"""Cycles in the load where the pause ended: (row, estimate under the
|
|
release line while the real house was over it)."""
|
|
out = []
|
|
for p, r in zip(rows, rows[1:]):
|
|
if PRE <= r["t"] < PRE + LOAD and p["hold"] and not r["hold"]:
|
|
out.append((r, r["est"] is not None and r["est"] < LINE
|
|
and r["house"] >= LINE))
|
|
return out
|
|
|
|
|
|
def quarters_ok(st):
|
|
# SAFETY-126: a landing 0-7 W over the aim is not an overrun.
|
|
return (max(a[0] / a[1] for a in st["quarters"].values())
|
|
<= AIM + peakguard.ALARM_MISS_W)
|
|
|
|
|
|
def show(tag, rows, st):
|
|
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
|
|
rel = releases(rows)
|
|
print(f" {tag}: flips {flips(load)}, charge s "
|
|
f"{sum(1 for r in load if r['u'] < -50)}, releases {len(rel)} "
|
|
f"(stale {sum(1 for _r, s in rel if s)}), lowest SoC "
|
|
f"{min(r['soc'] for r in rows):.1f} %, give kWh "
|
|
f"{sum(r['u'] for r in load if r['u'] > 0) / 3.6e6:.2f}, quarters "
|
|
f"{[round(a[0] / a[1], 1) for a in st['quarters'].values()]}")
|
|
|
|
|
|
# ============================================================================ #
|
|
print("1. a real peak, A in CHARGE at 49 % (above its reserve), the battery "
|
|
"reading lagging")
|
|
rows, st, c = scen(house=stepped_house(3000.0, 6500.0))
|
|
show("A 49 %", rows, st)
|
|
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
|
|
rel = releases(rows)
|
|
after = [r for r in rows if r["t"] >= PRE + LOAD]
|
|
back = next((i for i, r in enumerate(after) if r["u"] < -50), None)
|
|
print(f" A's charge back {back} s after the load")
|
|
check("A flips charge/give 3 times or fewer in the 13 min (1b5f7c9: 60, "
|
|
"f3f9483: 11)", flips(load) <= 3)
|
|
check("the pause never ends on an estimate under the release line while the "
|
|
"real house is over it", not any(s for _r, s in rel))
|
|
check("every quarter at or under the month aim",
|
|
quarters_ok(st))
|
|
check("after the load A's charge is back within 20 cycles (1 s each: a 10 s "
|
|
"register period plus its settle time, 16 s)",
|
|
back is not None and back <= 20)
|
|
|
|
# ============================================================================ #
|
|
print("\n2. the cap: while the pause holds, A gives at most the largest part "
|
|
"of the guard's floor placed on it this period")
|
|
rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0))
|
|
show("A 21 %", rows, st)
|
|
check("A gives only the guard's claim, under 0.15 kWh (f3f9483 0.94)",
|
|
sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.15)
|
|
check("A never goes under its peak reserve in the peak (rig: to 10 %)",
|
|
min(r["soc"] for r in rows) >= RESERVE - 0.5)
|
|
check("every quarter at or under the month aim", quarters_ok(st))
|
|
rows, st, c = scen(house=stepped_house(3000.0, 6500.0))
|
|
soc_start = max(r["soc"] for r in rows if r["t"] <= PRE + 5.0)
|
|
drop = soc_start - min(r["soc"] for r in rows if r["t"] >= PRE)
|
|
print(f" A at 49 %: SoC drop {drop:.2f} point")
|
|
# Owner 2026-11-13 accepted about 0.37 kWh per 45-min peak with the cap
|
|
# (DECISIONS): 0.37 * 13 / 45 = 0.107 kWh in this load, 0.70 point of
|
|
# 15.36 kWh. Measured 0.64.
|
|
check("A at 49 %: its SoC drops no more than the owner-accepted give allows "
|
|
"(0.70 point in the 13 min; f3f9483 6.1)", drop <= 0.70)
|
|
rows, st, c = scen(soc=49.0, other=18.2, house=stepped_house(3000.0, 12000.0))
|
|
show("A 49 %, B 18 % under its reserve, house 12000 W", rows, st)
|
|
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
|
|
check("the guard's own claim stands: with B under its reserve A gives for "
|
|
"the peak (over 0.2 kWh; the cap yields to the guard)",
|
|
sum(r["u"] for r in load if r["u"] > 0) / 3.6e6 > 0.2)
|
|
g = [("give" if r["u"] > 50 else "idle") for r in load]
|
|
sw = sum(1 for x, y in zip(g, g[1:]) if x != y)
|
|
print(f" give/idle switches of A in the load: {sw}")
|
|
check("...give/idle switches of A at most 10 in the 13 min", sw <= 10)
|
|
|
|
def period_max(rows, f):
|
|
"""Per row: the largest f(row) over the paused rows of its 15-minute
|
|
period so far (0 W outside the pause), as the owner rule reads."""
|
|
out, q, m = [], None, 0.0
|
|
for r in rows:
|
|
qi = int((T0.timestamp() + r["t"]) // 900)
|
|
if qi != q or not r["hold"]:
|
|
q, m = qi, 0.0
|
|
if r["hold"]:
|
|
m = max(m, f(r))
|
|
out.append(m)
|
|
return out
|
|
|
|
|
|
for tag, ch, hi_w in (("B on Cheap hours", None, 9000.0),
|
|
("B on Solar first (it gives for the house too)",
|
|
{"a": sc.CH_CH["a"],
|
|
"b": (True, packmode.SOLAR_FIRST, False)},
|
|
12000.0)):
|
|
rows, st, c = scen(soc=49.0, other=40.0, choice=ch,
|
|
house=stepped_house(3000.0, hi_w))
|
|
show(f"A 49 %, B 40 %, both able, {tag}, house {hi_w:.0f} W", rows, st)
|
|
mf = period_max(rows, lambda r: r["floor"])
|
|
held_rows = [(r, m) for r, m in zip(rows, mf)
|
|
if r["hold"] and r["floor"] > 0.0 and r["hi_u"] is not None]
|
|
n = max(1, len(held_rows))
|
|
over = sum(1 for r, m in held_rows if r["share_u"] > m + 2.0)
|
|
# the whole floor, as far as A's rating lets it be A's limit
|
|
under = sum(1 for r, m in held_rows
|
|
if r["hi_u"] < min(m, rp.RATING) - 500.0)
|
|
print(f" {len(held_rows)} paused cycles with a guard floor (mean "
|
|
f"{sum(r['floor'] for r, _m in held_rows) / n:.0f} W, period's "
|
|
f"largest {sum(m for _r, m in held_rows) / n:.0f} W): A's limit "
|
|
f"{sum(r['hi_u'] for r, _m in held_rows) / n:.0f} W, A "
|
|
f"{sum(r['share_u'] for r, _m in held_rows) / n:.0f} W, B "
|
|
f"{sum(r['share_o'] for r, _m in held_rows) / n:.0f} W; A over the "
|
|
f"period's largest floor in {over}; A's limit 500 W or more under it"
|
|
f" (or A's rating) in {under}")
|
|
check(f"{tag}: the pause holds with a guard floor for over 5 min",
|
|
len(held_rows) >= 300)
|
|
check("...A never gives more than the largest guard floor of the period "
|
|
"(owner 2026-11-13)", over == 0)
|
|
if ch is not None:
|
|
check("...B is placed part of the floor, so A's limit is its own part,"
|
|
" 500 W or more under the period's largest floor (or A's "
|
|
"rating) in 9 of 10 cycles (not the whole floor)",
|
|
under >= 0.9 * len(held_rows))
|
|
check("...every quarter at or under the month aim", quarters_ok(st))
|
|
|
|
rows, st, c = scen(soc=18.2, other=30.0, house=stepped_house(3000.0, 12000.0))
|
|
show("A 18 % under its reserve, B 30 % able, house 12000 W", rows, st)
|
|
check("the guard places its claim on the other pack: A under its reserve "
|
|
"gives under 0.05 kWh and its SoC stays at 18 %",
|
|
sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.05
|
|
and min(r["soc"] for r in rows) >= 18.0)
|
|
|
|
# ============================================================================ #
|
|
print("\n3. mirror: B in CHARGE at 18 % (under its reserve), A on Cheap hours")
|
|
rows, st, c = scen(unit="b", soc=18.2, other=30.0,
|
|
house=stepped_house(3000.0, 6500.0))
|
|
show("B 18 %", rows, st)
|
|
check("B gives nothing for the house: its SoC never goes under 18 %",
|
|
min(r["soc"] for r in rows) >= 18.0)
|
|
check("every quarter at or under the month aim", quarters_ok(st))
|
|
|
|
# ============================================================================ #
|
|
print("\n4. unchanged: the MAINT-24 live shape with the lagging reading")
|
|
rl, sl, cl = scen(house_w=3000.0, dur=600.0)
|
|
body = rl[len(rl) // 6:]
|
|
mean_a = sum(r["u"] for r in body) / len(body)
|
|
mean_b = sum(r["o"] for r in body) / len(body)
|
|
print(f" A {mean_a:+.0f} W, B {mean_b:+.0f} W, pause "
|
|
f"{sum(r['hold'] for r in rl)} cycles")
|
|
check("A charges about 1475 W (-1500..-1440) and the pause never comes",
|
|
-1500.0 <= mean_a <= -1440.0 and not any(r["hold"] for r in rl))
|
|
check("B does not trade with the grid (Cheap hours, nothing to charge it)",
|
|
abs(mean_b) < 100.0)
|
|
|
|
# ============================================================================ #
|
|
print("\n5. peakguard.shaping_held() with `settled`; Controller._batts_settled()")
|
|
check("the rule exists (MAINT_STEP_W, _batts_settled)",
|
|
hasattr(ctl_trade, "MAINT_STEP_W") and hasattr(ctl_trade.TradeMixin,
|
|
"_batts_settled"))
|
|
if not hasattr(ctl_trade, "MAINT_STEP_W"):
|
|
print(f"\n{len(fails)} FAILED:")
|
|
for f in fails:
|
|
print(f" - {f}")
|
|
sys.exit(1)
|
|
U = peakguard.MAINT_RESUME_UNDER_W
|
|
idle = peakguard.PeakDecision(engaged=False, floor_w=None, ceiling_w=6000.0,
|
|
ceiling_from="x", target_w=AIM)
|
|
|
|
|
|
def held(was, fc, house, settled):
|
|
return peakguard.shaping_held(
|
|
peakguard.shaping_suspended(idle, fc, AIM), was, fc, AIM, house,
|
|
settled)
|
|
|
|
|
|
check("held, house under the line but not settled: stays paused",
|
|
held(True, AIM - 1, AIM - U - 100.0, False))
|
|
check("held, house under the line and settled: resumes",
|
|
not held(True, AIM - 1, AIM - U - 100.0, True))
|
|
check("not held: settled makes no difference",
|
|
not held(False, AIM - 1, 6500.0, False))
|
|
check("neither the house nor the forecast read: no pause, settled or not "
|
|
"(SAFETY-63)",
|
|
not held(True, None, None, False))
|
|
|
|
|
|
class _Fresh:
|
|
def __init__(self, changed):
|
|
self.changed_mono = changed
|
|
|
|
|
|
class _Inv:
|
|
def __init__(self, name, cmd, changed):
|
|
self.name, self.cmd = name, cmd
|
|
self.batt_fresh = _Fresh(changed)
|
|
|
|
def standing_w(self):
|
|
return self.cmd
|
|
|
|
|
|
class _Ctl:
|
|
_batts_settled = getattr(ctl_trade.TradeMixin, "_batts_settled", None)
|
|
|
|
def __init__(self, invs):
|
|
self.inverters, self._m28_step, self._now_mono = invs, {}, 0.0
|
|
|
|
|
|
S = freshness.PLANT_SETTLE_S
|
|
a = _Inv("a", -1500.0, 0.0)
|
|
k = _Ctl([a, _Inv("b", 0.0, 0.0)])
|
|
check("no step seen yet: settled", k._batts_settled())
|
|
a.cmd = 500.0
|
|
k._now_mono = 100.0
|
|
check("a step up of the command: not settled", not k._batts_settled())
|
|
k._now_mono = 100.0 + S - 0.5
|
|
a.batt_fresh.changed_mono = 100.0 + S - 1.0
|
|
check("...a reading that arrived before step + settle does not count",
|
|
not k._batts_settled())
|
|
a.batt_fresh.changed_mono = 100.0 + S
|
|
k._now_mono = 100.0 + S
|
|
check("...one that arrived at step + settle does", k._batts_settled())
|
|
a.cmd = 3000.0
|
|
k._now_mono = 200.0
|
|
check("a second step up; the old reading is not current",
|
|
not k._batts_settled())
|
|
k._now_mono = 200.0 + freshness.BATT_OBLIGED_S
|
|
check("...and a reading that never changes is waited for no longer than "
|
|
"BATT_OBLIGED_S", k._batts_settled())
|
|
a.cmd = -1500.0
|
|
k._now_mono = 400.0
|
|
check("a step down (less give: the estimate errs high) is no step",
|
|
k._batts_settled())
|
|
a2 = _Inv("a", -1500.0, 0.0)
|
|
k2 = _Ctl([a2])
|
|
k2._batts_settled()
|
|
a2.cmd, k2._now_mono = -1500.0 + ctl_trade.MAINT_STEP_W - 1.0, 1.0
|
|
check("a move under ctl_trade.MAINT_STEP_W is no step", k2._batts_settled())
|
|
a2.cmd = -1500.0 + ctl_trade.MAINT_STEP_W
|
|
check("...one of ctl_trade.MAINT_STEP_W is", not k2._batts_settled())
|
|
|
|
# Per unit: the cap so far lives under each unit's own key, and only one pack
|
|
# can be in maintenance CHARGE at all (maintenance.arbitrate(): one slot).
|
|
au, ab = (cl.actuator_of("maintenance", i) for i in cl.inverters)
|
|
t = T0 + timedelta(seconds=60)
|
|
cl._m28_hi = {au: (int(t.timestamp() // 900), 3000.0)}
|
|
check("the period's cap is kept per unit: A's 3000 W is not B's",
|
|
cl._m28_was(au, t) == 3000.0 and cl._m28_was(ab, t) == 0.0)
|
|
check("...and it starts again from 0 W in the next 15-minute period",
|
|
cl._m28_was(au, t + timedelta(seconds=900)) == 0.0)
|
|
|
|
|
|
class _M:
|
|
def __init__(self, name):
|
|
self.name, self.phase = name, maintenance.CHARGE
|
|
|
|
|
|
check("maintenance.arbitrate(): with two packs in CHARGE only the first keeps "
|
|
"the slot", maintenance.arbitrate([_M("a"), _M("b")], t)[1] != ""
|
|
and maintenance.arbitrate([_M("a"), _M("b")], t)[0] == "")
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED:")
|
|
for f in fails:
|
|
print(f" - {f}")
|
|
sys.exit(1)
|
|
print("all checks passed")
|