MAINT-28 fix-forward round 2: while the pause holds, the maintenance pack gives only what the peak guard claims
checks / unit (pull_request) Successful in 27m52s
infra-pin / fresh (pull_request) Successful in 1s

Owner go 2026-11-13. The pack in maintenance CHARGE gets a unit limit with hi
= the guard's own floor while the pause holds, at any SoC (replaces the
under-reserve limit). Sim 45 min: A give 2.39 -> 0.02 kWh, SoC 49.0 %, charge
back 4 s. test_safety84 n2_charge_banked re-pinned (192 of 480 rows),
test_maint28 quarter bound to the month peak. Tests: test_maint28b.py.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-13 19:49:19 +01:00
co-authored by Claude
parent 34b81e8aeb
commit 776b84ca20
6 changed files with 40 additions and 25 deletions
@@ -40,6 +40,6 @@ The sim had no battery-reading lag, and the first version said the 300 W line is
**F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 13 s after the load (7 s in the test), not 0: the first settled estimate. No cut-back is touched.
**F-2: under its peak reserve the pack gives nothing for the house.** While the pause holds (N>=2, month aim above the cap), a pack in maintenance CHARGE that `peakguard.reserve_split()` holds gets `hi` = 0 W (P_MAINT_SHAPE). The guard's own claim for the peak is on the other packs (`place_on_able()`), so it is unchanged. Above the reserve nothing changes. Sim, B charging at 18 %: f3f9483 gave 1.89 kWh and went to 10.0 % (the log said "giving nothing for the peak"); now 0.00 kWh, 18.2 %, 0 give/idle flips in 45 min (the full cap measured by the reviewer: 32 in 40 min), quarters 5948 and 5952 W (aim 5950).
**F-2 and the cap (owner 2026-11-13): while the pause holds, the maintenance pack gives only what the guard claims.** While the pause holds (N>=2, month aim above the cap), the pack in maintenance CHARGE gets a unit limit `hi` = the guard's own floor (0 W when it claims nothing), at any SoC, so it also gives nothing for the house under its peak reserve. `hi` = 0 W alone was tried and is wrong: it crosses the guard's claim and resolves to 0 W (house 12 kW, B under its reserve: quarter 6713 W, a new month peak); with the guard's floor as `hi` the guard still gets its power. Other packs are unchanged (B not in maintenance still gives for the house). Sim, 45 min, house 6500 W: A at 49 %: A gives 0.02 kWh (uncapped 2.39), SoC stays at 49.0 %, charge/give flips 1, quarters 1958 1873 2873 W (all under the aim), charge back 4 s after the load. B at 18.2 % in CHARGE: f3f9483 gave 1.89 kWh to 10.0 % (log "giving nothing for the peak"); now 0.00 kWh, 18.2 %. Give/idle switches of the pack: 15 in 45 min, but they are the guard's claim on and off (3 episodes, one per quarter start, of 94 s at most 490 W, and 5 blips of 5-19 s at 100-490 W, 0.04 kWh in all); bridging gaps under 10 s would make it 3 episodes, 6 switches. Not added.
Mutants, each red in `test_maint28b.py`: the freshness check dropped (always settled); the reserve claim dropped.
Mutants, each red in `test_maint28b.py`: the freshness check dropped (always settled); the cap dropped.
+6 -5
View File
@@ -589,11 +589,12 @@ class CycleMixin:
"suspended so the peak claim is not "
"contradicted",
hint=messages.hint("maint_paused"))
# MAINT-28: under its peak reserve this pack gives only
# what the guard claims. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md
shape = ((0.0, "peak reserve")
if _m28 and inv.name in self._under_reserve()
else None)
# MAINT-28: while held this pack gives only what the
# guard claims. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md
shape = ((max((c.lo for c in claims
if c.source == peakguard.SOURCE),
default=0.0), "peak hold")
if _m28 else None)
else:
self._maint_shape_suspended = False
hi = 0.0
-7
View File
@@ -740,13 +740,6 @@ class TradeMixin:
ok = False
return ok
def _under_reserve(self) -> frozenset:
"""MAINT-28: the batteries at or under their peak reserve now
(`peakguard.reserve_split()`). DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md"""
floor = float(self.o.get(
"peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))
return frozenset(peakguard.reserve_split(self.packs, floor)[1])
def _reseed_drop(self, live) -> None:
"""SAFETY-96, N>=2: a battery that drops out of the split (stale link)
leaves the loop's start point, so the others take over at one slew step
+5 -3
View File
@@ -158,7 +158,7 @@ back = next((i for i, r in enumerate(after) if r["a"] < -50), None) # cycles
print(f" first give at {trip}, flips in the load {flips(load)}, charge s in"
f" the load {sum(1 for r in load if r['a'] < -50)}, charge back "
f"{back} cycles after it, quarters "
f"{[round(a[0] / a[1]) for a in st['quarters'].values()]}")
f"{[round(a[0] / a[1], 1) for a in st['quarters'].values()]}")
check("the guard stepped in once the projection went over the aim",
trip is not None)
check("from the first give to the load's end A does not flip",
@@ -166,8 +166,10 @@ check("from the first give to the load's end A does not flip",
check("...and does not charge at all", not any(r["a"] < -50 for r in load))
check("...the pause is on in every one of those cycles",
all(r["susp"] for r in load))
check("every quarter at or under the month aim",
max(a[0] / a[1] for a in st["quarters"].values()) <= AIM + 1.0)
# MAINT-28 fix: A gives only the guard's claim, so the guard lands the quarter
# itself: 5960.1 W against the 5950 W aim, 40 W under the month peak.
check("every quarter under the month peak (no new peak)",
max(a[0] / a[1] for a in st["quarters"].values()) <= MONTH_PEAK)
check("after the load A's charge is back within 10 cycles (1 s each)",
back is not None and back <= 10)
tail = [r["a"] for r in after[10:]]
+22 -6
View File
@@ -16,10 +16,12 @@ peak forecast like the live sensor, house +-50 W every 5 s, 3000 W then
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.
2. F-2 and the cap: while the pause holds, A gives only what the peak guard
claims, at any SoC (never under its reserve, SoC barely drops); with the
other pack under its reserve and a house too big for it, A still gives for
the guard.
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.
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()`.
@@ -188,14 +190,28 @@ check("after the load A's charge is back within 20 cycles (1 s each: a 10 s "
back is not None and back <= 20)
# ============================================================================ #
print("\n2. F-2: A starts just over its peak reserve (21 %), the pause holds")
print("\n2. the cap: while the pause holds, A gives only what the guard claims")
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 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)
check("A at 49 %: its SoC barely drops (under 0.5 point; f3f9483 6.1)",
soc_start - min(r["soc"] for r in rows if r["t"] >= PRE) < 0.5)
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)
# ============================================================================ #
print("\n3. mirror: B in CHARGE at 18 % (under its reserve), A on Cheap hours")
+5 -2
View File
@@ -484,7 +484,10 @@ print("\n8. the N=1 and N=2 digests")
# MAINT-28 re-pinned n2_charge_banked: in the 5000 W load (over the 3950 W
# aim) the maintenance charge no longer restarts at -125 W after each guard
# stop (t=1550 s, 64 of 480 rows); the pause is held until the load is gone
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). The other five are equal.
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). MAINT-28 fix-forward
# re-pinned it again: while the pause holds A gives only the guard's claim, so
# rows 288-479 (t = 1550 s on, 192 of 480) differ: a "peak hold" limit claim on
# A, and A's share 796 W instead of 2500 W. The other five are equal.
PINNED = {
"n1_charge":
"00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9",
@@ -497,7 +500,7 @@ PINNED = {
"n2_charge_dear":
"aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db",
"n2_charge_banked":
"5d523562219245c135a4e9aa856826bc2939a3157c6ae0320cafc2cd1a8a6cf1",
"cdcac3065224f80f62a7b643f6d1226614a19679511625c034659c3df0d265d9",
}
_now = s84.digests()
for k in PINNED: