From 8cd10ee6aa4fb36d0b42813f3bdf29d15b160899 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 21:51:56 +0100 Subject: [PATCH] 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")