Files
goodwe-addon/goodwe_controller/test_maint28b.py
T
glenn schrooyenandClaude 34b81e8aeb
checks / unit (pull_request) Successful in 28m10s
infra-pin / fresh (pull_request) Successful in 1s
MAINT-28 fix-forward: the peak pause ends only on a settled house estimate, and a pack under its peak reserve gives nothing for the house while it holds
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>
2026-11-13 16:56:18 +01:00

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")