From 29a8a05f30d2a2c03c49f547069c3e9bda1e89e9 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 13 Nov 2026 02:29:27 +0100 Subject: [PATCH] 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 Co-Authored-By: Claude --- .../MAINT-28-hold-the-pause-in-a-peak.md | 6 ++-- goodwe_controller/app/ctl_cycle.py | 4 ++- goodwe_controller/test_maint28.py | 34 ++++++++++--------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md b/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md index d10406c..5ddf269 100644 --- a/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md +++ b/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md @@ -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. diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index 423d248..09723ca 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -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: diff --git a/goodwe_controller/test_maint28.py b/goodwe_controller/test_maint28.py index 4ed5072..8a72c97 100644 --- a/goodwe_controller/test_maint28.py +++ b/goodwe_controller/test_maint28.py @@ -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 "