From 8cd10ee6aa4fb36d0b42813f3bdf29d15b160899 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 21:51:56 +0100 Subject: [PATCH 01/11] MAINT-28: during a real peak the maintenance charge stays paused until the house is clear of the aim Co-Authored-By: Claude --- .../MAINT-28-hold-the-pause-in-a-peak.md | 21 ++ goodwe_controller/app/ctl_cycle.py | 9 +- goodwe_controller/app/main.py | 1 + goodwe_controller/app/peakguard.py | 18 ++ goodwe_controller/test_maint28.py | 231 ++++++++++++++++++ 5 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md create mode 100644 goodwe_controller/test_maint28.py 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 new file mode 100644 index 0000000..7ae5a78 --- /dev/null +++ b/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md @@ -0,0 +1,21 @@ +# MAINT-28 - during a real peak the maintenance charge stays paused + +**Status: for review.** Tests: `test_maint28.py`. No option added; the peak aim, the month peak and the MAINT-13 numbers are unchanged. MAINT-24 follow-up (rig test finding F-1, `reports/MAINT-24-test-report.md`). + +## What happened (rig x10, house 6.5 kW, month aim 5.95 kW, A in maintenance CHARGE) +`peakguard.shaping_suspended()` pauses the charge while the forecast is over the aim or the guard is engaged. MAINT-24 moved the aim from 3450 W to the month aim (SAFETY-80), so the pause no longer lasts the whole peak: the guard gives, its projection drops under 5950 W and it idles ("heading for 5 6xx W, under this month's peak"), the pause ends, the floor charges A 150-460 W, the grid goes to 6.3-6.9 kW, the guard trips again. A flipped charge/give 468 times in 45 min (base 02eac3b 11). The quarters stayed at the aim; the cost was a battery working against itself and a grid that swung 5.4-6.9 kW. + +## The change +`peakguard.shaping_held(held, decision, forecast, aim, house)`: the old conjunction, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. + +Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house does not move when A switches. + +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. + +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). 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()`, house 3000 -> 6500 W for 45 min -> 3000 W, +-50 W, aim 5950 W, forecast = quarter average so far plus the rest at the present grid; `scratch/MAINT-28/dev/m28_repro.py`) +See the dev report for the table. Live shape (house 3000 W): no pause, A 1475 W, as MAINT-24. + +## 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. diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index 873e746..42e7d59 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -564,8 +564,13 @@ class CycleMixin: maint_aim = peakguard.quarter_target_w( float(self.o.get("peak_cap_w", 3500)), self.peak_margin_w, _reading) - if peakguard.shaping_suspended( - self.peak_guard, self.peak_fc, maint_aim): + # MAINT-28: stays paused until the house is clear of the aim. + house = (None if self.grid is None or self.site_batt_w is None + else float(self.grid) + float(self.site_batt_w)) + self._maint_peak_hold = peakguard.shaping_held( + self._maint_peak_hold, self.peak_guard, self.peak_fc, + maint_aim, house) + if self._maint_peak_hold: # Edge-logged: level-logging here would write one line per # cycle for as long as the event lasts and flush the 40-line # action log of the very context that explains it. diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 9023c48..2d6cece 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -505,6 +505,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self._land_batt_prev = None self._peak_needed = False self._maint_shape_suspended = False + self._maint_peak_hold = False # MAINT-28 # OPT-11: battery modes, off by default (SAFETY-10 section 1). Each # battery's mode and on/off switch live in the store, not in options, # so a change applies at once (`pack_use`, `pack_mode`). diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py index 356569a..cd31f86 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -28,6 +28,10 @@ FLOOR_AT_SOC_FLOOR = "the battery level is at or below the peak reserve" SOURCE = "peak" +# MAINT-28. Watts the house must sit under the aim before a paused maintenance +# charge resumes. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md. +MAINT_RESUME_UNDER_W = 300.0 + # Mirror of config.yaml's peak options, pinned to the file by # test_peakguard.py. Not a read: `decide()` takes them as arguments. PEAK_OPTION_DEFAULTS = { @@ -446,6 +450,20 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool: return decision is not None and decision.engaged +def shaping_held(held, decision, peak_forecast_w, aim_w, house_w) -> bool: + """MAINT-28. `shaping_suspended()`, kept on until the house is + `MAINT_RESUME_UNDER_W` under `aim_w` (`peak_forecast_w` stands in for an + unread house; neither read: not held, as SAFETY-63). Only ever pauses the + maintenance charge, never a cut-back. + DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.""" + if shaping_suspended(decision, peak_forecast_w, aim_w): + return True + if not held: + return False + level = house_w if house_w is not None else peak_forecast_w + return level is not None and level > float(aim_w) - MAINT_RESUME_UNDER_W + + def cap_floor(decision, cap_w): """SAFETY-128: the floor no higher than `cap_w` (the command standing while the readings have not answered it), or `decision` unchanged when None. diff --git a/goodwe_controller/test_maint28.py b/goodwe_controller/test_maint28.py new file mode 100644 index 0000000..81d8297 --- /dev/null +++ b/goodwe_controller/test_maint28.py @@ -0,0 +1,231 @@ +"""Runnable check for MAINT-28. `python3 test_maint28.py` + +MAINT-24 follow-up. With A in maintenance CHARGE and a real peak (house over +the month aim), MAINT-24's pause ended every time the guard idled: the +forecast dipped under the aim, the floor charged A, the guard tripped again, +and A flipped charge/give every few seconds (rig x10: 468 flips in 45 min, +base 02eac3b 11). Now the pause is held (`peakguard.shaping_held()`) until the +house is MAINT_RESUME_UNDER_W under the aim. + +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 (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 + 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. +3. Unchanged: the MAINT-24 live shape (A 1475 W, no pause), N=1, the SAFETY-78 + drain and battery modes off (digests of release/1.0 02eac3b, as + test_maint24.py). +Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md. +""" + +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 main_files # noqa: E402 +import opt11p3_replay as rp # noqa: E402 +import opt20_scen as sc # noqa: E402 +from app import peakguard, tariff # noqa: E402 +from app.maintenance import DRAIN, MaintResult # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +CH = sc.MAINT_CHARGE +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 + + +def stepped_house(base_w, hi_w): + """[(t_utc, W)]: base_w, then hi_w from PRE for LOAD s, +-50 W per 5 s.""" + 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(*, a=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): + """`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).""" + rows = [] + q = dict(q=None, s=0.0, last=None, start=None) + + def patch(c): + if a is not None: + c.maints[0].tick = ( + lambda now, soc_, blocked_by=None, gate_open=True: a) + 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 + susp = [] + c._m28_susp = susp + + async def cyc(): + c.cheap = True + if fc and c.grid is not None: + g = float(c.grid) + if fc == "grid": + c.peak_fc = g + else: + now = main_files.live.datetime.now() + 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() + susp.append(bool(c._maint_shape_suspended)) + c.cycle = cyc + + def cb(c, h, now, pr): + sh = dict(c.allocation.shares) if c.allocation is not None \ + and c.allocation.shares else {} + rows.append(dict( + now=now, grid=h.grid(), shares=sh, + action=getattr(c.arb, "action", None), + reason=getattr(c.arb, "reason", None) or "", + roles=dict(c.mode_tick.roles) if c.mode_tick else {}, + why=c.mode_tick.why if c.mode_tick else None, + p={u: p.p for u, p in h.plants.items()}, + t=(now - T0).total_seconds(), a=h.plants["a"].p, + susp=c._m28_susp[-1])) + + src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)] + stats, c = rp.run(variant, src, sc.WPTS, T0, dur, check=cb, lag=sc.LAG, + n=n, soc0={"a": soc[0], "b": soc[1]}, + choice=sc.CH_CH, refuse=(), patch=patch, + extra={"cycle_cost_threshold_eur": 0.10}) + return rows, stats, c + + +def flips(rs): + """Reversals of A between charging and giving (beyond 50 W).""" + n, s = 0, 0 + for r in rs: + v = 1 if r["a"] > 50 else -1 if r["a"] < -50 else 0 + if v and s and v != s: + n += 1 + if v: + s = v + return n + + +# ============================================================================ # +print("1. a real peak: house 3000 W, 6500 W for 20 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 +trip = next((r["t"] for r in rows if r["t"] >= PRE and r["a"] > 50), None) +load = [r for r in rows if trip is not None and trip <= r["t"] < end] +after = [r for r in rows if r["t"] >= end] +back = next((r["t"] - end for r in after if r["a"] < -50), None) +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} s after it, quarters " + f"{[round(a[0] / a[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", + flips(load) == 0) +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) +check("after the load A's charge is back within 10 cycles", + back is not None and back <= 10.0) +tail = [r["a"] for r in after if r["t"] >= end + 10.0] +check("...and stays on: at least 150 W (half the resume margin), no give", + tail and sum(tail) / len(tail) <= -150.0 and max(tail) <= 50.0) + +# ============================================================================ # +print("\n2. peakguard.shaping_held()") +held = peakguard.shaping_held +U = peakguard.MAINT_RESUME_UNDER_W +eng = peakguard.PeakDecision(engaged=True, floor_w=100.0, ceiling_w=6000.0, + ceiling_from="x", target_w=AIM) +idle = peakguard.PeakDecision(engaged=False, floor_w=None, ceiling_w=6000.0, + ceiling_from="x", target_w=AIM) +check("the old conjunction still pauses: forecast over the aim; guard engaged", + held(False, idle, AIM + 1, AIM, 3000.0) + and held(False, eng, None, AIM, 3000.0)) +check("not held and not suspended: not paused", not held(False, idle, AIM - 1, + AIM, 6500.0)) +check("held: stays paused while the house is over the aim minus the margin", + held(True, idle, AIM - 1, AIM, 6500.0) + and held(True, idle, AIM - 1, AIM, AIM - U + 1)) +check("held: resumes when the house is the margin under the aim", + not held(True, idle, AIM - 1, AIM, AIM - U) + and not held(True, idle, AIM - 1, AIM, 3000.0)) +check("held, house unread: the forecast stands in; neither read: resumes " + "(SAFETY-63)", + held(True, idle, AIM - 1, AIM, None) + and not held(True, idle, AIM - U, AIM, None) + and not held(True, idle, None, AIM, None)) +check("a guard that is engaged pauses whatever the house reads", + held(True, eng, AIM - 1, AIM, 3000.0)) + +# ============================================================================ # +print("\n3. unchanged: the live shape, N=1, the drain, modes off") +rl, sl, cl = scen(a=CH) +body = rl[len(rl) // 6:] +mean_a = sum(r["a"] for r in body) / len(body) +print(f" A {mean_a:+.0f} W, paused {sum(cl._m28_susp)} cycles") +check("MAINT-24 live shape: A charges 1475 W and the pause never comes", + -1500.0 <= mean_a <= -1440.0 and not any(cl._m28_susp)) +d1 = sc.digest(scen(a=CH, n=1, fc=None, peak=None)[0]) +dd = sc.digest(scen(a=DRAIN_R, soc=(60.0, 30.0), peak=None, fc=None)[0]) +dm = sc.digest(scen(a=CH, variant="modes_off", peak=None, fc=None)[0]) +AT_BASE = { + "n1": "44816d132984ecd64d5a2510223debe102f29decd1d5127ae2b255b45dbf8bd4", + "drain": "8fd86d0816626ee5ae86bc973cb0121b1b5108e56c3b0fdd4b4be6c1f403f748", + "off": "a007f962793611bce15b11f576a76aa52063adcfde82b2a2dc0b0c075b60e60b", +} +got = {"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]}") +check("N=1 with A in maintenance charge and no forecast entity: equal to " + "release/1.0 02eac3b", got["n1"] == AT_BASE["n1"]) +check("the SAFETY-78 drain beside a charger: equal to 02eac3b", + got["drain"] == AT_BASE["drain"]) +check("battery modes off: equal to 02eac3b", got["off"] == AT_BASE["off"]) + +print() +if fails: + print(f"{len(fails)} FAILED:") + for f in fails: + print(f" - {f}") + sys.exit(1) +print("all checks passed") -- 2.54.0 From 4f9aa5223fb9220cb7ea51aa3d8f78995fd2eb43 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 22:28:01 +0100 Subject: [PATCH 02/11] MAINT-28: the hold is for N=2 and up (SAFETY-87 N=1 pins), N=1 peak digest test Co-Authored-By: Claude --- .../DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md | 2 +- goodwe_controller/app/ctl_cycle.py | 4 +++- goodwe_controller/test_maint28.py | 6 +++++- 3 files changed, 9 insertions(+), 3 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 7ae5a78..4554c0e 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 @@ -6,7 +6,7 @@ `peakguard.shaping_suspended()` pauses the charge while the forecast is over the aim or the guard is engaged. MAINT-24 moved the aim from 3450 W to the month aim (SAFETY-80), so the pause no longer lasts the whole peak: the guard gives, its projection drops under 5950 W and it idles ("heading for 5 6xx W, under this month's peak"), the pause ends, the floor charges A 150-460 W, the grid goes to 6.3-6.9 kW, the guard trips again. A flipped charge/give 468 times in 45 min (base 02eac3b 11). The quarters stayed at the aim; the cost was a battery working against itself and a grid that swung 5.4-6.9 kW. ## The change -`peakguard.shaping_held(held, decision, forecast, aim, house)`: the old conjunction, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. +`peakguard.shaping_held(held, decision, forecast, aim, house)`: the old conjunction, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. The hold is for N=2 and up: at one inverter the old rule stays, because SAFETY-87's N=1 digests (`n1_charge_low`, a real peak at N=1) are pinned byte for byte. Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house does not move when A switches. diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index 42e7d59..b5caa41 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -565,10 +565,12 @@ class CycleMixin: float(self.o.get("peak_cap_w", 3500)), self.peak_margin_w, _reading) # MAINT-28: stays paused until the house is clear of the aim. + # N=2 and up; one inverter keeps the old rule (SAFETY-87 pins). house = (None if self.grid is None or self.site_batt_w is None else float(self.grid) + float(self.site_batt_w)) self._maint_peak_hold = peakguard.shaping_held( - self._maint_peak_hold, self.peak_guard, self.peak_fc, + self._maint_peak_hold and len(self.inverters) > 1, + self.peak_guard, self.peak_fc, maint_aim, house) if self._maint_peak_hold: # Edge-logged: level-logging here would write one line per diff --git a/goodwe_controller/test_maint28.py b/goodwe_controller/test_maint28.py index 81d8297..3605176 100644 --- a/goodwe_controller/test_maint28.py +++ b/goodwe_controller/test_maint28.py @@ -207,17 +207,21 @@ check("MAINT-24 live shape: A charges 1475 W and the pause never comes", d1 = sc.digest(scen(a=CH, n=1, fc=None, peak=None)[0]) dd = sc.digest(scen(a=DRAIN_R, soc=(60.0, 30.0), peak=None, fc=None)[0]) dm = sc.digest(scen(a=CH, variant="modes_off", peak=None, fc=None)[0]) +d1p = sc.digest(scen(a=CH, n=1, fc=None, peak=None, house_w=5000.0)[0]) AT_BASE = { + "n1_peak": "8d266f39eb880f80fad8d236b2fb5100934cb92efd8ed2082e85eb3b71cd6c45", "n1": "44816d132984ecd64d5a2510223debe102f29decd1d5127ae2b255b45dbf8bd4", "drain": "8fd86d0816626ee5ae86bc973cb0121b1b5108e56c3b0fdd4b4be6c1f403f748", "off": "a007f962793611bce15b11f576a76aa52063adcfde82b2a2dc0b0c075b60e60b", } -got = {"n1": d1, "drain": dd, "off": dm} +got = {"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]}") check("N=1 with A in maintenance charge and no forecast entity: equal to " "release/1.0 02eac3b", got["n1"] == AT_BASE["n1"]) +check("N=1 in a real peak (house 5000 W, a guard that steps in): equal " + "to 02eac3b, the hold is for N=2 and up", got["n1_peak"] == AT_BASE["n1_peak"]) check("the SAFETY-78 drain beside a charger: equal to 02eac3b", got["drain"] == AT_BASE["drain"]) check("battery modes off: equal to 02eac3b", got["off"] == AT_BASE["off"]) -- 2.54.0 From 92f038741532b07f2282f4cda525adc4eff08d36 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 22:44:32 +0100 Subject: [PATCH 03/11] MAINT-28: reset the hold every cycle, test it; digests vs 1b5f7c9; DECISIONS numbers and mutants Co-Authored-By: Claude --- .../MAINT-28-hold-the-pause-in-a-peak.md | 23 +++++-- goodwe_controller/app/ctl_cycle.py | 4 +- goodwe_controller/test_maint28.py | 66 +++++++++++++++---- 3 files changed, 74 insertions(+), 19 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 4554c0e..35b4497 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 @@ -8,14 +8,29 @@ ## The change `peakguard.shaping_held(held, decision, forecast, aim, house)`: the old conjunction, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. The hold is for N=2 and up: at one inverter the old rule stays, because SAFETY-87's N=1 digests (`n1_charge_low`, a real peak at N=1) are pinned byte for byte. -Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house does not move when A switches. +Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house is the meter reading plus the batteries' power (`grid + site_batt_w`): the net demand the meter would see with no battery at work. Solar is already in it, and that is right: the aim is a limit on what the meter takes, so a sunny hour that lowers the net demand is real room, and a cloud that raises it brings the hold back at once. It is not the gross load, and it would be wrong to add PV back (that would hold the pause on room the meter really has). It stays put when A switches, except for the age of the battery reading (up to 10 s on the rig); the 300 W line is far larger than that lag moves it. 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. -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). A cap on that give would conflict with the guard's `lo` (`lo > hi` resolves to 0 W), so it is not tried here. +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: at one inverter the old rule stays, because SAFETY-87's N=1 digests (`n1_charge_low`, a real peak at N=1) are pinned byte for byte. -## Numbers (sim, real `cycle()`, house 3000 -> 6500 W for 45 min -> 3000 W, +-50 W, aim 5950 W, forecast = quarter average so far plus the rest at the present grid; `scratch/MAINT-28/dev/m28_repro.py`) -See the dev report for the table. Live shape (house 3000 W): no pause, A 1475 W, as MAINT-24. +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). + +| 45 min load | base 02eac3b | 1b5f7c9 | MAINT-28 | +|---|---|---|---| +| A charge/give reversals | 0 | 52 | 1 (the first trip) | +| A charging seconds in the load (of 2700) | 0 | 367 | 301 (before the first trip) | +| quarter averages (W) | 5959 5950 5950 5438 5502 | 5356 5923 5924 5041 4479 | 5356 4012 4000 4226 4476 | +| A out / in (kWh) | 0.65 / 0 | 0.45 / 0.51 | 1.67 / 0.56 | +| charge back after the load | - | 3 s | 3 s | + +Rig (MAINT-24 test report, F-1): 468 reversals in 45 min on 1b5f7c9, 11 on 02eac3b. Live shape (house 3000 W): no pause, A 1475 W, as MAINT-24. + +## Tests and mutants +`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 (`shaping_held(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`). ## 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. diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index b5caa41..60def72 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -537,6 +537,8 @@ 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. + _peak_held, self._maint_peak_hold = self._maint_peak_hold, False for inv, result in zip(self.inverters, maint_results): if result.owns_setpoint: # PER-INVERTER: §12's recalibration is a PACK procedure, filed @@ -569,7 +571,7 @@ class CycleMixin: house = (None if self.grid is None or self.site_batt_w is None else float(self.grid) + float(self.site_batt_w)) self._maint_peak_hold = peakguard.shaping_held( - self._maint_peak_hold and len(self.inverters) > 1, + _peak_held and len(self.inverters) > 1, self.peak_guard, self.peak_fc, maint_aim, house) if self._maint_peak_hold: diff --git a/goodwe_controller/test_maint28.py b/goodwe_controller/test_maint28.py index 3605176..c38e6a0 100644 --- a/goodwe_controller/test_maint28.py +++ b/goodwe_controller/test_maint28.py @@ -17,7 +17,7 @@ present grid), house +-50 W every 5 s. 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. 3. Unchanged: the MAINT-24 live shape (A 1475 W, no pause), N=1, the SAFETY-78 - drain and battery modes off (digests of release/1.0 02eac3b, as + drain and battery modes off (digests of release/1.0 1b5f7c9, as test_maint24.py). Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md. """ @@ -36,7 +36,7 @@ import main_files # noqa: E402 import opt11p3_replay as rp # noqa: E402 import opt20_scen as sc # noqa: E402 from app import peakguard, tariff # noqa: E402 -from app.maintenance import DRAIN, MaintResult # noqa: E402 +from app.maintenance import DRAIN, IDLE, MaintResult # noqa: E402 fails = [] @@ -65,7 +65,7 @@ def stepped_house(base_w, hi_w): return out -def scen(*, a=None, soc=(49.0, 30.0), house=None, house_w=3000.0, +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): """`a`: A's forced maintenance result. `fc`: "grid" (follows the grid), "quarter" (quarter average so far, the rest at the present grid, like the @@ -74,7 +74,10 @@ def scen(*, a=None, soc=(49.0, 30.0), house=None, house_w=3000.0, q = dict(q=None, s=0.0, last=None, start=None) def patch(c): - if a is not None: + if tick is not None: + c.maints[0].tick = ( + lambda now, soc_, blocked_by=None, gate_open=True: tick(now)) + elif a is not None: c.maints[0].tick = ( lambda now, soc_, blocked_by=None, gate_open=True: a) if peak is not None: @@ -119,7 +122,7 @@ def scen(*, a=None, soc=(49.0, 30.0), house=None, house_w=3000.0, why=c.mode_tick.why if c.mode_tick else None, p={u: p.p for u, p in h.plants.items()}, t=(now - T0).total_seconds(), a=h.plants["a"].p, - susp=c._m28_susp[-1])) + susp=c._m28_susp[-1], hold=bool(getattr(c, "_maint_peak_hold", False)))) src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)] stats, c = rp.run(variant, src, sc.WPTS, T0, dur, check=cb, lag=sc.LAG, @@ -149,10 +152,10 @@ end = PRE + LOAD trip = next((r["t"] for r in rows if r["t"] >= PRE and r["a"] > 50), None) load = [r for r in rows if trip is not None and trip <= r["t"] < end] after = [r for r in rows if r["t"] >= end] -back = next((r["t"] - end for r in after if r["a"] < -50), None) +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} s after it, quarters " + f"{back} cycles after it, quarters " f"{[round(a[0] / a[1]) for a in st['quarters'].values()]}") check("the guard stepped in once the projection went over the aim", trip is not None) @@ -163,12 +166,47 @@ 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) -check("after the load A's charge is back within 10 cycles", - back is not None and back <= 10.0) -tail = [r["a"] for r in after if r["t"] >= end + 10.0] +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:]] check("...and stays on: at least 150 W (half the resume margin), no give", tail and sum(tail) / len(tail) <= -150.0 and max(tail) <= 50.0) +# ============================================================================ # +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 + + +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 + out.append((T0.timestamp() + t, w + rnd.uniform(-50.0, 50.0))) + return out + + +def _tick_b(now): + t = (now - T0).total_seconds() + return MaintResult(IDLE, False, None, False, []) \ + 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"]] +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] +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)") +check("the hold was on in the peak", len(held_before) > 100) +check("it is off in every cycle the charge-only branch is not taken", + not any(r["hold"] for r in idle_rows)) +check("a new maintenance charge at house 5700 W (over the resume line) " + "starts at once: (5950 - 5700) / 2 = 125 W, not paused", + sum(later) / len(later) <= -60.0) + # ============================================================================ # print("\n2. peakguard.shaping_held()") held = peakguard.shaping_held @@ -219,12 +257,12 @@ for k in AT_BASE: print(f" {k} {'same' if got[k] == AT_BASE[k] else 'DIFFERS'} " f"{got[k][:16]}") check("N=1 with A in maintenance charge and no forecast entity: equal to " - "release/1.0 02eac3b", got["n1"] == AT_BASE["n1"]) + "release/1.0 1b5f7c9", got["n1"] == AT_BASE["n1"]) check("N=1 in a real peak (house 5000 W, a guard that steps in): equal " - "to 02eac3b, the hold is for N=2 and up", got["n1_peak"] == AT_BASE["n1_peak"]) -check("the SAFETY-78 drain beside a charger: equal to 02eac3b", + "to 1b5f7c9, the hold is for N=2 and up", got["n1_peak"] == AT_BASE["n1_peak"]) +check("the SAFETY-78 drain beside a charger: equal to 1b5f7c9", got["drain"] == AT_BASE["drain"]) -check("battery modes off: equal to 02eac3b", got["off"] == AT_BASE["off"]) +check("battery modes off: equal to 1b5f7c9", got["off"] == AT_BASE["off"]) print() if fails: -- 2.54.0 From 8640bfcbc1c260b2af4f9700d48aa665c3ba3c90 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 23:13:57 +0100 Subject: [PATCH 04/11] MAINT-28: hold only where MAINT-24 moved the aim (banked peak above the cap); test_safety119 pins stay Co-Authored-By: Claude --- .../DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md | 6 +++--- goodwe_controller/app/ctl_cycle.py | 7 +++++-- goodwe_controller/test_maint28.py | 7 ++++++- 3 files changed, 14 insertions(+), 6 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 35b4497..386b1aa 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 @@ -6,13 +6,13 @@ `peakguard.shaping_suspended()` pauses the charge while the forecast is over the aim or the guard is engaged. MAINT-24 moved the aim from 3450 W to the month aim (SAFETY-80), so the pause no longer lasts the whole peak: the guard gives, its projection drops under 5950 W and it idles ("heading for 5 6xx W, under this month's peak"), the pause ends, the floor charges A 150-460 W, the grid goes to 6.3-6.9 kW, the guard trips again. A flipped charge/give 468 times in 45 min (base 02eac3b 11). The quarters stayed at the aim; the cost was a battery working against itself and a grid that swung 5.4-6.9 kW. ## The change -`peakguard.shaping_held(held, decision, forecast, aim, house)`: the old conjunction, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. The hold is for N=2 and up: at one inverter the old rule stays, because SAFETY-87's N=1 digests (`n1_charge_low`, a real peak at N=1) are pinned byte for byte. +`peakguard.shaping_held(held, decision, forecast, aim, house)`: the old conjunction, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. 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). Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house is the meter reading plus the batteries' power (`grid + site_batt_w`): the net demand the meter would see with no battery at work. Solar is already in it, and that is right: the aim is a limit on what the meter takes, so a sunny hour that lowers the net demand is real room, and a cloud that raises it brings the hold back at once. It is not the gross load, and it would be wrong to add PV back (that would hold the pause on room the meter really has). It stays put when A switches, except for the age of the battery reading (up to 10 s on the rig); the 300 W line is far larger than that lag moves it. 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: at one inverter the old rule stays, because SAFETY-87's N=1 digests (`n1_charge_low`, a real peak at N=1) are pinned byte for byte. +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). 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. @@ -30,7 +30,7 @@ House 3000 -> 6500 W for 45 min -> 3000 W, +-50 W every 5 s, banked month peak 6 Rig (MAINT-24 test report, F-1): 468 reversals in 45 min on 1b5f7c9, 11 on 02eac3b. Live shape (house 3000 W): no pause, A 1475 W, as MAINT-24. ## Tests and mutants -`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 (`shaping_held(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`). +`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 (`shaping_held(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`); 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. diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index 60def72..006c919 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -567,11 +567,14 @@ class CycleMixin: float(self.o.get("peak_cap_w", 3500)), self.peak_margin_w, _reading) # MAINT-28: stays paused until the house is clear of the aim. - # N=2 and up; one inverter keeps the old rule (SAFETY-87 pins). + # Only where MAINT-24 moved the aim (a banked peak above the + # cap) and at N=2 and up: elsewhere the old rule, byte for byte + # (SAFETY-87 / SAFETY-119 pins). house = (None if self.grid is None or self.site_batt_w is None else float(self.grid) + float(self.site_batt_w)) self._maint_peak_hold = peakguard.shaping_held( - _peak_held and len(self.inverters) > 1, + _peak_held and len(self.inverters) > 1 + and maint_aim > self.peak_aim_w(), self.peak_guard, self.peak_fc, maint_aim, house) if self._maint_peak_hold: diff --git a/goodwe_controller/test_maint28.py b/goodwe_controller/test_maint28.py index c38e6a0..bcd16f5 100644 --- a/goodwe_controller/test_maint28.py +++ b/goodwe_controller/test_maint28.py @@ -245,14 +245,16 @@ check("MAINT-24 live shape: A charges 1475 W and the pause never comes", d1 = sc.digest(scen(a=CH, n=1, fc=None, peak=None)[0]) dd = sc.digest(scen(a=DRAIN_R, soc=(60.0, 30.0), peak=None, fc=None)[0]) 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, peak=None, house_w=5000.0)[0]) AT_BASE = { + "n2_nobank": "c164c9d4f221f7d685a0aa904a7878d4b287cf295d2e22281132140ba4a047db", "n1_peak": "8d266f39eb880f80fad8d236b2fb5100934cb92efd8ed2082e85eb3b71cd6c45", "n1": "44816d132984ecd64d5a2510223debe102f29decd1d5127ae2b255b45dbf8bd4", "drain": "8fd86d0816626ee5ae86bc973cb0121b1b5108e56c3b0fdd4b4be6c1f403f748", "off": "a007f962793611bce15b11f576a76aa52063adcfde82b2a2dc0b0c075b60e60b", } -got = {"n1_peak": d1p, "n1": d1, "drain": dd, "off": dm} +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]}") @@ -260,6 +262,9 @@ 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 (house 5000 W, a guard that steps in): equal " "to 1b5f7c9, the hold is for N=2 and up", got["n1_peak"] == AT_BASE["n1_peak"]) +check("N=2 in a real peak with no banked peak (house 5000 W over the aim " + "3450 W): equal to 1b5f7c9, the hold is for a month aim above the cap", + got["n2_nobank"] == AT_BASE["n2_nobank"]) check("the SAFETY-78 drain beside a charger: equal to 1b5f7c9", got["drain"] == AT_BASE["drain"]) check("battery modes off: equal to 1b5f7c9", got["off"] == AT_BASE["off"]) -- 2.54.0 From 23359a047d2752589607258e0e49fd72a707e4f4 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 23:29:13 +0100 Subject: [PATCH 05/11] MAINT-28: test_maint28 points at MUTATION.md (test_bytecode) Co-Authored-By: Claude --- goodwe_controller/test_maint28.py | 1 + 1 file changed, 1 insertion(+) diff --git a/goodwe_controller/test_maint28.py b/goodwe_controller/test_maint28.py index bcd16f5..284e851 100644 --- a/goodwe_controller/test_maint28.py +++ b/goodwe_controller/test_maint28.py @@ -20,6 +20,7 @@ present grid), house +-50 W every 5 s. drain and battery modes off (digests of release/1.0 1b5f7c9, as test_maint24.py). Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md. +Read MUTATION.md before you report a survivor. """ import logging -- 2.54.0 From edf30f345a8519f300e8c0d5b26dd4748fdbe95d Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 23:58:19 +0100 Subject: [PATCH 06/11] MAINT-28: keep the single shaping_suspended(.., maint_aim) call site (test_peak_margin pin); shaping_held takes its answer Co-Authored-By: Claude --- .../DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md | 2 +- goodwe_controller/app/ctl_cycle.py | 7 ++++--- goodwe_controller/app/peakguard.py | 12 ++++++------ goodwe_controller/test_maint28.py | 8 +++++++- 4 files changed, 18 insertions(+), 11 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 386b1aa..fba51c6 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 @@ -6,7 +6,7 @@ `peakguard.shaping_suspended()` pauses the charge while the forecast is over the aim or the guard is engaged. MAINT-24 moved the aim from 3450 W to the month aim (SAFETY-80), so the pause no longer lasts the whole peak: the guard gives, its projection drops under 5950 W and it idles ("heading for 5 6xx W, under this month's peak"), the pause ends, the floor charges A 150-460 W, the grid goes to 6.3-6.9 kW, the guard trips again. A flipped charge/give 468 times in 45 min (base 02eac3b 11). The quarters stayed at the aim; the cost was a battery working against itself and a grid that swung 5.4-6.9 kW. ## The change -`peakguard.shaping_held(held, decision, forecast, aim, house)`: the old conjunction, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. 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). +`peakguard.shaping_held(suspended, held, forecast, aim, house)`: `suspended` is the old `shaping_suspended()`, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. 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). Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house is the meter reading plus the batteries' power (`grid + site_batt_w`): the net demand the meter would see with no battery at work. Solar is already in it, and that is right: the aim is a limit on what the meter takes, so a sunny hour that lowers the net demand is real room, and a cloud that raises it brings the hold back at once. It is not the gross load, and it would be wrong to add PV back (that would hold the pause on room the meter really has). It stays put when A switches, except for the age of the battery reading (up to 10 s on the rig); the 300 W line is far larger than that lag moves it. diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index 006c919..423d248 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -572,11 +572,12 @@ class CycleMixin: # (SAFETY-87 / SAFETY-119 pins). house = (None if self.grid is None or self.site_batt_w is None else float(self.grid) + float(self.site_batt_w)) + _susp = peakguard.shaping_suspended( + self.peak_guard, self.peak_fc, maint_aim) self._maint_peak_hold = peakguard.shaping_held( - _peak_held and len(self.inverters) > 1 + _susp, _peak_held and len(self.inverters) > 1 and maint_aim > self.peak_aim_w(), - self.peak_guard, self.peak_fc, - maint_aim, house) + self.peak_fc, maint_aim, house) if self._maint_peak_hold: # Edge-logged: level-logging here would write one line per # cycle for as long as the event lasts and flush the 40-line diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py index cd31f86..6f052aa 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -450,13 +450,13 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool: return decision is not None and decision.engaged -def shaping_held(held, decision, peak_forecast_w, aim_w, house_w) -> bool: - """MAINT-28. `shaping_suspended()`, kept on until the house is - `MAINT_RESUME_UNDER_W` under `aim_w` (`peak_forecast_w` stands in for an - unread house; neither read: not held, as SAFETY-63). Only ever pauses the - maintenance charge, never a cut-back. +def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w) -> bool: + """MAINT-28. `suspended` (`shaping_suspended()`'s answer), kept on while + `held` until the house is `MAINT_RESUME_UNDER_W` under `aim_w` + (`peak_forecast_w` stands in for an unread house; neither read: not held, + as SAFETY-63). Only ever pauses the maintenance charge, never a cut-back. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.""" - if shaping_suspended(decision, peak_forecast_w, aim_w): + if suspended: return True if not held: return False diff --git a/goodwe_controller/test_maint28.py b/goodwe_controller/test_maint28.py index 284e851..f4a8c4f 100644 --- a/goodwe_controller/test_maint28.py +++ b/goodwe_controller/test_maint28.py @@ -210,7 +210,13 @@ check("a new maintenance charge at house 5700 W (over the resume line) " # ============================================================================ # print("\n2. peakguard.shaping_held()") -held = peakguard.shaping_held + + +def held(was, decision, fc, aim, house): + return peakguard.shaping_held( + peakguard.shaping_suspended(decision, fc, aim), was, fc, aim, house) + + U = peakguard.MAINT_RESUME_UNDER_W eng = peakguard.PeakDecision(engaged=True, floor_w=100.0, ceiling_w=6000.0, ceiling_from="x", target_w=AIM) -- 2.54.0 From bc5be4964e9ecc078439a164c9e74c1ccb321245 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 13 Nov 2026 00:20:21 +0100 Subject: [PATCH 07/11] MAINT-28: N=1 digest test with a banked peak (so the N=1 gate is tested) Co-Authored-By: Claude --- .../DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md | 2 +- goodwe_controller/test_maint28.py | 6 +++--- 2 files changed, 4 insertions(+), 4 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 fba51c6..d10406c 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 @@ -30,7 +30,7 @@ House 3000 -> 6500 W for 45 min -> 3000 W, +-50 W every 5 s, banked month peak 6 Rig (MAINT-24 test report, F-1): 468 reversals in 45 min on 1b5f7c9, 11 on 02eac3b. Live shape (house 3000 W): no pause, A 1475 W, as MAINT-24. ## Tests and mutants -`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 (`shaping_held(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`); M6 the aim gate removed (digest `n2_nobank`). +`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. diff --git a/goodwe_controller/test_maint28.py b/goodwe_controller/test_maint28.py index f4a8c4f..4ed5072 100644 --- a/goodwe_controller/test_maint28.py +++ b/goodwe_controller/test_maint28.py @@ -253,10 +253,10 @@ d1 = sc.digest(scen(a=CH, n=1, fc=None, peak=None)[0]) dd = sc.digest(scen(a=DRAIN_R, soc=(60.0, 30.0), peak=None, fc=None)[0]) 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, peak=None, house_w=5000.0)[0]) +d1p = sc.digest(scen(a=CH, n=1, fc=None, house_w=6500.0)[0]) AT_BASE = { "n2_nobank": "c164c9d4f221f7d685a0aa904a7878d4b287cf295d2e22281132140ba4a047db", - "n1_peak": "8d266f39eb880f80fad8d236b2fb5100934cb92efd8ed2082e85eb3b71cd6c45", + "n1_peak": "ba8eddb78265ecc0db1d3bc0e06f48478e69047f7eb124dc523ae19ef930b631", "n1": "44816d132984ecd64d5a2510223debe102f29decd1d5127ae2b255b45dbf8bd4", "drain": "8fd86d0816626ee5ae86bc973cb0121b1b5108e56c3b0fdd4b4be6c1f403f748", "off": "a007f962793611bce15b11f576a76aa52063adcfde82b2a2dc0b0c075b60e60b", @@ -267,7 +267,7 @@ for k in AT_BASE: f"{got[k][:16]}") 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 (house 5000 W, a guard that steps in): equal " +check("N=1 in a real peak above a banked month peak (house 6500 W, the guard steps in): equal " "to 1b5f7c9, the hold is for N=2 and up", got["n1_peak"] == AT_BASE["n1_peak"]) check("N=2 in a real peak with no banked peak (house 5000 W over the aim " "3450 W): equal to 1b5f7c9, the hold is for a month aim above the cap", -- 2.54.0 From f5bd2a3520788b1c4970124920e684607ad4669d Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 13 Nov 2026 00:59:41 +0100 Subject: [PATCH 08/11] MAINT-28: re-pin test_safety84 n2_charge_banked (the charge no longer restarts in the 5000 W load) Co-Authored-By: Claude --- goodwe_controller/test_safety84.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/goodwe_controller/test_safety84.py b/goodwe_controller/test_safety84.py index 47b0cdf..0b0c128 100644 --- a/goodwe_controller/test_safety84.py +++ b/goodwe_controller/test_safety84.py @@ -481,6 +481,10 @@ print("\n8. the N=1 and N=2 digests") # quarter's room, as at N=2, so the guard no longer steps in and discharges. # The banked quarters go 3461.9 -> 3450.0 W (aim 3450) and 3938.9 -> 3950.0 # W (aim 3950). n1_charge_dear (no cheap window) is unchanged. +# 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. PINNED = { "n1_charge": "00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9", @@ -493,7 +497,7 @@ PINNED = { "n2_charge_dear": "aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db", "n2_charge_banked": - "a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f", + "5d523562219245c135a4e9aa856826bc2939a3157c6ae0320cafc2cd1a8a6cf1", } _now = s84.digests() for k in PINNED: -- 2.54.0 From 29a8a05f30d2a2c03c49f547069c3e9bda1e89e9 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 13 Nov 2026 02:29:27 +0100 Subject: [PATCH 09/11] 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 " -- 2.54.0 From 7edf8484d110cdeb23f15f91856e8d9b1da92930 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 13 Nov 2026 03:57:31 +0100 Subject: [PATCH 10/11] MAINT-28: test_maint28 reads the replay clock directly (main_files.live parsed every app file per cycle): 269 s -> 28 s, same numbers Co-Authored-By: Claude --- goodwe_controller/test_maint28.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/goodwe_controller/test_maint28.py b/goodwe_controller/test_maint28.py index 8a72c97..fc29cf1 100644 --- a/goodwe_controller/test_maint28.py +++ b/goodwe_controller/test_maint28.py @@ -33,10 +33,9 @@ sys.path.insert(0, HERE) sys.path.insert(0, os.path.join(HERE, "tools")) logging.disable(logging.WARNING) -import main_files # noqa: E402 import opt11p3_replay as rp # noqa: E402 import opt20_scen as sc # noqa: E402 -from app import peakguard, tariff # noqa: E402 +from app import ctl_cycle, peakguard, tariff # noqa: E402 from app.maintenance import DRAIN, IDLE, MaintResult # noqa: E402 fails = [] @@ -100,7 +99,9 @@ def scen(*, a=None, tick=None, soc=(49.0, 30.0), house=None, house_w=3000.0, if fc == "grid": c.peak_fc = g else: - now = main_files.live.datetime.now() + # the replay's clock; not main_files.live (it parses + # every app file per access: ~0.1 s a cycle). + now = ctl_cycle.datetime.now() qi = int(now.timestamp() // 900) if q["q"] != qi: q.update(q=qi, s=0.0, last=now, start=now) -- 2.54.0 From e7706150e89e745aa13b925fea833139aa98a89a Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 13 Nov 2026 05:53:55 +0100 Subject: [PATCH 11/11] MAINT-28: DECISIONS gives the measured reason for the uncapped give (review M-1); ctl_cycle comment one line + pointer (L-1) Co-Authored-By: Claude --- .../DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md | 2 +- goodwe_controller/app/ctl_cycle.py | 6 ++---- 2 files changed, 3 insertions(+), 5 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 5ddf269..5cbf248 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 @@ -14,7 +14,7 @@ Why 300 W: the house wanders +-50 W; once the charge resumes it settles at (aim 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. +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). Measured alternative, A capped at 0 W while held and the guard idle (review scratch, same 45 min run): A gives 0.44 kWh in the load instead of 1.66, quarters 5919/5915 W, but A switches give/idle 32 times in 40 min. The uncapped give costs ~1.2 kWh round trip per 45-min peak (a few cents) and ~50 min more maintenance charge, far inside the 36 h timeout, so the steady give is kept. ## 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 15 min to keep the suite short). diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index 09723ca..a8928a7 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -568,10 +568,8 @@ class CycleMixin: maint_aim = peakguard.quarter_target_w( float(self.o.get("peak_cap_w", 3500)), self.peak_margin_w, _reading) - # MAINT-28: stays paused until the house is clear of the aim. - # Only where MAINT-24 moved the aim (a banked peak above the - # cap) and at N=2 and up: elsewhere the old rule, byte for byte - # (SAFETY-87 / SAFETY-119 pins). + # MAINT-28: hold the pause in a real peak. + # DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md house = (None if self.grid is None or self.site_batt_w is None else float(self.grid) + float(self.site_batt_w)) _susp = peakguard.shaping_suspended( -- 2.54.0