MAINT-28: shorter test_maint28 runs (892 s -> ~280 s), digests re-pinned on 1b5f7c9; hold kept through the two short holds (documented); DECISIONS duplicate removed
checks / unit (pull_request) Successful in 40m7s
infra-pin / fresh (pull_request) Successful in 2s

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-13 02:29:27 +01:00
co-authored by Claude
parent f5bd2a3520
commit 29a8a05f30
3 changed files with 24 additions and 20 deletions
@@ -12,12 +12,12 @@ Why the house and not the forecast: while A gives, the forecast sits just under
Why 300 W: the house wanders +-50 W; once the charge resumes it settles at (aim - house) / 2, so 300 W gives at least 150 W, three times the noise, and charging less than that is not worth a restart.
The flag lives only while the charge-only branch is taken: every cycle starts it at False, so a maintenance that ended, a mode change or a missing aim cannot carry a stale True into the next charge (test_maint28.py part 1b). The hold is for N=2 and up, and only where MAINT-24 moved the aim (a banked month peak above `peak_cap_w`, so the aim is above cap minus margin). Elsewhere the old rule stays, byte for byte: SAFETY-87's N=1 digests (`n1_charge_low`) and SAFETY-119's N=2 charge at 5050 W over a 5000 W cap with no banked peak are pinned, and 1b5f7c9 behaves as 02eac3b there (a chase that was not a MAINT-24 regression).
The flag lives only while the charge-only branch is taken: every cycle that reaches the maintenance loop starts it at False, so a maintenance that ended, a mode change or a missing aim cannot carry a stale True into the next charge (test_maint28.py part 1b). `cycle()` has two returns before that loop, and the flag is kept through them on purpose: "inputs missing" and "readings late" (SAFETY-128) are holds of under `stale_after` seconds that write nothing and decide nothing, so the next full cycle sees the same charge and the same peak. A missed poll must not end a peak pause; a reset there would restart the charge on it (`_maint_shape_suspended` is kept through them the same way).
It only ever keeps the maintenance charge off. No cut-back is delayed or weakened: the guard's claim is filed as before. While the pause is held the loop works as at 02eac3b, so A also gives for the house (quarters then sit under the aim, A's energy goes to the load: 1.67 kWh in the 45 min below, 0.45 kWh on 1b5f7c9). A cap on that give would conflict with the guard's `lo` (`lo > hi` resolves to 0 W), so it is not tried here.
## Numbers (sim, real `cycle()`, A rated 2500 W)
House 3000 -> 6500 W for 45 min -> 3000 W, +-50 W every 5 s, banked month peak 6000 W (aim 5950 W), forecast = quarter average so far plus the rest at the present grid (`scratch/MAINT-28/dev/m28_repro.py`; `test_maint28.py` runs the same shape for 20 min to keep the suite short).
House 3000 -> 6500 W for 45 min -> 3000 W, +-50 W every 5 s, banked month peak 6000 W (aim 5950 W), forecast = quarter average so far plus the rest at the present grid (`scratch/MAINT-28/dev/m28_repro.py`; `test_maint28.py` runs the same shape for 15 min to keep the suite short).
| 45 min load | base 02eac3b | 1b5f7c9 | MAINT-28 |
|---|---|---|---|
@@ -33,4 +33,4 @@ Rig (MAINT-24 test report, F-1): 468 reversals in 45 min on 1b5f7c9, 11 on 02eac
`test_maint28.py`: part 1 the real peak, 1b the stale hold, 2 `shaping_held()` edges, 3 the unchanged cases (digests equal 1b5f7c9). On 1b5f7c9 itself (hold stubbed out) part 1 and 1b fail. Mutants, each red: M1 hold state not kept (`held` always False: the 1b5f7c9 behaviour); M2 resume line `-` to `+`; M3 house not passed; M4 the per-cycle reset removed; M5 the N=1 gate removed (digest `n1_peak`: N=1, banked peak, house 6500 W); M6 the aim gate removed (digest `n2_nobank`).
## Not changed
MAINT-24's rate and its tests, N=1, the drain, modes off (digests of 02eac3b). The pause for a late or missing forecast.
MAINT-24's rate and its tests, N=1, the drain, modes off (digests of 1b5f7c9). The pause for a late or missing forecast.
+3 -1
View File
@@ -537,7 +537,9 @@ class CycleMixin:
# maintenance ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST
# STAYS SHORT. (DECISIONS/MAIN-orchestration-archive.md)
# MAINT-28: the hold lives only while the charge-only branch is taken.
# MAINT-28: the hold lives only while the charge-only branch is taken;
# kept through the two short holds above on purpose
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md).
_peak_held, self._maint_peak_hold = self._maint_peak_hold, False
for inv, result in zip(self.inverters, maint_results):
if result.owns_setpoint:
+18 -16
View File
@@ -12,7 +12,7 @@ window, cap 3500 / margin 50, a month peak of 6000 W banked (aim 5950 W), a
peak forecast like the live sensor (quarter average so far, the rest at the
present grid), house +-50 W every 5 s.
1. A real peak (house 3000 W, then 6500 W for 20 min, then 3000 W): once the
1. A real peak (house 3000 W, then 6500 W for 15 min, then 3000 W): once the
guard has stepped in, A does not charge and does not flip; every quarter is
at or under the aim; A's charge is back within 10 cycles after the load.
2. peakguard.shaping_held(): the pure rule, with its edges.
@@ -53,7 +53,7 @@ MONTH_PEAK = 6000.0
AIM = MONTH_PEAK - 50.0
DRAIN_R = MaintResult(DRAIN, True, 2000.0, False, [])
T0 = sc.at(2.0) # a quarter boundary
PRE, LOAD, POST = 300.0, 1200.0, 600.0 # s
PRE, LOAD, POST = 120.0, 900.0, 120.0 # s (CI time: short runs)
def stepped_house(base_w, hi_w):
@@ -67,7 +67,7 @@ def stepped_house(base_w, hi_w):
def scen(*, a=None, tick=None, soc=(49.0, 30.0), house=None, house_w=3000.0,
peak=MONTH_PEAK, fc="grid", n=2, variant="p3_ch_sell", dur=2700.0):
peak=MONTH_PEAK, fc="grid", n=2, variant="p3_ch_sell", dur=600.0):
"""`a`: A's forced maintenance result. `fc`: "grid" (follows the grid),
"quarter" (quarter average so far, the rest at the present grid, like the
live sensor) or None (no forecast entity). Returns (rows, stats, c)."""
@@ -146,7 +146,7 @@ def flips(rs):
# ============================================================================ #
print("1. a real peak: house 3000 W, 6500 W for 20 min, 3000 W")
print("1. a real peak: house 3000 W, 6500 W for 15 min, 3000 W")
rows, st, c = scen(a=CH, house=stepped_house(3000.0, 6500.0), fc="quarter",
dur=PRE + LOAD + POST)
end = PRE + LOAD
@@ -176,14 +176,16 @@ check("...and stays on: at least 150 W (half the resume margin), no give",
# ============================================================================ #
print("\n1b. the hold does not outlive the charge: a stale True must not "
"pause the next charge")
HI_END, IDLE_END = 900.0, 1000.0
# The idle window opens on a quarter boundary, so the new charge meets a
# fresh quarter whose projection (house 5700 W) is under the aim.
HI_START, HI_END, IDLE_END, B_END = 300.0, 900.0, 1000.0, 1300.0
def _house_b():
rnd = random.Random(29)
out = [(sc.UTC0.timestamp() - 10, 3000.0)]
for t in range(0, 1500, 5):
w = 3000.0 if t < PRE else 6500.0 if t < HI_END else 5700.0
for t in range(0, int(B_END) + 100, 5):
w = 3000.0 if t < HI_START else 6500.0 if t < HI_END else 5700.0
out.append((T0.timestamp() + t, w + rnd.uniform(-50.0, 50.0)))
return out
@@ -194,10 +196,10 @@ def _tick_b(now):
if HI_END <= t < IDLE_END else CH
rb, sb, cb_ = scen(tick=_tick_b, house=_house_b(), fc="quarter", dur=1400.0)
held_before = [r for r in rb if PRE <= r["t"] < HI_END and r["hold"]]
rb, sb, cb_ = scen(tick=_tick_b, house=_house_b(), fc="quarter", dur=B_END)
held_before = [r for r in rb if HI_START <= r["t"] < HI_END and r["hold"]]
idle_rows = [r for r in rb if HI_END <= r["t"] < IDLE_END]
later = [r["a"] for r in rb if IDLE_END + 10.0 <= r["t"] < 1300.0]
later = [r["a"] for r in rb if IDLE_END + 10.0 <= r["t"] < B_END]
print(f" hold on {len(held_before)} cycles before; in the idle window "
f"{sum(r['hold'] for r in idle_rows)} of {len(idle_rows)}; A after "
f"{sum(later) / len(later):+.0f} W (house 5700 W: 125 W)")
@@ -255,16 +257,16 @@ dm = sc.digest(scen(a=CH, variant="modes_off", peak=None, fc=None)[0])
d2n = sc.digest(scen(a=CH, house_w=5000.0, peak=None, fc="grid")[0])
d1p = sc.digest(scen(a=CH, n=1, fc=None, house_w=6500.0)[0])
AT_BASE = {
"n2_nobank": "c164c9d4f221f7d685a0aa904a7878d4b287cf295d2e22281132140ba4a047db",
"n1_peak": "ba8eddb78265ecc0db1d3bc0e06f48478e69047f7eb124dc523ae19ef930b631",
"n1": "44816d132984ecd64d5a2510223debe102f29decd1d5127ae2b255b45dbf8bd4",
"drain": "8fd86d0816626ee5ae86bc973cb0121b1b5108e56c3b0fdd4b4be6c1f403f748",
"off": "a007f962793611bce15b11f576a76aa52063adcfde82b2a2dc0b0c075b60e60b",
"n2_nobank": "f9053833891794ead8b3f44db4030ded8c4b43681e25e8a19882105d3593f20d",
"n1_peak": "3e451c536bea8270469fffe578c5a7199431e0e7ba4902413406b0afe2bb0fd1",
"n1": "02ecc3011c559df050ae5321ceb6b24cf79342ae35f1a289fbd1a749c86f1d10",
"drain": "bfcf9a643dfffff21d0a8417426d2088377a8f64afbc544732b1635a77775d8a",
"off": "6f4ec14578d8ed59fc7721485d037f3c1fb086abae7c52f28c9e9155cf2107b0",
}
got = {"n2_nobank": d2n, "n1_peak": d1p, "n1": d1, "drain": dd, "off": dm}
for k in AT_BASE:
print(f" {k} {'same' if got[k] == AT_BASE[k] else 'DIFFERS'} "
f"{got[k][:16]}")
f"{got[k]}")
check("N=1 with A in maintenance charge and no forecast entity: equal to "
"release/1.0 1b5f7c9", got["n1"] == AT_BASE["n1"])
check("N=1 in a real peak above a banked month peak (house 6500 W, the guard steps in): equal "