Rig FAIL (TEST-23, f3f9483). F-1: the hold ended at each guard step because
grid + site_batt_w added a new meter reading to a battery reading up to 10 s
old. It now ends only when no battery reading may pre-date a step up of its
command (Controller._batts_settled()). F-2: while the pause holds, a pack in
maintenance CHARGE at or under its peak reserve is limited to 0 W, so it gives
only what the guard claims for the peak. Sim: Lag.batt_period_s (a held 10 s
register). Tests: test_maint28b.py. DECISIONS updated.
Co-Authored-By: Claude <noreply@anthropic.com>
315 lines
12 KiB
Python
315 lines
12 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: A starts just over its reserve: while the pause holds it gives for the
|
|
house only down to the reserve, never below.
|
|
3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing
|
|
for the house, the other pack carries what the guard claims.
|
|
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
|
|
|
|
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, peakguard, tariff # noqa: E402
|
|
|
|
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))
|
|
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,
|
|
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. F-2: A starts just over its peak reserve (21 %), the pause holds")
|
|
rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0))
|
|
show("A 21 %", rows, st)
|
|
check("A gave for the house while above the reserve (as before)",
|
|
any(r["u"] > 500 for r in rows if r["soc"] > RESERVE + 0.5))
|
|
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))
|
|
|
|
# ============================================================================ #
|
|
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())
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED:")
|
|
for f in fails:
|
|
print(f" - {f}")
|
|
sys.exit(1)
|
|
print("all checks passed")
|