From 34b81e8aebdfccd2da66bc78be13054a4e5245cc Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 13 Nov 2026 16:29:43 +0100 Subject: [PATCH 1/8] MAINT-28 fix-forward: the peak pause ends only on a settled house estimate, and a pack under its peak reserve gives nothing for the house while it holds Rig FAIL (TEST-23, f3f9483). F-1: the hold ended at each guard step because grid + site_batt_w added a new meter reading to a battery reading up to 10 s old. It now ends only when no battery reading may pre-date a step up of its command (Controller._batts_settled()). F-2: while the pause holds, a pack in maintenance CHARGE at or under its peak reserve is limited to 0 W, so it gives only what the guard claims for the peak. Sim: Lag.batt_period_s (a held 10 s register). Tests: test_maint28b.py. DECISIONS updated. Co-Authored-By: Claude --- .../MAINT-28-hold-the-pause-in-a-peak.md | 13 +- goodwe_controller/app/ctl_cycle.py | 18 +- goodwe_controller/app/ctl_trade.py | 36 ++ goodwe_controller/app/main.py | 1 + goodwe_controller/app/peakguard.py | 11 +- goodwe_controller/test_maint28b.py | 314 ++++++++++++++++++ goodwe_controller/tools/opt11p3_replay.py | 14 +- 7 files changed, 395 insertions(+), 12 deletions(-) create mode 100644 goodwe_controller/test_maint28b.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 index 5cbf248..ea464a8 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 @@ -1,6 +1,6 @@ # 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`). +**Status: fix-forward after the rig FAIL (TEST-23, f3f9483).** Tests: `test_maint28.py`, `test_maint28b.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. @@ -8,7 +8,7 @@ ## The change `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. +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 does not stay put when a battery steps: see "Fix-forward" below. 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. @@ -34,3 +34,12 @@ Rig (MAINT-24 test report, F-1): 468 reversals in 45 min on 1b5f7c9, 11 on 02eac ## Not changed MAINT-24's rate and its tests, N=1, the drain, modes off (digests of 1b5f7c9). The pause for a late or missing forecast. + +## Fix-forward (rig FAIL F-1, F-2; `reports/MAINT-28-test-report.md`) +The sim had no battery-reading lag, and the first version said the 300 W line is far larger than the lag moves the estimate. That was wrong. The rig publishes a battery's power every 10 s; the meter is newer. After each guard step charge -> give (+500-1000 W) the next meter reading shows the give and the battery reading still shows the charge, so `grid + site_batt_w` drops by the step, under the 5650 W line: 69/69, 132/133, 215/215, 51/51 releases at a median 5430 W with the house at 6450 W or more. Test: `test_maint28b.py`, sim `Lag.batt_period_s`. + +**F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 13 s after the load (7 s in the test), not 0: the first settled estimate. No cut-back is touched. + +**F-2: under its peak reserve the pack gives nothing for the house.** While the pause holds (N>=2, month aim above the cap), a pack in maintenance CHARGE that `peakguard.reserve_split()` holds gets `hi` = 0 W (P_MAINT_SHAPE). The guard's own claim for the peak is on the other packs (`place_on_able()`), so it is unchanged. Above the reserve nothing changes. Sim, B charging at 18 %: f3f9483 gave 1.89 kWh and went to 10.0 % (the log said "giving nothing for the peak"); now 0.00 kWh, 18.2 %, 0 give/idle flips in 45 min (the full cap measured by the reviewer: 32 in 40 min), quarters 5948 and 5952 W (aim 5950). + +Mutants, each red in `test_maint28b.py`: the freshness check dropped (always settled); the reserve claim dropped. diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index a8928a7..228d495 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -574,10 +574,11 @@ class CycleMixin: else float(self.grid) + float(self.site_batt_w)) _susp = peakguard.shaping_suspended( self.peak_guard, self.peak_fc, maint_aim) + _m28 = (len(self.inverters) > 1 + and maint_aim > self.peak_aim_w()) self._maint_peak_hold = peakguard.shaping_held( - _susp, _peak_held and len(self.inverters) > 1 - and maint_aim > self.peak_aim_w(), - self.peak_fc, maint_aim, house) + _susp, _peak_held and _m28, self.peak_fc, maint_aim, + house, self._batts_settled()) 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 @@ -588,6 +589,11 @@ class CycleMixin: "suspended so the peak claim is not " "contradicted", hint=messages.hint("maint_paused")) + # MAINT-28: under its peak reserve this pack gives only + # what the guard claims. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md + shape = ((0.0, "peak reserve") + if _m28 and inv.name in self._under_reserve() + else None) else: self._maint_shape_suspended = False hi = 0.0 @@ -604,10 +610,12 @@ class CycleMixin: floor = self._charge_headroom(floor, claims) if floor > 0: hi, reason = -floor, "cheap-window charge" + shape = (hi, reason) + if shape is not None: # PER-INVERTER, like the SET above and for the same reason - # the shaping is about the pack being charged, which is `inv`. - claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, - reason=reason, + claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, + hi=shape[0], reason=shape[1], actuator=self.actuator_of("maintenance", inv))) diff --git a/goodwe_controller/app/ctl_trade.py b/goodwe_controller/app/ctl_trade.py index 09b92f6..096333e 100644 --- a/goodwe_controller/app/ctl_trade.py +++ b/goodwe_controller/app/ctl_trade.py @@ -19,6 +19,7 @@ from . import packmode from . import priceruns from . import solarfc from . import control as _ctl +from . import freshness from . import messages from .maintenance import CHARGE, DRAIN, HOLD, IDLE, MaintConfig, POWER_MOVING from .mqtt import MODE_LABELS, USE_OFF, USE_ON, unit_slugs, unit_state_key @@ -49,6 +50,11 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S HOUSE_PROFILE_KEY = "house_profile" +# MAINT-28: a step of a battery's command, in W, after which its reading is not +# current until a changed one arrives (`Controller._batts_settled()`). +MAINT_STEP_W = 100.0 + + # OPT-34: the level of a night grid charge that has started, so it does not # drop when the forecast goes (`Controller._latch_charge()`). CHARGE_LATCH_KEY = "night_charge_latch" @@ -711,6 +717,36 @@ class TradeMixin: lo = hi = float(inv.batt) self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since) + def _batts_settled(self) -> bool: + """MAINT-28: False while a battery reading may pre-date a step UP of + its command, so `grid + site_batt_w` is a new grid and an old battery. + DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md""" + now, ok = self._now_mono, True + if now is None: + return True + for inv in self.inverters: + cmd = float(inv.standing_w()) + seen = self._m28_step.get(inv.name) + if seen is None: + seen = self._m28_step[inv.name] = (cmd, None) + elif abs(cmd - seen[0]) >= MAINT_STEP_W: + seen = self._m28_step[inv.name] = ( + cmd, now if cmd > seen[0] else seen[1]) + at = seen[1] + if at is None or now - at >= freshness.BATT_OBLIGED_S: + continue + got = inv.batt_fresh.changed_mono + if got is None or got < at + freshness.PLANT_SETTLE_S: + ok = False + return ok + + def _under_reserve(self) -> frozenset: + """MAINT-28: the batteries at or under their peak reserve now + (`peakguard.reserve_split()`). DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md""" + floor = float(self.o.get( + "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) + return frozenset(peakguard.reserve_split(self.packs, floor)[1]) + def _reseed_drop(self, live) -> None: """SAFETY-96, N>=2: a battery that drops out of the split (stale link) leaves the loop's start point, so the others take over at one slew step diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 2d6cece..dc4f884 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -506,6 +506,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self._peak_needed = False self._maint_shape_suspended = False self._maint_peak_hold = False # MAINT-28 + self._m28_step = {} # MAINT-28: unit -> (command, step at) # 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 6f052aa..9a14c79 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -450,18 +450,23 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool: return decision is not None and decision.engaged -def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w) -> bool: +def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w, + settled=True) -> 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. + as SAFETY-63) and `settled` (no battery reading older than its last + command step, so `house_w` is not a new grid plus an old battery). Only + ever pauses the maintenance charge, never a cut-back. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.""" if suspended: 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 + if level is None: + return False + return level > float(aim_w) - MAINT_RESUME_UNDER_W or not settled def cap_floor(decision, cap_w): diff --git a/goodwe_controller/test_maint28b.py b/goodwe_controller/test_maint28b.py new file mode 100644 index 0000000..b409b72 --- /dev/null +++ b/goodwe_controller/test_maint28b.py @@ -0,0 +1,314 @@ +"""Runnable check for MAINT-28 fix-forward. `python3 test_maint28b.py` + +The rig failed MAINT-28 (TEST-23, f3f9483): the hold of the maintenance pause +ended at almost every guard step (F-1), and while it stuck, the pack in +maintenance CHARGE gave its full rating for the house below its peak reserve +(F-2). test_maint28.py missed both because its battery reading has no lag. Here +the battery register is published every 10 s and held (`Lag.batt_period_s`), +the meter reading is 2 s old, as on the rig; the rating is the rig's A (5 kW). + +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, house +-50 W every 5 s, 3000 W then +6500 W for 13 min (over a quarter boundary), then 3000 W. + +1. A real peak, A in CHARGE above its peak reserve: A flips charge/give a few + times (base 1b5f7c9: dozens), no release of the pause on an estimate that + mixes a new grid with an old battery reading, quarters at or under the aim, + A's charge back after the load. +2. F-2: A starts just over its reserve: while the pause holds it gives for the + house only down to the reserve, never below. +3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing + for the house, the other pack carries what the guard claims. +4. Unchanged: the MAINT-24 live shape (A 1475 W, B no grid trade), with the + same lag on. +5. `peakguard.shaping_held()` with `settled`, and `Controller._batts_settled()`. +Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md. +Read MUTATION.md before you report a survivor. +""" + +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 opt11p3_replay as rp # noqa: E402 +import opt20_scen as sc # noqa: E402 +from app import ctl_cycle, ctl_trade, freshness, peakguard, tariff # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +rp.RATING, rp.CAP_KWH = 5000.0, 15.36 # the rig's A +LAG = rp.Lag(dead_s=1.0, meter_s=2.0, batt_s=1.0, p1_s=0.35, tick=1.0, + tau_s=2.0, ramp_w_s=1e9, batt_period_s=10.0) +CH = sc.MAINT_CHARGE +MONTH_PEAK = 6000.0 +AIM = MONTH_PEAK - 50.0 +LINE = AIM - getattr(peakguard, "MAINT_RESUME_UNDER_W", 300.0) +T0 = sc.at(2.0) # a quarter boundary +PRE, LOAD, POST = 120.0, 780.0, 120.0 # s (CI time: short runs) +RESERVE = 20.0 # peak_soc_floor default + + +def stepped_house(base_w, hi_w): + 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(*, unit="a", soc=49.0, other=30.0, house=None, house_w=3000.0, + peak=MONTH_PEAK, dur=PRE + LOAD + POST, fc="quarter", n=2, + choice=None): + """`unit` is in maintenance CHARGE (forced result), at `soc`, the other at + `other`. Rows: the plants' power and SoC, the pause flag, the add-on's house + estimate and the real house.""" + rows = [] + q = dict(q=None, s=0.0, last=None, start=None) + idx = 0 if unit == "a" else 1 + o = "b" if unit == "a" else "a" + + def patch(c): + c.maints[idx].tick = ( + lambda now, soc_, blocked_by=None, gate_open=True: CH) + 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 + + async def cyc(): + c.cheap = True + if fc and c.grid is not None: + g = float(c.grid) + now = ctl_cycle.datetime.now() # the replay's clock + 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() + c.cycle = cyc + + def cb(c, h, now, pr): + est = (None if c.grid is None or c.site_batt_w is None + else float(c.grid) + float(c.site_batt_w)) + rows.append(dict( + t=(now - T0).total_seconds(), grid=h.grid(), u=h.plants[unit].p, + o=h.plants[o].p, soc=h.plants[unit].soc, soc_o=h.plants[o].soc, + hold=bool(getattr(c, "_maint_peak_hold", False)), est=est, + house=h.grid() + sum(p.p for p in h.plants.values()))) + + src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)] + ch = dict(sc.CH_CH if choice is None else choice) + stats, c = rp.run("p3_ch_sell", src, sc.WPTS, T0, dur, check=cb, lag=LAG, + n=n, soc0={unit: soc, o: other}, choice=ch, + refuse=(), patch=patch, + extra={"cycle_cost_threshold_eur": 0.10}) + return rows, stats, c + + +def flips(rs, k="u"): + n, s = 0, 0 + for r in rs: + v = 1 if r[k] > 50 else -1 if r[k] < -50 else 0 + if v and s and v != s: + n += 1 + if v: + s = v + return n + + +def releases(rows): + """Cycles in the load where the pause ended: (row, estimate under the + release line while the real house was over it).""" + out = [] + for p, r in zip(rows, rows[1:]): + if PRE <= r["t"] < PRE + LOAD and p["hold"] and not r["hold"]: + out.append((r, r["est"] is not None and r["est"] < LINE + and r["house"] >= LINE)) + return out + + +def quarters_ok(st): + # SAFETY-126: a landing 0-7 W over the aim is not an overrun. + return (max(a[0] / a[1] for a in st["quarters"].values()) + <= AIM + peakguard.ALARM_MISS_W) + + +def show(tag, rows, st): + load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] + rel = releases(rows) + print(f" {tag}: flips {flips(load)}, charge s " + f"{sum(1 for r in load if r['u'] < -50)}, releases {len(rel)} " + f"(stale {sum(1 for _r, s in rel if s)}), lowest SoC " + f"{min(r['soc'] for r in rows):.1f} %, give kWh " + f"{sum(r['u'] for r in load if r['u'] > 0) / 3.6e6:.2f}, quarters " + f"{[round(a[0] / a[1], 1) for a in st['quarters'].values()]}") + + +# ============================================================================ # +print("1. a real peak, A in CHARGE at 49 % (above its reserve), the battery " + "reading lagging") +rows, st, c = scen(house=stepped_house(3000.0, 6500.0)) +show("A 49 %", rows, st) +load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] +rel = releases(rows) +after = [r for r in rows if r["t"] >= PRE + LOAD] +back = next((i for i, r in enumerate(after) if r["u"] < -50), None) +print(f" A's charge back {back} s after the load") +check("A flips charge/give 3 times or fewer in the 13 min (1b5f7c9: 60, " + "f3f9483: 11)", flips(load) <= 3) +check("the pause never ends on an estimate under the release line while the " + "real house is over it", not any(s for _r, s in rel)) +check("every quarter at or under the month aim", + quarters_ok(st)) +check("after the load A's charge is back within 20 cycles (1 s each: a 10 s " + "register period plus its settle time, 16 s)", + back is not None and back <= 20) + +# ============================================================================ # +print("\n2. F-2: A starts just over its peak reserve (21 %), the pause holds") +rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0)) +show("A 21 %", rows, st) +check("A gave for the house while above the reserve (as before)", + any(r["u"] > 500 for r in rows if r["soc"] > RESERVE + 0.5)) +check("A never goes under its peak reserve in the peak (rig: to 10 %)", + min(r["soc"] for r in rows) >= RESERVE - 0.5) +check("every quarter at or under the month aim", quarters_ok(st)) + +# ============================================================================ # +print("\n3. mirror: B in CHARGE at 18 % (under its reserve), A on Cheap hours") +rows, st, c = scen(unit="b", soc=18.2, other=30.0, + house=stepped_house(3000.0, 6500.0)) +show("B 18 %", rows, st) +check("B gives nothing for the house: its SoC never goes under 18 %", + min(r["soc"] for r in rows) >= 18.0) +check("every quarter at or under the month aim", quarters_ok(st)) + +# ============================================================================ # +print("\n4. unchanged: the MAINT-24 live shape with the lagging reading") +rl, sl, cl = scen(house_w=3000.0, dur=600.0) +body = rl[len(rl) // 6:] +mean_a = sum(r["u"] for r in body) / len(body) +mean_b = sum(r["o"] for r in body) / len(body) +print(f" A {mean_a:+.0f} W, B {mean_b:+.0f} W, pause " + f"{sum(r['hold'] for r in rl)} cycles") +check("A charges about 1475 W (-1500..-1440) and the pause never comes", + -1500.0 <= mean_a <= -1440.0 and not any(r["hold"] for r in rl)) +check("B does not trade with the grid (Cheap hours, nothing to charge it)", + abs(mean_b) < 100.0) + +# ============================================================================ # +print("\n5. peakguard.shaping_held() with `settled`; Controller._batts_settled()") +check("the rule exists (MAINT_STEP_W, _batts_settled)", + hasattr(ctl_trade, "MAINT_STEP_W") and hasattr(ctl_trade.TradeMixin, + "_batts_settled")) +if not hasattr(ctl_trade, "MAINT_STEP_W"): + print(f"\n{len(fails)} FAILED:") + for f in fails: + print(f" - {f}") + sys.exit(1) +U = peakguard.MAINT_RESUME_UNDER_W +idle = peakguard.PeakDecision(engaged=False, floor_w=None, ceiling_w=6000.0, + ceiling_from="x", target_w=AIM) + + +def held(was, fc, house, settled): + return peakguard.shaping_held( + peakguard.shaping_suspended(idle, fc, AIM), was, fc, AIM, house, + settled) + + +check("held, house under the line but not settled: stays paused", + held(True, AIM - 1, AIM - U - 100.0, False)) +check("held, house under the line and settled: resumes", + not held(True, AIM - 1, AIM - U - 100.0, True)) +check("not held: settled makes no difference", + not held(False, AIM - 1, 6500.0, False)) +check("neither the house nor the forecast read: no pause, settled or not " + "(SAFETY-63)", + not held(True, None, None, False)) + + +class _Fresh: + def __init__(self, changed): + self.changed_mono = changed + + +class _Inv: + def __init__(self, name, cmd, changed): + self.name, self.cmd = name, cmd + self.batt_fresh = _Fresh(changed) + + def standing_w(self): + return self.cmd + + +class _Ctl: + _batts_settled = getattr(ctl_trade.TradeMixin, "_batts_settled", None) + + def __init__(self, invs): + self.inverters, self._m28_step, self._now_mono = invs, {}, 0.0 + + +S = freshness.PLANT_SETTLE_S +a = _Inv("a", -1500.0, 0.0) +k = _Ctl([a, _Inv("b", 0.0, 0.0)]) +check("no step seen yet: settled", k._batts_settled()) +a.cmd = 500.0 +k._now_mono = 100.0 +check("a step up of the command: not settled", not k._batts_settled()) +k._now_mono = 100.0 + S - 0.5 +a.batt_fresh.changed_mono = 100.0 + S - 1.0 +check("...a reading that arrived before step + settle does not count", + not k._batts_settled()) +a.batt_fresh.changed_mono = 100.0 + S +k._now_mono = 100.0 + S +check("...one that arrived at step + settle does", k._batts_settled()) +a.cmd = 3000.0 +k._now_mono = 200.0 +check("a second step up; the old reading is not current", + not k._batts_settled()) +k._now_mono = 200.0 + freshness.BATT_OBLIGED_S +check("...and a reading that never changes is waited for no longer than " + "BATT_OBLIGED_S", k._batts_settled()) +a.cmd = -1500.0 +k._now_mono = 400.0 +check("a step down (less give: the estimate errs high) is no step", + k._batts_settled()) +a2 = _Inv("a", -1500.0, 0.0) +k2 = _Ctl([a2]) +k2._batts_settled() +a2.cmd, k2._now_mono = -1500.0 + ctl_trade.MAINT_STEP_W - 1.0, 1.0 +check("a move under ctl_trade.MAINT_STEP_W is no step", k2._batts_settled()) +a2.cmd = -1500.0 + ctl_trade.MAINT_STEP_W +check("...one of ctl_trade.MAINT_STEP_W is", not k2._batts_settled()) + +print() +if fails: + print(f"{len(fails)} FAILED:") + for f in fails: + print(f" - {f}") + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/tools/opt11p3_replay.py b/goodwe_controller/tools/opt11p3_replay.py index 589c22f..c3bbad0 100644 --- a/goodwe_controller/tools/opt11p3_replay.py +++ b/goodwe_controller/tools/opt11p3_replay.py @@ -116,10 +116,15 @@ class Lag: after the reading (the HA source's debounce), so a cycle just after a quarter boundary can still see the old block; `tick` the cycle period; `tau_s` / `ramp_w_s` the battery's own lag (TEST-23's plant: `dead_s` 1, - no lag or ramp).""" + no lag or ramp). `batt_period_s` (MAINT-28): the battery register is + published every that many seconds and held between (the rig: 10 s), so its + reading is `batt_s` plus up to a period older than the meter's; None keeps + the constant age.""" def __init__(self, dead_s=1.0, meter_s=1.0, batt_s=1.0, p1_s=0.35, - tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0): + tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0, + batt_period_s=None): + self.batt_period_s = batt_period_s self.dead_s, self.meter_s, self.batt_s = dead_s, meter_s, batt_s self.p1_s, self.tick, self.sub = p1_s, tick, sub self.tau_s, self.ramp_w_s = tau_s, ramp_w_s @@ -211,6 +216,11 @@ class Hass: if e.startswith("sensor.batt"): # A real reading jitters by a few watts; a dead-flat one is a # stale register to the add-on (SAFETY-37), which is not modelled. + if self.lag is not None and self.lag.batt_period_s: + per = self.lag.batt_period_s + ts = math.floor((self.t - self.lag.batt_s) / per) * per + j = (ord(u) * 31 + int(ts)) % 7 - 3 + return float(round(self._past(self.t - ts)[1][u]) + j) j = (ord(u) * 31 + int(self.t)) % 7 - 3 p = self.plants[u].p if self.lag is None else \ self._past(self.lag.batt_s)[1][u] -- 2.54.0 From 776b84ca20a97a7218f60e16c5e866b89b505bf8 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 13 Nov 2026 19:49:19 +0100 Subject: [PATCH 2/8] MAINT-28 fix-forward round 2: while the pause holds, the maintenance pack gives only what the peak guard claims Owner go 2026-11-13. The pack in maintenance CHARGE gets a unit limit with hi = the guard's own floor while the pause holds, at any SoC (replaces the under-reserve limit). Sim 45 min: A give 2.39 -> 0.02 kWh, SoC 49.0 %, charge back 4 s. test_safety84 n2_charge_banked re-pinned (192 of 480 rows), test_maint28 quarter bound to the month peak. Tests: test_maint28b.py. Co-Authored-By: Claude --- .../MAINT-28-hold-the-pause-in-a-peak.md | 4 +-- goodwe_controller/app/ctl_cycle.py | 11 ++++---- goodwe_controller/app/ctl_trade.py | 7 ----- goodwe_controller/test_maint28.py | 8 ++++-- goodwe_controller/test_maint28b.py | 28 +++++++++++++++---- goodwe_controller/test_safety84.py | 7 +++-- 6 files changed, 40 insertions(+), 25 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 ea464a8..b781875 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 @@ -40,6 +40,6 @@ The sim had no battery-reading lag, and the first version said the 300 W line is **F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 13 s after the load (7 s in the test), not 0: the first settled estimate. No cut-back is touched. -**F-2: under its peak reserve the pack gives nothing for the house.** While the pause holds (N>=2, month aim above the cap), a pack in maintenance CHARGE that `peakguard.reserve_split()` holds gets `hi` = 0 W (P_MAINT_SHAPE). The guard's own claim for the peak is on the other packs (`place_on_able()`), so it is unchanged. Above the reserve nothing changes. Sim, B charging at 18 %: f3f9483 gave 1.89 kWh and went to 10.0 % (the log said "giving nothing for the peak"); now 0.00 kWh, 18.2 %, 0 give/idle flips in 45 min (the full cap measured by the reviewer: 32 in 40 min), quarters 5948 and 5952 W (aim 5950). +**F-2 and the cap (owner 2026-11-13): while the pause holds, the maintenance pack gives only what the guard claims.** While the pause holds (N>=2, month aim above the cap), the pack in maintenance CHARGE gets a unit limit `hi` = the guard's own floor (0 W when it claims nothing), at any SoC, so it also gives nothing for the house under its peak reserve. `hi` = 0 W alone was tried and is wrong: it crosses the guard's claim and resolves to 0 W (house 12 kW, B under its reserve: quarter 6713 W, a new month peak); with the guard's floor as `hi` the guard still gets its power. Other packs are unchanged (B not in maintenance still gives for the house). Sim, 45 min, house 6500 W: A at 49 %: A gives 0.02 kWh (uncapped 2.39), SoC stays at 49.0 %, charge/give flips 1, quarters 1958 1873 2873 W (all under the aim), charge back 4 s after the load. B at 18.2 % in CHARGE: f3f9483 gave 1.89 kWh to 10.0 % (log "giving nothing for the peak"); now 0.00 kWh, 18.2 %. Give/idle switches of the pack: 15 in 45 min, but they are the guard's claim on and off (3 episodes, one per quarter start, of 94 s at most 490 W, and 5 blips of 5-19 s at 100-490 W, 0.04 kWh in all); bridging gaps under 10 s would make it 3 episodes, 6 switches. Not added. -Mutants, each red in `test_maint28b.py`: the freshness check dropped (always settled); the reserve claim dropped. +Mutants, each red in `test_maint28b.py`: the freshness check dropped (always settled); the cap dropped. diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index 228d495..28d677c 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -589,11 +589,12 @@ class CycleMixin: "suspended so the peak claim is not " "contradicted", hint=messages.hint("maint_paused")) - # MAINT-28: under its peak reserve this pack gives only - # what the guard claims. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md - shape = ((0.0, "peak reserve") - if _m28 and inv.name in self._under_reserve() - else None) + # MAINT-28: while held this pack gives only what the + # guard claims. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md + shape = ((max((c.lo for c in claims + if c.source == peakguard.SOURCE), + default=0.0), "peak hold") + if _m28 else None) else: self._maint_shape_suspended = False hi = 0.0 diff --git a/goodwe_controller/app/ctl_trade.py b/goodwe_controller/app/ctl_trade.py index 096333e..e4fb9ce 100644 --- a/goodwe_controller/app/ctl_trade.py +++ b/goodwe_controller/app/ctl_trade.py @@ -740,13 +740,6 @@ class TradeMixin: ok = False return ok - def _under_reserve(self) -> frozenset: - """MAINT-28: the batteries at or under their peak reserve now - (`peakguard.reserve_split()`). DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md""" - floor = float(self.o.get( - "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) - return frozenset(peakguard.reserve_split(self.packs, floor)[1]) - def _reseed_drop(self, live) -> None: """SAFETY-96, N>=2: a battery that drops out of the split (stale link) leaves the loop's start point, so the others take over at one slew step diff --git a/goodwe_controller/test_maint28.py b/goodwe_controller/test_maint28.py index fc29cf1..f58aeb7 100644 --- a/goodwe_controller/test_maint28.py +++ b/goodwe_controller/test_maint28.py @@ -158,7 +158,7 @@ back = next((i for i, r in enumerate(after) if r["a"] < -50), None) # cycles print(f" first give at {trip}, flips in the load {flips(load)}, charge s in" f" the load {sum(1 for r in load if r['a'] < -50)}, charge back " f"{back} cycles after it, quarters " - f"{[round(a[0] / a[1]) for a in st['quarters'].values()]}") + f"{[round(a[0] / a[1], 1) for a in st['quarters'].values()]}") check("the guard stepped in once the projection went over the aim", trip is not None) check("from the first give to the load's end A does not flip", @@ -166,8 +166,10 @@ check("from the first give to the load's end A does not flip", check("...and does not charge at all", not any(r["a"] < -50 for r in load)) check("...the pause is on in every one of those cycles", all(r["susp"] for r in load)) -check("every quarter at or under the month aim", - max(a[0] / a[1] for a in st["quarters"].values()) <= AIM + 1.0) +# MAINT-28 fix: A gives only the guard's claim, so the guard lands the quarter +# itself: 5960.1 W against the 5950 W aim, 40 W under the month peak. +check("every quarter under the month peak (no new peak)", + max(a[0] / a[1] for a in st["quarters"].values()) <= MONTH_PEAK) check("after the load A's charge is back within 10 cycles (1 s each)", back is not None and back <= 10) tail = [r["a"] for r in after[10:]] diff --git a/goodwe_controller/test_maint28b.py b/goodwe_controller/test_maint28b.py index b409b72..72019df 100644 --- a/goodwe_controller/test_maint28b.py +++ b/goodwe_controller/test_maint28b.py @@ -16,10 +16,12 @@ peak forecast like the live sensor, house +-50 W every 5 s, 3000 W then times (base 1b5f7c9: dozens), no release of the pause on an estimate that mixes a new grid with an old battery reading, quarters at or under the aim, A's charge back after the load. -2. F-2: A starts just over its reserve: while the pause holds it gives for the - house only down to the reserve, never below. +2. F-2 and the cap: while the pause holds, A gives only what the peak guard + claims, at any SoC (never under its reserve, SoC barely drops); with the + other pack under its reserve and a house too big for it, A still gives for + the guard. 3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing - for the house, the other pack carries what the guard claims. + for the house. 4. Unchanged: the MAINT-24 live shape (A 1475 W, B no grid trade), with the same lag on. 5. `peakguard.shaping_held()` with `settled`, and `Controller._batts_settled()`. @@ -188,14 +190,28 @@ check("after the load A's charge is back within 20 cycles (1 s each: a 10 s " back is not None and back <= 20) # ============================================================================ # -print("\n2. F-2: A starts just over its peak reserve (21 %), the pause holds") +print("\n2. the cap: while the pause holds, A gives only what the guard claims") rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0)) show("A 21 %", rows, st) -check("A gave for the house while above the reserve (as before)", - any(r["u"] > 500 for r in rows if r["soc"] > RESERVE + 0.5)) +check("A gives only the guard's claim, under 0.15 kWh (f3f9483 0.94)", + sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.15) check("A never goes under its peak reserve in the peak (rig: to 10 %)", min(r["soc"] for r in rows) >= RESERVE - 0.5) check("every quarter at or under the month aim", quarters_ok(st)) +rows, st, c = scen(house=stepped_house(3000.0, 6500.0)) +soc_start = max(r["soc"] for r in rows if r["t"] <= PRE + 5.0) +check("A at 49 %: its SoC barely drops (under 0.5 point; f3f9483 6.1)", + soc_start - min(r["soc"] for r in rows if r["t"] >= PRE) < 0.5) +rows, st, c = scen(soc=49.0, other=18.2, house=stepped_house(3000.0, 12000.0)) +show("A 49 %, B 18 % under its reserve, house 12000 W", rows, st) +load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] +check("the guard's own claim stands: with B under its reserve A gives for " + "the peak (over 0.2 kWh; the cap yields to the guard)", + sum(r["u"] for r in load if r["u"] > 0) / 3.6e6 > 0.2) +g = [("give" if r["u"] > 50 else "idle") for r in load] +sw = sum(1 for x, y in zip(g, g[1:]) if x != y) +print(f" give/idle switches of A in the load: {sw}") +check("...give/idle switches of A at most 10 in the 13 min", sw <= 10) # ============================================================================ # print("\n3. mirror: B in CHARGE at 18 % (under its reserve), A on Cheap hours") diff --git a/goodwe_controller/test_safety84.py b/goodwe_controller/test_safety84.py index 0b0c128..28bede5 100644 --- a/goodwe_controller/test_safety84.py +++ b/goodwe_controller/test_safety84.py @@ -484,7 +484,10 @@ print("\n8. the N=1 and N=2 digests") # MAINT-28 re-pinned n2_charge_banked: in the 5000 W load (over the 3950 W # aim) the maintenance charge no longer restarts at -125 W after each guard # stop (t=1550 s, 64 of 480 rows); the pause is held until the load is gone -# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). The other five are equal. +# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). MAINT-28 fix-forward +# re-pinned it again: while the pause holds A gives only the guard's claim, so +# rows 288-479 (t = 1550 s on, 192 of 480) differ: a "peak hold" limit claim on +# A, and A's share 796 W instead of 2500 W. The other five are equal. PINNED = { "n1_charge": "00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9", @@ -497,7 +500,7 @@ PINNED = { "n2_charge_dear": "aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db", "n2_charge_banked": - "5d523562219245c135a4e9aa856826bc2939a3157c6ae0320cafc2cd1a8a6cf1", + "cdcac3065224f80f62a7b643f6d1226614a19679511625c034659c3df0d265d9", } _now = s84.digests() for k in PINNED: -- 2.54.0 From fb4a449591f93a7c1f16eb778dd48db6f2da3239 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 13 Nov 2026 21:21:14 +0100 Subject: [PATCH 3/8] MAINT-28 fix-forward round 3: the cap is the guard's largest claim of the period, 0 W for a pack the guard does not place on test_maint28 quarter bound back to aim + 1 W (now 5925 W; the guard alone landed 5960.1 W): the pack's limit is the largest floor the guard claimed in the 15-minute period. A pack in peak_guard.held (the guard places its claim on the others) gets 0 W. test_safety84 n2_charge_banked re-pinned again, its banked quarters asserted under the aim. New test: guard claims on the other pack only. Co-Authored-By: Claude --- .../MAINT-28-hold-the-pause-in-a-peak.md | 2 +- goodwe_controller/app/ctl_cycle.py | 21 ++++++++++++++----- goodwe_controller/app/main.py | 1 + goodwe_controller/test_maint28.py | 8 +++---- goodwe_controller/test_maint28b.py | 11 ++++++++-- goodwe_controller/test_safety84.py | 12 +++++++++-- 6 files changed, 40 insertions(+), 15 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 b781875..1ee2f9c 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 @@ -40,6 +40,6 @@ The sim had no battery-reading lag, and the first version said the 300 W line is **F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 13 s after the load (7 s in the test), not 0: the first settled estimate. No cut-back is touched. -**F-2 and the cap (owner 2026-11-13): while the pause holds, the maintenance pack gives only what the guard claims.** While the pause holds (N>=2, month aim above the cap), the pack in maintenance CHARGE gets a unit limit `hi` = the guard's own floor (0 W when it claims nothing), at any SoC, so it also gives nothing for the house under its peak reserve. `hi` = 0 W alone was tried and is wrong: it crosses the guard's claim and resolves to 0 W (house 12 kW, B under its reserve: quarter 6713 W, a new month peak); with the guard's floor as `hi` the guard still gets its power. Other packs are unchanged (B not in maintenance still gives for the house). Sim, 45 min, house 6500 W: A at 49 %: A gives 0.02 kWh (uncapped 2.39), SoC stays at 49.0 %, charge/give flips 1, quarters 1958 1873 2873 W (all under the aim), charge back 4 s after the load. B at 18.2 % in CHARGE: f3f9483 gave 1.89 kWh to 10.0 % (log "giving nothing for the peak"); now 0.00 kWh, 18.2 %. Give/idle switches of the pack: 15 in 45 min, but they are the guard's claim on and off (3 episodes, one per quarter start, of 94 s at most 490 W, and 5 blips of 5-19 s at 100-490 W, 0.04 kWh in all); bridging gaps under 10 s would make it 3 episodes, 6 switches. Not added. +**F-2 and the cap (owner 2026-11-13): while the pause holds, the maintenance pack gives at most what the guard claimed this period.** While the pause holds (N>=2, month aim above the cap), the pack in maintenance CHARGE gets a unit limit `hi` = the largest floor of the guard's claim in this 15-minute period (reset at each period and when the pause ends), at any SoC; 0 W when the guard places its claim on the other packs, i.e. this pack is under its reserve (`peak_guard.held`; the claim itself is site-scoped). Other packs are unchanged (B not in maintenance still gives for the house). Why not `hi` = 0 W: it crosses the guard's claim and resolves to 0 W (house 12 kW, B under its reserve: quarter 6713 W, a new month peak). Why the largest, not the present floor: the guard alone lands 10 W over its aim (SAFETY-126) because its floor drops to 0 W each time the projection reaches the aim, and the pack steps on and off (15 give/idle switches in 45 min, test_maint28 quarter 5960.1 W against 5950 W); holding the largest floor of the period gives test_maint28 5925 W and 0 switches. Sim, 45 min, house 6500 W: A at 49 %: A gives 0.37 kWh (uncapped 2.39), SoC 50.5 -> 48.3 %, charge/give flips 1, give/idle switches 0, quarters 1422 1370 2550 W (all under the aim), charge back 4 s after the load. B at 18.2 % in CHARGE: f3f9483 gave 1.89 kWh to 10.0 % (log "giving nothing for the peak"); now 0.00 kWh, 18.2 %, quarters 5948 and 5952 W: the guard places everything on the other pack, and its own landing error shows (up to +9.5 W, inside SAFETY-126's 10 W; test_maint28b allows that and nothing more). test_safety84 n2_charge_banked: banked quarters 3349 and 3016 W against the aim 3950 W. Mutants, each red in `test_maint28b.py`: the freshness check dropped (always settled); the cap dropped. diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index 28d677c..b16f73a 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -589,14 +589,25 @@ class CycleMixin: "suspended so the peak claim is not " "contradicted", hint=messages.hint("maint_paused")) - # MAINT-28: while held this pack gives only what the - # guard claims. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md - shape = ((max((c.lo for c in claims - if c.source == peakguard.SOURCE), - default=0.0), "peak hold") + # MAINT-28: while held this pack gives at most the most the + # guard claimed this period (0 W under its reserve: the + # guard places its claim on the other packs). + # DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md + _q = int(now.timestamp() // 900) + if self._m28_q != _q: + self._m28_q, self._m28_hi = _q, 0.0 + if self.peak_guard is None or \ + inv.name not in self.peak_guard.held: + self._m28_hi = max(self._m28_hi, max( + (c.lo for c in claims + if c.source == peakguard.SOURCE), default=0.0)) + shape = ((0.0 if self.peak_guard is not None + and inv.name in self.peak_guard.held + else self._m28_hi, "peak hold") if _m28 else None) else: self._maint_shape_suspended = False + self._m28_hi = 0.0 hi = 0.0 reason = "charge-only" if self.cheap: diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index dc4f884..bff2a4d 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -506,6 +506,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self._peak_needed = False self._maint_shape_suspended = False self._maint_peak_hold = False # MAINT-28 + self._m28_q, self._m28_hi = None, 0.0 # MAINT-28: period, its most guard claim self._m28_step = {} # MAINT-28: unit -> (command, step at) # 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, diff --git a/goodwe_controller/test_maint28.py b/goodwe_controller/test_maint28.py index f58aeb7..fc29cf1 100644 --- a/goodwe_controller/test_maint28.py +++ b/goodwe_controller/test_maint28.py @@ -158,7 +158,7 @@ back = next((i for i, r in enumerate(after) if r["a"] < -50), None) # cycles print(f" first give at {trip}, flips in the load {flips(load)}, charge s in" f" the load {sum(1 for r in load if r['a'] < -50)}, charge back " f"{back} cycles after it, quarters " - f"{[round(a[0] / a[1], 1) for a in st['quarters'].values()]}") + 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", @@ -166,10 +166,8 @@ check("from the first give to the load's end A does not flip", check("...and does not charge at all", not any(r["a"] < -50 for r in load)) check("...the pause is on in every one of those cycles", all(r["susp"] for r in load)) -# MAINT-28 fix: A gives only the guard's claim, so the guard lands the quarter -# itself: 5960.1 W against the 5950 W aim, 40 W under the month peak. -check("every quarter under the month peak (no new peak)", - max(a[0] / a[1] for a in st["quarters"].values()) <= MONTH_PEAK) +check("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 (1 s each)", back is not None and back <= 10) tail = [r["a"] for r in after[10:]] diff --git a/goodwe_controller/test_maint28b.py b/goodwe_controller/test_maint28b.py index 72019df..a5d38de 100644 --- a/goodwe_controller/test_maint28b.py +++ b/goodwe_controller/test_maint28b.py @@ -200,8 +200,8 @@ check("A never goes under its peak reserve in the peak (rig: to 10 %)", check("every quarter at or under the month aim", quarters_ok(st)) rows, st, c = scen(house=stepped_house(3000.0, 6500.0)) soc_start = max(r["soc"] for r in rows if r["t"] <= PRE + 5.0) -check("A at 49 %: its SoC barely drops (under 0.5 point; f3f9483 6.1)", - soc_start - min(r["soc"] for r in rows if r["t"] >= PRE) < 0.5) +check("A at 49 %: its SoC barely drops (under 1 point; f3f9483 6.1)", + soc_start - min(r["soc"] for r in rows if r["t"] >= PRE) < 1.0) rows, st, c = scen(soc=49.0, other=18.2, house=stepped_house(3000.0, 12000.0)) show("A 49 %, B 18 % under its reserve, house 12000 W", rows, st) load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] @@ -213,6 +213,13 @@ sw = sum(1 for x, y in zip(g, g[1:]) if x != y) print(f" give/idle switches of A in the load: {sw}") check("...give/idle switches of A at most 10 in the 13 min", sw <= 10) +rows, st, c = scen(soc=18.2, other=30.0, house=stepped_house(3000.0, 12000.0)) +show("A 18 % under its reserve, B 30 % able, house 12000 W", rows, st) +check("the guard places its claim on the other pack: A under its reserve " + "gives under 0.05 kWh and its SoC stays at 18 %", + sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.05 + and min(r["soc"] for r in rows) >= 18.0) + # ============================================================================ # print("\n3. mirror: B in CHARGE at 18 % (under its reserve), A on Cheap hours") rows, st, c = scen(unit="b", soc=18.2, other=30.0, diff --git a/goodwe_controller/test_safety84.py b/goodwe_controller/test_safety84.py index 28bede5..cf4679c 100644 --- a/goodwe_controller/test_safety84.py +++ b/goodwe_controller/test_safety84.py @@ -487,7 +487,9 @@ print("\n8. the N=1 and N=2 digests") # (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). MAINT-28 fix-forward # re-pinned it again: while the pause holds A gives only the guard's claim, so # rows 288-479 (t = 1550 s on, 192 of 480) differ: a "peak hold" limit claim on -# A, and A's share 796 W instead of 2500 W. The other five are equal. +# A, and A's share capped at the guard's claim instead of 2500 W. Its banked +# quarters stay under the aim 3950 W (3349 and 3016 W; f3f9483 3349 and 2527 W), +# graded below. The other five are equal. PINNED = { "n1_charge": "00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9", @@ -500,8 +502,14 @@ PINNED = { "n2_charge_dear": "aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db", "n2_charge_banked": - "cdcac3065224f80f62a7b643f6d1226614a19679511625c034659c3df0d265d9", + "6b0af4837a71120d50f29ed85058cb676fe6f7a822ccf0d6c566e58217ebc109", } +_nb_rows, _nb = s84.run(**s84.SCENARIOS["n2_charge_banked"]) +print(f" n2_charge_banked banked quarters {[round(v, 1) for v in banked(_nb)]}" + f" W (aim 3950)") +check("...and in the re-pinned n2_charge_banked every banked quarter is at or " + "under the aim 3950 W", + len(banked(_nb)) >= 2 and all(v <= 3950.0 + 1.0 for v in banked(_nb))) _now = s84.digests() for k in PINNED: print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'} " -- 2.54.0 From 07d23d2eedc008c8cb306bc76fbc66757ac6ccb4 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 13 Nov 2026 23:42:23 +0100 Subject: [PATCH 4/8] MAINT-28 fix-forward round 4: the paused pack's cap is its own part of the guard's floor, the largest of the period, per unit Owner 2026-11-13 ("ok max", relayed by the manager): while the pause holds, the pack in maintenance CHARGE gives at most the largest floor the guard claimed in the quarter. The cap is now that pack's own part of the floor (the floor less what the other units are placed at, split without the cap), the largest in the 15-minute period, kept per unit (`_m28_hi`). 0 W while the guard holds it at its reserve. test_maint28b: SoC check stated against the owner-accepted give (0.70 point, measured 0.64); both-packs-able tests (B on Cheap hours, B on Solar first); per-unit state and the one-slot arbitrate. test_maint28 unchanged (5925 W). Pins: test_safety84 n2_charge_banked, test_packshare's one-resolve AST pin (a second call with the same weights). Co-Authored-By: Claude --- .../MAINT-28-hold-the-pause-in-a-peak.md | 14 ++- goodwe_controller/app/ctl_cycle.py | 26 ++--- goodwe_controller/app/ctl_trade.py | 40 ++++++- goodwe_controller/app/main.py | 2 +- goodwe_controller/test_maint28b.py | 108 ++++++++++++++++-- goodwe_controller/test_packshare.py | 5 +- goodwe_controller/test_safety84.py | 12 +- 7 files changed, 173 insertions(+), 34 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 1ee2f9c..6b83d7b 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 @@ -38,8 +38,16 @@ MAINT-24's rate and its tests, N=1, the drain, modes off (digests of 1b5f7c9). T ## Fix-forward (rig FAIL F-1, F-2; `reports/MAINT-28-test-report.md`) The sim had no battery-reading lag, and the first version said the 300 W line is far larger than the lag moves the estimate. That was wrong. The rig publishes a battery's power every 10 s; the meter is newer. After each guard step charge -> give (+500-1000 W) the next meter reading shows the give and the battery reading still shows the charge, so `grid + site_batt_w` drops by the step, under the 5650 W line: 69/69, 132/133, 215/215, 51/51 releases at a median 5430 W with the house at 6450 W or more. Test: `test_maint28b.py`, sim `Lag.batt_period_s`. -**F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 13 s after the load (7 s in the test), not 0: the first settled estimate. No cut-back is touched. +**F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 4 s after the load (12 s in test_maint28b), not 0: the first settled estimate. No cut-back is touched. -**F-2 and the cap (owner 2026-11-13): while the pause holds, the maintenance pack gives at most what the guard claimed this period.** While the pause holds (N>=2, month aim above the cap), the pack in maintenance CHARGE gets a unit limit `hi` = the largest floor of the guard's claim in this 15-minute period (reset at each period and when the pause ends), at any SoC; 0 W when the guard places its claim on the other packs, i.e. this pack is under its reserve (`peak_guard.held`; the claim itself is site-scoped). Other packs are unchanged (B not in maintenance still gives for the house). Why not `hi` = 0 W: it crosses the guard's claim and resolves to 0 W (house 12 kW, B under its reserve: quarter 6713 W, a new month peak). Why the largest, not the present floor: the guard alone lands 10 W over its aim (SAFETY-126) because its floor drops to 0 W each time the projection reaches the aim, and the pack steps on and off (15 give/idle switches in 45 min, test_maint28 quarter 5960.1 W against 5950 W); holding the largest floor of the period gives test_maint28 5925 W and 0 switches. Sim, 45 min, house 6500 W: A at 49 %: A gives 0.37 kWh (uncapped 2.39), SoC 50.5 -> 48.3 %, charge/give flips 1, give/idle switches 0, quarters 1422 1370 2550 W (all under the aim), charge back 4 s after the load. B at 18.2 % in CHARGE: f3f9483 gave 1.89 kWh to 10.0 % (log "giving nothing for the peak"); now 0.00 kWh, 18.2 %, quarters 5948 and 5952 W: the guard places everything on the other pack, and its own landing error shows (up to +9.5 W, inside SAFETY-126's 10 W; test_maint28b allows that and nothing more). test_safety84 n2_charge_banked: banked quarters 3349 and 3016 W against the aim 3950 W. +**F-2 and the cap.** Owner 2026-11-13 ("1" = cap): while the pause holds, the pack in maintenance CHARGE gives only what the guard claims for the peak, at any SoC; other packs unchanged. Owner 2026-11-13 ("ok max", relayed by the manager): accepted "while the hold is on, the pack in maintenance CHARGE gives at most the largest floor the peak guard claimed in the current quarter" (about 0.37 kWh per 45-min peak in the sim; quarters at or under the aim). -Mutants, each red in `test_maint28b.py`: the freshness check dropped (always settled); the cap dropped. +The rule as built (`Controller._m28_share()`): while the pause holds (N>=2, month aim above the cap) the pack gets a unit limit `hi` = the largest part of the guard's floor placed on it in this 15-minute period. Its part in a cycle is the floor less what the other units are placed at in this cycle's split without that limit, so a part the guard's split puts on the other pack is not this pack's. A pack the guard holds at its reserve (`peak_guard.held`) gets 0 W and its part starts again from 0 W. The largest part is kept per unit (`_m28_hi`, keyed by the unit) and dropped in a new period or when the pause ends; only one pack can be in maintenance CHARGE anyway (`maintenance.arbitrate()`, one slot per site). + +Why the largest of the period and not the present part: the guard alone lands the quarter 9.5-10 W over its aim in the sim (SAFETY-126: its own projection ends on 5950.0 W but the meter reading it counts lags the true grid), and with the present part only, test_maint28 part 1 ends at 5960.1 W; the largest of the period lets the loop keep A up and it ends at 5925 W. Why not `hi` = 0 W: with the other pack under its reserve the guard needs this one (test_maint28b, house 12 kW, B at 18 %: A gives 1.07 kWh for the guard). + +Tests (test_maint28b, 13-min load, A 5 kW): A at 49 %: SoC drop 0.64 point (the owner's 0.37 kWh per 45 min is 0.70 point here; the check is <= 0.70). Both packs able, B on Cheap hours: B is placed nothing, A's limit is the period's largest floor (3087 W) and A gives 3081 W. B on Solar first (gives for the house): A's limit 3294 W against a period's largest floor of 6284 W, under it by 500 W or more in 564 of 569 paused cycles; A still gives 3294 W, so the quarter ends 3843 W, far under the aim: that is the cost of the largest-of-period rule when the other pack gives for the house. + +Sim, rig shape, 45 min, house 6500 W: A in CHARGE at 49 %: A gives 0.17 kWh (f3f9483 0.44), SoC 50.5 -> 49.0 %, charge/give flips 1, give/idle switches 3, quarters 5358 1663 1899 2572 4476 W, charge back 4 s after the load; B (Cheap hours, not in maintenance) gives 3.04 kWh for the house (f3f9483 0.32). B at 18.2 % in CHARGE: f3f9483 gave 1.89 kWh to 10.0 %; now 0.00 kWh, 18.2 %, quarters 5948 and 5952 W (the guard alone, on A). test_safety84 n2_charge_banked: banked quarters 3349 and 3217 W against the aim 3950 W. + +Mutants, each red: the freshness check dropped (always settled); the cap dropped; the other units' placement dropped (part = the whole floor); one cap for the site instead of per unit; the 0 W for a held pack dropped (all in `test_maint28b.py`); the period's largest dropped (present part only: `test_maint28.py` part 1 quarter 5960 W). diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index b16f73a..5b749b9 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -29,7 +29,7 @@ from . import shadow from .price import Uncertainty from .tariff import next_quarter from .watchdog import loop_is_following -from .ctl_trade import pack_energy_of +from .ctl_trade import M28_REASON, pack_energy_of from .rows import why_key, why_raised _LOG = logging.getLogger("goodwe") @@ -589,25 +589,20 @@ class CycleMixin: "suspended so the peak claim is not " "contradicted", hint=messages.hint("maint_paused")) - # MAINT-28: while held this pack gives at most the most the - # guard claimed this period (0 W under its reserve: the - # guard places its claim on the other packs). + # MAINT-28: while held this pack gives at most the most of + # the guard's floor placed on it this period + # (`_m28_share()`). # DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md - _q = int(now.timestamp() // 900) - if self._m28_q != _q: - self._m28_q, self._m28_hi = _q, 0.0 - if self.peak_guard is None or \ - inv.name not in self.peak_guard.held: - self._m28_hi = max(self._m28_hi, max( - (c.lo for c in claims - if c.source == peakguard.SOURCE), default=0.0)) shape = ((0.0 if self.peak_guard is not None and inv.name in self.peak_guard.held - else self._m28_hi, "peak hold") + else max([c.lo for c in claims + if c.source == peakguard.SOURCE] + + [self._m28_was(self.actuator_of( + "maintenance", inv), now)], + default=0.0), M28_REASON) if _m28 else None) else: self._maint_shape_suspended = False - self._m28_hi = 0.0 hi = 0.0 reason = "charge-only" if self.cheap: @@ -875,6 +870,9 @@ class CycleMixin: self.loop_clamp_facts = () self._loop_decision = None + # MAINT-28: a paused pack gives at most its largest part of the floor + # in this period. + self._m28_share(claims, now) # SAFETY-28. A new resolution is a new tick, and the previous record is # stale from this line onwards. Incremented BEFORE resolve() so that the # record resolve() builds is stamped with the tick it is for. diff --git a/goodwe_controller/app/ctl_trade.py b/goodwe_controller/app/ctl_trade.py index e4fb9ce..df0d21a 100644 --- a/goodwe_controller/app/ctl_trade.py +++ b/goodwe_controller/app/ctl_trade.py @@ -10,7 +10,7 @@ from datetime import datetime, timedelta, timezone from . import arbitrage from .arbiter import ( ACTUATOR_SITE, Claim, P_MAINTENANCE, P_MAINT_SHAPE, P_PEAK, SET, - pre_resolve, + pre_resolve, resolve, ) from . import allocator from . import dayplan @@ -53,6 +53,8 @@ HOUSE_PROFILE_KEY = "house_profile" # MAINT-28: a step of a battery's command, in W, after which its reading is not # current until a changed one arrives (`Controller._batts_settled()`). MAINT_STEP_W = 100.0 +# MAINT-28: the reason on a paused pack's cap (`Controller._m28_share()`). +M28_REASON = "peak hold" # OPT-34: the level of a night grid charge that has started, so it does not @@ -740,6 +742,42 @@ class TradeMixin: ok = False return ok + def _m28_share(self, claims, now) -> None: + """MAINT-28: each paused pack's cap (0 W if the guard holds it at its + reserve) set to the largest part of the guard's floor left to it in + this 15-minute period: the floor less what the other units are placed + at in this cycle's split without the cap. Kept per unit in `_m28_hi`, + dropped while the unit is not paused or is held. + DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md""" + floor = max((c.lo for c in claims if c.source == peakguard.SOURCE), + default=0.0) + g = self.peak_guard + held = {self.actuator_of("maintenance", inv) for inv in self.inverters + if g is not None and inv.name in g.held} + q, seen = int(now.timestamp() // 900), set() + for i, c in enumerate(claims): + if c.source != "maintenance" or c.reason != M28_REASON \ + or c.actuator == ACTUATOR_SITE or c.actuator in held: + continue + seen.add(c.actuator) + shares = resolve(claims[:i] + claims[i + 1:], units=self.units, + live=self.live_units(), weights=self.pack_weights, + reserve=self._held_back()).shares + part = max(0.0, floor - sum(float(w) for u, w in shares.items() + if u != c.actuator)) + hi = max(part, self._m28_was(c.actuator, now)) + self._m28_hi[c.actuator] = (q, hi) + claims[i] = dataclasses.replace(c, hi=hi) + # A unit not paused (or held at its reserve) starts again from 0 W. + for u in set(self._m28_hi) - seen: + del self._m28_hi[u] + + def _m28_was(self, u, now) -> float: + """MAINT-28: unit `u`'s cap so far in this 15-minute period, 0 W in + a new one (`_m28_share()`).""" + q, hi = self._m28_hi.get(u, (None, 0.0)) + return hi if q == int(now.timestamp() // 900) else 0.0 + def _reseed_drop(self, live) -> None: """SAFETY-96, N>=2: a battery that drops out of the split (stale link) leaves the loop's start point, so the others take over at one slew step diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index bff2a4d..6939231 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -506,8 +506,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self._peak_needed = False self._maint_shape_suspended = False self._maint_peak_hold = False # MAINT-28 - self._m28_q, self._m28_hi = None, 0.0 # MAINT-28: period, its most guard claim self._m28_step = {} # MAINT-28: unit -> (command, step at) + self._m28_hi = {} # MAINT-28: unit -> (period, cap) # 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/test_maint28b.py b/goodwe_controller/test_maint28b.py index a5d38de..0b1d3bf 100644 --- a/goodwe_controller/test_maint28b.py +++ b/goodwe_controller/test_maint28b.py @@ -16,10 +16,13 @@ peak forecast like the live sensor, house +-50 W every 5 s, 3000 W then times (base 1b5f7c9: dozens), no release of the pause on an estimate that mixes a new grid with an old battery reading, quarters at or under the aim, A's charge back after the load. -2. F-2 and the cap: while the pause holds, A gives only what the peak guard - claims, at any SoC (never under its reserve, SoC barely drops); with the - other pack under its reserve and a house too big for it, A still gives for - the guard. +2. F-2 and the cap (owner 2026-11-13): while the pause holds, A gives at most + the largest part of the guard's floor placed on it in the 15-min period, + at any SoC (never under its reserve; SoC drop within the owner-accepted + give); with both packs able, never over the period's largest floor, and + when B is placed part of it A's limit is its own part, not the whole floor; + one unit's limit is its own (two packs in CHARGE never share it); with the + other pack under its reserve, A still gives for the guard. 3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing for the house. 4. Unchanged: the MAINT-24 live shape (A 1475 W, B no grid trade), with the @@ -33,6 +36,7 @@ import logging import os import random import sys +from datetime import timedelta HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) @@ -41,7 +45,8 @@ logging.disable(logging.WARNING) import opt11p3_replay as rp # noqa: E402 import opt20_scen as sc # noqa: E402 -from app import ctl_cycle, ctl_trade, freshness, peakguard, tariff # noqa: E402 +from app import (ctl_cycle, ctl_trade, freshness, maintenance, # noqa: E402 + packmode, peakguard, tariff) fails = [] @@ -115,10 +120,22 @@ def scen(*, unit="a", soc=49.0, other=30.0, house=None, house_w=3000.0, def cb(c, h, now, pr): est = (None if c.grid is None or c.site_batt_w is None else float(c.grid) + float(c.site_batt_w)) + g = c.peak_guard + floor = (float(g.floor_w) if g is not None and g.engaged + and g.floor_w is not None else 0.0) + sh = (dict(c.allocation.shares) if c.allocation is not None + and c.allocation.shares else {}) + au = c.actuator_of("maintenance", c.inverters[idx]) + ao = c.actuator_of("maintenance", c.inverters[1 - idx]) rows.append(dict( t=(now - T0).total_seconds(), grid=h.grid(), u=h.plants[unit].p, o=h.plants[o].p, soc=h.plants[unit].soc, soc_o=h.plants[o].soc, hold=bool(getattr(c, "_maint_peak_hold", False)), est=est, + floor=floor, share_u=float(sh.get(au, 0.0)), + hi_u=next((float(pl.hi) for pl in (c.allocation.plans + if c.allocation else ()) + if pl.unit == au), None), + share_o=float(sh.get(ao, 0.0)), house=h.grid() + sum(p.p for p in h.plants.values()))) src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)] @@ -190,7 +207,8 @@ check("after the load A's charge is back within 20 cycles (1 s each: a 10 s " back is not None and back <= 20) # ============================================================================ # -print("\n2. the cap: while the pause holds, A gives only what the guard claims") +print("\n2. the cap: while the pause holds, A gives at most the largest part " + "of the guard's floor placed on it this period") rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0)) show("A 21 %", rows, st) check("A gives only the guard's claim, under 0.15 kWh (f3f9483 0.94)", @@ -200,8 +218,13 @@ check("A never goes under its peak reserve in the peak (rig: to 10 %)", check("every quarter at or under the month aim", quarters_ok(st)) rows, st, c = scen(house=stepped_house(3000.0, 6500.0)) soc_start = max(r["soc"] for r in rows if r["t"] <= PRE + 5.0) -check("A at 49 %: its SoC barely drops (under 1 point; f3f9483 6.1)", - soc_start - min(r["soc"] for r in rows if r["t"] >= PRE) < 1.0) +drop = soc_start - min(r["soc"] for r in rows if r["t"] >= PRE) +print(f" A at 49 %: SoC drop {drop:.2f} point") +# Owner 2026-11-13 accepted about 0.37 kWh per 45-min peak with the cap +# (DECISIONS): 0.37 * 13 / 45 = 0.107 kWh in this load, 0.70 point of +# 15.36 kWh. Measured 0.64. +check("A at 49 %: its SoC drops no more than the owner-accepted give allows " + "(0.70 point in the 13 min; f3f9483 6.1)", drop <= 0.70) rows, st, c = scen(soc=49.0, other=18.2, house=stepped_house(3000.0, 12000.0)) show("A 49 %, B 18 % under its reserve, house 12000 W", rows, st) load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] @@ -213,6 +236,55 @@ sw = sum(1 for x, y in zip(g, g[1:]) if x != y) print(f" give/idle switches of A in the load: {sw}") check("...give/idle switches of A at most 10 in the 13 min", sw <= 10) +def period_max(rows, f): + """Per row: the largest f(row) over the paused rows of its 15-minute + period so far (0 W outside the pause), as the owner rule reads.""" + out, q, m = [], None, 0.0 + for r in rows: + qi = int((T0.timestamp() + r["t"]) // 900) + if qi != q or not r["hold"]: + q, m = qi, 0.0 + if r["hold"]: + m = max(m, f(r)) + out.append(m) + return out + + +for tag, ch, hi_w in (("B on Cheap hours", None, 9000.0), + ("B on Solar first (it gives for the house too)", + {"a": sc.CH_CH["a"], + "b": (True, packmode.SOLAR_FIRST, False)}, + 12000.0)): + rows, st, c = scen(soc=49.0, other=40.0, choice=ch, + house=stepped_house(3000.0, hi_w)) + show(f"A 49 %, B 40 %, both able, {tag}, house {hi_w:.0f} W", rows, st) + mf = period_max(rows, lambda r: r["floor"]) + held_rows = [(r, m) for r, m in zip(rows, mf) + if r["hold"] and r["floor"] > 0.0 and r["hi_u"] is not None] + n = max(1, len(held_rows)) + over = sum(1 for r, m in held_rows if r["share_u"] > m + 2.0) + # the whole floor, as far as A's rating lets it be A's limit + under = sum(1 for r, m in held_rows + if r["hi_u"] < min(m, rp.RATING) - 500.0) + print(f" {len(held_rows)} paused cycles with a guard floor (mean " + f"{sum(r['floor'] for r, _m in held_rows) / n:.0f} W, period's " + f"largest {sum(m for _r, m in held_rows) / n:.0f} W): A's limit " + f"{sum(r['hi_u'] for r, _m in held_rows) / n:.0f} W, A " + f"{sum(r['share_u'] for r, _m in held_rows) / n:.0f} W, B " + f"{sum(r['share_o'] for r, _m in held_rows) / n:.0f} W; A over the " + f"period's largest floor in {over}; A's limit 500 W or more under it" + f" (or A's rating) in {under}") + check(f"{tag}: the pause holds with a guard floor for over 5 min", + len(held_rows) >= 300) + check("...A never gives more than the largest guard floor of the period " + "(owner 2026-11-13)", over == 0) + if ch is not None: + check("...B is placed part of the floor, so A's limit is its own part," + " 500 W or more under the period's largest floor (or A's " + "rating) in 9 of 10 cycles (not the whole floor)", + under >= 0.9 * len(held_rows)) + check("...every quarter at or under the month aim", quarters_ok(st)) + rows, st, c = scen(soc=18.2, other=30.0, house=stepped_house(3000.0, 12000.0)) show("A 18 % under its reserve, B 30 % able, house 12000 W", rows, st) check("the guard places its claim on the other pack: A under its reserve " @@ -328,6 +400,26 @@ check("a move under ctl_trade.MAINT_STEP_W is no step", k2._batts_settled()) a2.cmd = -1500.0 + ctl_trade.MAINT_STEP_W check("...one of ctl_trade.MAINT_STEP_W is", not k2._batts_settled()) +# Per unit: the cap so far lives under each unit's own key, and only one pack +# can be in maintenance CHARGE at all (maintenance.arbitrate(): one slot). +au, ab = (cl.actuator_of("maintenance", i) for i in cl.inverters) +t = T0 + timedelta(seconds=60) +cl._m28_hi = {au: (int(t.timestamp() // 900), 3000.0)} +check("the period's cap is kept per unit: A's 3000 W is not B's", + cl._m28_was(au, t) == 3000.0 and cl._m28_was(ab, t) == 0.0) +check("...and it starts again from 0 W in the next 15-minute period", + cl._m28_was(au, t + timedelta(seconds=900)) == 0.0) + + +class _M: + def __init__(self, name): + self.name, self.phase = name, maintenance.CHARGE + + +check("maintenance.arbitrate(): with two packs in CHARGE only the first keeps " + "the slot", maintenance.arbitrate([_M("a"), _M("b")], t)[1] != "" + and maintenance.arbitrate([_M("a"), _M("b")], t)[0] == "") + print() if fails: print(f"{len(fails)} FAILED:") diff --git a/goodwe_controller/test_packshare.py b/goodwe_controller/test_packshare.py index b4e3893..b0c43a4 100644 --- a/goodwe_controller/test_packshare.py +++ b/goodwe_controller/test_packshare.py @@ -1148,7 +1148,10 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE "could do - and no fixture anywhere could see that, because every check " "calls `resolve()` for itself. A method, not a dict, because the " "weighting is direction-dependent and the direction is stage 2's output", - len(_resolve_calls) == 1 and _w_arg == ["self.pack_weights"]) + # MAINT-28 added a second call (`_m28_share()`'s split without the + # paused pack's cap); it must pass the same method, as the cycle's does. + len(_resolve_calls) >= 1 + and _w_arg == ["self.pack_weights"] * len(_resolve_calls)) # ⚠️ AC 3 SAYS `unmet_w` MUST BE **VISIBLE**, AND `Allocation.explain()` IS ONLY # HALF OF THAT. The other half is main.py putting it in the action log, which no diff --git a/goodwe_controller/test_safety84.py b/goodwe_controller/test_safety84.py index cf4679c..a546491 100644 --- a/goodwe_controller/test_safety84.py +++ b/goodwe_controller/test_safety84.py @@ -485,11 +485,11 @@ print("\n8. the N=1 and N=2 digests") # 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). MAINT-28 fix-forward -# re-pinned it again: while the pause holds A gives only the guard's claim, so -# rows 288-479 (t = 1550 s on, 192 of 480) differ: a "peak hold" limit claim on -# A, and A's share capped at the guard's claim instead of 2500 W. Its banked -# quarters stay under the aim 3950 W (3349 and 3016 W; f3f9483 3349 and 2527 W), -# graded below. The other five are equal. +# re-pinned it again: while the pause holds A gives at most the largest part +# of the guard's floor placed on it this period, so rows 288-479 (t = 1440 s +# on, 192 of 480) differ: a "peak hold" limit claim on A and A's share. Its +# banked quarters stay under the aim 3950 W (3349 and 3217 W; f3f9483 3349 and +# 2527 W), graded below. The other five are equal. PINNED = { "n1_charge": "00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9", @@ -502,7 +502,7 @@ PINNED = { "n2_charge_dear": "aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db", "n2_charge_banked": - "6b0af4837a71120d50f29ed85058cb676fe6f7a822ccf0d6c566e58217ebc109", + "2da0c3bc9f0ff3857402146efcd6119180a7d4d1cc1297f1c15ec0b8c0104f42", } _nb_rows, _nb = s84.run(**s84.SCENARIOS["n2_charge_banked"]) print(f" n2_charge_banked banked quarters {[round(v, 1) for v in banked(_nb)]}" -- 2.54.0 From 0020756aab58d13203a089ab44ff1c59b7828801 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 14 Nov 2026 16:07:06 +0100 Subject: [PATCH 5/8] Revert "Revert "MAINT-28: during a real peak the maintenance charge stays paused (#247)"" Restores #247's code on this branch after the release/1.0 merge. The fix rounds follow in the next commit. Co-Authored-By: Claude --- .../MAINT-28-hold-the-pause-in-a-peak.md | 36 +++ goodwe_controller/app/ctl_cycle.py | 17 +- goodwe_controller/app/main.py | 1 + goodwe_controller/app/peakguard.py | 18 ++ goodwe_controller/test_maint28.py | 288 ++++++++++++++++++ goodwe_controller/test_safety84.py | 6 +- 6 files changed, 363 insertions(+), 3 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..5cbf248 --- /dev/null +++ b/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md @@ -0,0 +1,36 @@ +# 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(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. + +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 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). 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). + +| 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 (`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 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 873e746..a8928a7 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -537,6 +537,10 @@ 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; + # 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: # PER-INVERTER: §12's recalibration is a PACK procedure, filed @@ -564,8 +568,17 @@ 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: 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( + self.peak_guard, self.peak_fc, maint_aim) + self._maint_peak_hold = peakguard.shaping_held( + _susp, _peak_held and len(self.inverters) > 1 + and maint_aim > self.peak_aim_w(), + 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..6f052aa 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(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 suspended: + 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..fc29cf1 --- /dev/null +++ b/goodwe_controller/test_maint28.py @@ -0,0 +1,288 @@ +"""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 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. +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 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 +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 opt11p3_replay as rp # noqa: E402 +import opt20_scen as sc # noqa: E402 +from app import ctl_cycle, peakguard, tariff # noqa: E402 +from app.maintenance import DRAIN, IDLE, 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 = 120.0, 900.0, 120.0 # s (CI time: short runs) + + +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, 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=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).""" + rows = [] + q = dict(q=None, s=0.0, last=None, start=None) + + def patch(c): + 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: + 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: + # 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) + 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], 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, + 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 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 +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((i for i, r in enumerate(after) if r["a"] < -50), None) # cycles +print(f" first give at {trip}, flips in the load {flips(load)}, charge s in" + f" the load {sum(1 for r in load if r['a'] < -50)}, charge back " + f"{back} cycles after it, quarters " + f"{[round(a[0] / a[1]) for a in st['quarters'].values()]}") +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 (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") +# 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, 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 + + +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=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"] < 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)") +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()") + + +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) +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]) +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": "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]}") +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 " + "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"]) + +print() +if fails: + print(f"{len(fails)} FAILED:") + for f in fails: + print(f" - {f}") + sys.exit(1) +print("all checks passed") 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 9a76f1a968a74422af1074201c5b4f8298dd0d50 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 14 Nov 2026 16:07:12 +0100 Subject: [PATCH 6/8] MAINT-28 fix-forward rounds 1-4, re-applied after the release/1.0 merge The same diff as f3f9483..07d23d2 (cap, settled rule, tests, DECISIONS). The merge took the release/1.0 tree, so the rounds are applied as one patch on top of the restored #247. Co-Authored-By: Claude --- .../MAINT-28-hold-the-pause-in-a-peak.md | 21 +- goodwe_controller/app/ctl_cycle.py | 30 +- goodwe_controller/app/ctl_trade.py | 69 ++- goodwe_controller/app/main.py | 2 + goodwe_controller/app/peakguard.py | 11 +- goodwe_controller/test_maint28b.py | 429 ++++++++++++++++++ goodwe_controller/test_packshare.py | 5 +- goodwe_controller/test_safety84.py | 15 +- goodwe_controller/tools/opt11p3_replay.py | 14 +- 9 files changed, 579 insertions(+), 17 deletions(-) create mode 100644 goodwe_controller/test_maint28b.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 index 5cbf248..6b83d7b 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 @@ -1,6 +1,6 @@ # 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`). +**Status: fix-forward after the rig FAIL (TEST-23, f3f9483).** Tests: `test_maint28.py`, `test_maint28b.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. @@ -8,7 +8,7 @@ ## The change `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. +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 does not stay put when a battery steps: see "Fix-forward" below. 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. @@ -34,3 +34,20 @@ Rig (MAINT-24 test report, F-1): 468 reversals in 45 min on 1b5f7c9, 11 on 02eac ## Not changed MAINT-24's rate and its tests, N=1, the drain, modes off (digests of 1b5f7c9). The pause for a late or missing forecast. + +## Fix-forward (rig FAIL F-1, F-2; `reports/MAINT-28-test-report.md`) +The sim had no battery-reading lag, and the first version said the 300 W line is far larger than the lag moves the estimate. That was wrong. The rig publishes a battery's power every 10 s; the meter is newer. After each guard step charge -> give (+500-1000 W) the next meter reading shows the give and the battery reading still shows the charge, so `grid + site_batt_w` drops by the step, under the 5650 W line: 69/69, 132/133, 215/215, 51/51 releases at a median 5430 W with the house at 6450 W or more. Test: `test_maint28b.py`, sim `Lag.batt_period_s`. + +**F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 4 s after the load (12 s in test_maint28b), not 0: the first settled estimate. No cut-back is touched. + +**F-2 and the cap.** Owner 2026-11-13 ("1" = cap): while the pause holds, the pack in maintenance CHARGE gives only what the guard claims for the peak, at any SoC; other packs unchanged. Owner 2026-11-13 ("ok max", relayed by the manager): accepted "while the hold is on, the pack in maintenance CHARGE gives at most the largest floor the peak guard claimed in the current quarter" (about 0.37 kWh per 45-min peak in the sim; quarters at or under the aim). + +The rule as built (`Controller._m28_share()`): while the pause holds (N>=2, month aim above the cap) the pack gets a unit limit `hi` = the largest part of the guard's floor placed on it in this 15-minute period. Its part in a cycle is the floor less what the other units are placed at in this cycle's split without that limit, so a part the guard's split puts on the other pack is not this pack's. A pack the guard holds at its reserve (`peak_guard.held`) gets 0 W and its part starts again from 0 W. The largest part is kept per unit (`_m28_hi`, keyed by the unit) and dropped in a new period or when the pause ends; only one pack can be in maintenance CHARGE anyway (`maintenance.arbitrate()`, one slot per site). + +Why the largest of the period and not the present part: the guard alone lands the quarter 9.5-10 W over its aim in the sim (SAFETY-126: its own projection ends on 5950.0 W but the meter reading it counts lags the true grid), and with the present part only, test_maint28 part 1 ends at 5960.1 W; the largest of the period lets the loop keep A up and it ends at 5925 W. Why not `hi` = 0 W: with the other pack under its reserve the guard needs this one (test_maint28b, house 12 kW, B at 18 %: A gives 1.07 kWh for the guard). + +Tests (test_maint28b, 13-min load, A 5 kW): A at 49 %: SoC drop 0.64 point (the owner's 0.37 kWh per 45 min is 0.70 point here; the check is <= 0.70). Both packs able, B on Cheap hours: B is placed nothing, A's limit is the period's largest floor (3087 W) and A gives 3081 W. B on Solar first (gives for the house): A's limit 3294 W against a period's largest floor of 6284 W, under it by 500 W or more in 564 of 569 paused cycles; A still gives 3294 W, so the quarter ends 3843 W, far under the aim: that is the cost of the largest-of-period rule when the other pack gives for the house. + +Sim, rig shape, 45 min, house 6500 W: A in CHARGE at 49 %: A gives 0.17 kWh (f3f9483 0.44), SoC 50.5 -> 49.0 %, charge/give flips 1, give/idle switches 3, quarters 5358 1663 1899 2572 4476 W, charge back 4 s after the load; B (Cheap hours, not in maintenance) gives 3.04 kWh for the house (f3f9483 0.32). B at 18.2 % in CHARGE: f3f9483 gave 1.89 kWh to 10.0 %; now 0.00 kWh, 18.2 %, quarters 5948 and 5952 W (the guard alone, on A). test_safety84 n2_charge_banked: banked quarters 3349 and 3217 W against the aim 3950 W. + +Mutants, each red: the freshness check dropped (always settled); the cap dropped; the other units' placement dropped (part = the whole floor); one cap for the site instead of per unit; the 0 W for a held pack dropped (all in `test_maint28b.py`); the period's largest dropped (present part only: `test_maint28.py` part 1 quarter 5960 W). diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index a8928a7..5b749b9 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -29,7 +29,7 @@ from . import shadow from .price import Uncertainty from .tariff import next_quarter from .watchdog import loop_is_following -from .ctl_trade import pack_energy_of +from .ctl_trade import M28_REASON, pack_energy_of from .rows import why_key, why_raised _LOG = logging.getLogger("goodwe") @@ -574,10 +574,11 @@ class CycleMixin: else float(self.grid) + float(self.site_batt_w)) _susp = peakguard.shaping_suspended( self.peak_guard, self.peak_fc, maint_aim) + _m28 = (len(self.inverters) > 1 + and maint_aim > self.peak_aim_w()) self._maint_peak_hold = peakguard.shaping_held( - _susp, _peak_held and len(self.inverters) > 1 - and maint_aim > self.peak_aim_w(), - self.peak_fc, maint_aim, house) + _susp, _peak_held and _m28, self.peak_fc, maint_aim, + house, self._batts_settled()) 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 @@ -588,6 +589,18 @@ class CycleMixin: "suspended so the peak claim is not " "contradicted", hint=messages.hint("maint_paused")) + # MAINT-28: while held this pack gives at most the most of + # the guard's floor placed on it this period + # (`_m28_share()`). + # DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md + shape = ((0.0 if self.peak_guard is not None + and inv.name in self.peak_guard.held + else max([c.lo for c in claims + if c.source == peakguard.SOURCE] + + [self._m28_was(self.actuator_of( + "maintenance", inv), now)], + default=0.0), M28_REASON) + if _m28 else None) else: self._maint_shape_suspended = False hi = 0.0 @@ -604,10 +617,12 @@ class CycleMixin: floor = self._charge_headroom(floor, claims) if floor > 0: hi, reason = -floor, "cheap-window charge" + shape = (hi, reason) + if shape is not None: # PER-INVERTER, like the SET above and for the same reason - # the shaping is about the pack being charged, which is `inv`. - claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, - reason=reason, + claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, + hi=shape[0], reason=shape[1], actuator=self.actuator_of("maintenance", inv))) @@ -855,6 +870,9 @@ class CycleMixin: self.loop_clamp_facts = () self._loop_decision = None + # MAINT-28: a paused pack gives at most its largest part of the floor + # in this period. + self._m28_share(claims, now) # SAFETY-28. A new resolution is a new tick, and the previous record is # stale from this line onwards. Incremented BEFORE resolve() so that the # record resolve() builds is stamped with the tick it is for. diff --git a/goodwe_controller/app/ctl_trade.py b/goodwe_controller/app/ctl_trade.py index 09b92f6..df0d21a 100644 --- a/goodwe_controller/app/ctl_trade.py +++ b/goodwe_controller/app/ctl_trade.py @@ -10,7 +10,7 @@ from datetime import datetime, timedelta, timezone from . import arbitrage from .arbiter import ( ACTUATOR_SITE, Claim, P_MAINTENANCE, P_MAINT_SHAPE, P_PEAK, SET, - pre_resolve, + pre_resolve, resolve, ) from . import allocator from . import dayplan @@ -19,6 +19,7 @@ from . import packmode from . import priceruns from . import solarfc from . import control as _ctl +from . import freshness from . import messages from .maintenance import CHARGE, DRAIN, HOLD, IDLE, MaintConfig, POWER_MOVING from .mqtt import MODE_LABELS, USE_OFF, USE_ON, unit_slugs, unit_state_key @@ -49,6 +50,13 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S HOUSE_PROFILE_KEY = "house_profile" +# MAINT-28: a step of a battery's command, in W, after which its reading is not +# current until a changed one arrives (`Controller._batts_settled()`). +MAINT_STEP_W = 100.0 +# MAINT-28: the reason on a paused pack's cap (`Controller._m28_share()`). +M28_REASON = "peak hold" + + # OPT-34: the level of a night grid charge that has started, so it does not # drop when the forecast goes (`Controller._latch_charge()`). CHARGE_LATCH_KEY = "night_charge_latch" @@ -711,6 +719,65 @@ class TradeMixin: lo = hi = float(inv.batt) self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since) + def _batts_settled(self) -> bool: + """MAINT-28: False while a battery reading may pre-date a step UP of + its command, so `grid + site_batt_w` is a new grid and an old battery. + DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md""" + now, ok = self._now_mono, True + if now is None: + return True + for inv in self.inverters: + cmd = float(inv.standing_w()) + seen = self._m28_step.get(inv.name) + if seen is None: + seen = self._m28_step[inv.name] = (cmd, None) + elif abs(cmd - seen[0]) >= MAINT_STEP_W: + seen = self._m28_step[inv.name] = ( + cmd, now if cmd > seen[0] else seen[1]) + at = seen[1] + if at is None or now - at >= freshness.BATT_OBLIGED_S: + continue + got = inv.batt_fresh.changed_mono + if got is None or got < at + freshness.PLANT_SETTLE_S: + ok = False + return ok + + def _m28_share(self, claims, now) -> None: + """MAINT-28: each paused pack's cap (0 W if the guard holds it at its + reserve) set to the largest part of the guard's floor left to it in + this 15-minute period: the floor less what the other units are placed + at in this cycle's split without the cap. Kept per unit in `_m28_hi`, + dropped while the unit is not paused or is held. + DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md""" + floor = max((c.lo for c in claims if c.source == peakguard.SOURCE), + default=0.0) + g = self.peak_guard + held = {self.actuator_of("maintenance", inv) for inv in self.inverters + if g is not None and inv.name in g.held} + q, seen = int(now.timestamp() // 900), set() + for i, c in enumerate(claims): + if c.source != "maintenance" or c.reason != M28_REASON \ + or c.actuator == ACTUATOR_SITE or c.actuator in held: + continue + seen.add(c.actuator) + shares = resolve(claims[:i] + claims[i + 1:], units=self.units, + live=self.live_units(), weights=self.pack_weights, + reserve=self._held_back()).shares + part = max(0.0, floor - sum(float(w) for u, w in shares.items() + if u != c.actuator)) + hi = max(part, self._m28_was(c.actuator, now)) + self._m28_hi[c.actuator] = (q, hi) + claims[i] = dataclasses.replace(c, hi=hi) + # A unit not paused (or held at its reserve) starts again from 0 W. + for u in set(self._m28_hi) - seen: + del self._m28_hi[u] + + def _m28_was(self, u, now) -> float: + """MAINT-28: unit `u`'s cap so far in this 15-minute period, 0 W in + a new one (`_m28_share()`).""" + q, hi = self._m28_hi.get(u, (None, 0.0)) + return hi if q == int(now.timestamp() // 900) else 0.0 + def _reseed_drop(self, live) -> None: """SAFETY-96, N>=2: a battery that drops out of the split (stale link) leaves the loop's start point, so the others take over at one slew step diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 2d6cece..6939231 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -506,6 +506,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self._peak_needed = False self._maint_shape_suspended = False self._maint_peak_hold = False # MAINT-28 + self._m28_step = {} # MAINT-28: unit -> (command, step at) + self._m28_hi = {} # MAINT-28: unit -> (period, cap) # 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 6f052aa..9a14c79 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -450,18 +450,23 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool: return decision is not None and decision.engaged -def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w) -> bool: +def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w, + settled=True) -> 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. + as SAFETY-63) and `settled` (no battery reading older than its last + command step, so `house_w` is not a new grid plus an old battery). Only + ever pauses the maintenance charge, never a cut-back. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.""" if suspended: 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 + if level is None: + return False + return level > float(aim_w) - MAINT_RESUME_UNDER_W or not settled def cap_floor(decision, cap_w): diff --git a/goodwe_controller/test_maint28b.py b/goodwe_controller/test_maint28b.py new file mode 100644 index 0000000..0b1d3bf --- /dev/null +++ b/goodwe_controller/test_maint28b.py @@ -0,0 +1,429 @@ +"""Runnable check for MAINT-28 fix-forward. `python3 test_maint28b.py` + +The rig failed MAINT-28 (TEST-23, f3f9483): the hold of the maintenance pause +ended at almost every guard step (F-1), and while it stuck, the pack in +maintenance CHARGE gave its full rating for the house below its peak reserve +(F-2). test_maint28.py missed both because its battery reading has no lag. Here +the battery register is published every 10 s and held (`Lag.batt_period_s`), +the meter reading is 2 s old, as on the rig; the rating is the rig's A (5 kW). + +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, house +-50 W every 5 s, 3000 W then +6500 W for 13 min (over a quarter boundary), then 3000 W. + +1. A real peak, A in CHARGE above its peak reserve: A flips charge/give a few + times (base 1b5f7c9: dozens), no release of the pause on an estimate that + mixes a new grid with an old battery reading, quarters at or under the aim, + A's charge back after the load. +2. F-2 and the cap (owner 2026-11-13): while the pause holds, A gives at most + the largest part of the guard's floor placed on it in the 15-min period, + at any SoC (never under its reserve; SoC drop within the owner-accepted + give); with both packs able, never over the period's largest floor, and + when B is placed part of it A's limit is its own part, not the whole floor; + one unit's limit is its own (two packs in CHARGE never share it); with the + other pack under its reserve, A still gives for the guard. +3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing + for the house. +4. Unchanged: the MAINT-24 live shape (A 1475 W, B no grid trade), with the + same lag on. +5. `peakguard.shaping_held()` with `settled`, and `Controller._batts_settled()`. +Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md. +Read MUTATION.md before you report a survivor. +""" + +import logging +import os +import random +import sys +from datetime import timedelta + +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 opt11p3_replay as rp # noqa: E402 +import opt20_scen as sc # noqa: E402 +from app import (ctl_cycle, ctl_trade, freshness, maintenance, # noqa: E402 + packmode, peakguard, tariff) + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +rp.RATING, rp.CAP_KWH = 5000.0, 15.36 # the rig's A +LAG = rp.Lag(dead_s=1.0, meter_s=2.0, batt_s=1.0, p1_s=0.35, tick=1.0, + tau_s=2.0, ramp_w_s=1e9, batt_period_s=10.0) +CH = sc.MAINT_CHARGE +MONTH_PEAK = 6000.0 +AIM = MONTH_PEAK - 50.0 +LINE = AIM - getattr(peakguard, "MAINT_RESUME_UNDER_W", 300.0) +T0 = sc.at(2.0) # a quarter boundary +PRE, LOAD, POST = 120.0, 780.0, 120.0 # s (CI time: short runs) +RESERVE = 20.0 # peak_soc_floor default + + +def stepped_house(base_w, hi_w): + 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(*, unit="a", soc=49.0, other=30.0, house=None, house_w=3000.0, + peak=MONTH_PEAK, dur=PRE + LOAD + POST, fc="quarter", n=2, + choice=None): + """`unit` is in maintenance CHARGE (forced result), at `soc`, the other at + `other`. Rows: the plants' power and SoC, the pause flag, the add-on's house + estimate and the real house.""" + rows = [] + q = dict(q=None, s=0.0, last=None, start=None) + idx = 0 if unit == "a" else 1 + o = "b" if unit == "a" else "a" + + def patch(c): + c.maints[idx].tick = ( + lambda now, soc_, blocked_by=None, gate_open=True: CH) + 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 + + async def cyc(): + c.cheap = True + if fc and c.grid is not None: + g = float(c.grid) + now = ctl_cycle.datetime.now() # the replay's clock + 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() + c.cycle = cyc + + def cb(c, h, now, pr): + est = (None if c.grid is None or c.site_batt_w is None + else float(c.grid) + float(c.site_batt_w)) + g = c.peak_guard + floor = (float(g.floor_w) if g is not None and g.engaged + and g.floor_w is not None else 0.0) + sh = (dict(c.allocation.shares) if c.allocation is not None + and c.allocation.shares else {}) + au = c.actuator_of("maintenance", c.inverters[idx]) + ao = c.actuator_of("maintenance", c.inverters[1 - idx]) + rows.append(dict( + t=(now - T0).total_seconds(), grid=h.grid(), u=h.plants[unit].p, + o=h.plants[o].p, soc=h.plants[unit].soc, soc_o=h.plants[o].soc, + hold=bool(getattr(c, "_maint_peak_hold", False)), est=est, + floor=floor, share_u=float(sh.get(au, 0.0)), + hi_u=next((float(pl.hi) for pl in (c.allocation.plans + if c.allocation else ()) + if pl.unit == au), None), + share_o=float(sh.get(ao, 0.0)), + house=h.grid() + sum(p.p for p in h.plants.values()))) + + src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)] + ch = dict(sc.CH_CH if choice is None else choice) + stats, c = rp.run("p3_ch_sell", src, sc.WPTS, T0, dur, check=cb, lag=LAG, + n=n, soc0={unit: soc, o: other}, choice=ch, + refuse=(), patch=patch, + extra={"cycle_cost_threshold_eur": 0.10}) + return rows, stats, c + + +def flips(rs, k="u"): + n, s = 0, 0 + for r in rs: + v = 1 if r[k] > 50 else -1 if r[k] < -50 else 0 + if v and s and v != s: + n += 1 + if v: + s = v + return n + + +def releases(rows): + """Cycles in the load where the pause ended: (row, estimate under the + release line while the real house was over it).""" + out = [] + for p, r in zip(rows, rows[1:]): + if PRE <= r["t"] < PRE + LOAD and p["hold"] and not r["hold"]: + out.append((r, r["est"] is not None and r["est"] < LINE + and r["house"] >= LINE)) + return out + + +def quarters_ok(st): + # SAFETY-126: a landing 0-7 W over the aim is not an overrun. + return (max(a[0] / a[1] for a in st["quarters"].values()) + <= AIM + peakguard.ALARM_MISS_W) + + +def show(tag, rows, st): + load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] + rel = releases(rows) + print(f" {tag}: flips {flips(load)}, charge s " + f"{sum(1 for r in load if r['u'] < -50)}, releases {len(rel)} " + f"(stale {sum(1 for _r, s in rel if s)}), lowest SoC " + f"{min(r['soc'] for r in rows):.1f} %, give kWh " + f"{sum(r['u'] for r in load if r['u'] > 0) / 3.6e6:.2f}, quarters " + f"{[round(a[0] / a[1], 1) for a in st['quarters'].values()]}") + + +# ============================================================================ # +print("1. a real peak, A in CHARGE at 49 % (above its reserve), the battery " + "reading lagging") +rows, st, c = scen(house=stepped_house(3000.0, 6500.0)) +show("A 49 %", rows, st) +load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] +rel = releases(rows) +after = [r for r in rows if r["t"] >= PRE + LOAD] +back = next((i for i, r in enumerate(after) if r["u"] < -50), None) +print(f" A's charge back {back} s after the load") +check("A flips charge/give 3 times or fewer in the 13 min (1b5f7c9: 60, " + "f3f9483: 11)", flips(load) <= 3) +check("the pause never ends on an estimate under the release line while the " + "real house is over it", not any(s for _r, s in rel)) +check("every quarter at or under the month aim", + quarters_ok(st)) +check("after the load A's charge is back within 20 cycles (1 s each: a 10 s " + "register period plus its settle time, 16 s)", + back is not None and back <= 20) + +# ============================================================================ # +print("\n2. the cap: while the pause holds, A gives at most the largest part " + "of the guard's floor placed on it this period") +rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0)) +show("A 21 %", rows, st) +check("A gives only the guard's claim, under 0.15 kWh (f3f9483 0.94)", + sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.15) +check("A never goes under its peak reserve in the peak (rig: to 10 %)", + min(r["soc"] for r in rows) >= RESERVE - 0.5) +check("every quarter at or under the month aim", quarters_ok(st)) +rows, st, c = scen(house=stepped_house(3000.0, 6500.0)) +soc_start = max(r["soc"] for r in rows if r["t"] <= PRE + 5.0) +drop = soc_start - min(r["soc"] for r in rows if r["t"] >= PRE) +print(f" A at 49 %: SoC drop {drop:.2f} point") +# Owner 2026-11-13 accepted about 0.37 kWh per 45-min peak with the cap +# (DECISIONS): 0.37 * 13 / 45 = 0.107 kWh in this load, 0.70 point of +# 15.36 kWh. Measured 0.64. +check("A at 49 %: its SoC drops no more than the owner-accepted give allows " + "(0.70 point in the 13 min; f3f9483 6.1)", drop <= 0.70) +rows, st, c = scen(soc=49.0, other=18.2, house=stepped_house(3000.0, 12000.0)) +show("A 49 %, B 18 % under its reserve, house 12000 W", rows, st) +load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] +check("the guard's own claim stands: with B under its reserve A gives for " + "the peak (over 0.2 kWh; the cap yields to the guard)", + sum(r["u"] for r in load if r["u"] > 0) / 3.6e6 > 0.2) +g = [("give" if r["u"] > 50 else "idle") for r in load] +sw = sum(1 for x, y in zip(g, g[1:]) if x != y) +print(f" give/idle switches of A in the load: {sw}") +check("...give/idle switches of A at most 10 in the 13 min", sw <= 10) + +def period_max(rows, f): + """Per row: the largest f(row) over the paused rows of its 15-minute + period so far (0 W outside the pause), as the owner rule reads.""" + out, q, m = [], None, 0.0 + for r in rows: + qi = int((T0.timestamp() + r["t"]) // 900) + if qi != q or not r["hold"]: + q, m = qi, 0.0 + if r["hold"]: + m = max(m, f(r)) + out.append(m) + return out + + +for tag, ch, hi_w in (("B on Cheap hours", None, 9000.0), + ("B on Solar first (it gives for the house too)", + {"a": sc.CH_CH["a"], + "b": (True, packmode.SOLAR_FIRST, False)}, + 12000.0)): + rows, st, c = scen(soc=49.0, other=40.0, choice=ch, + house=stepped_house(3000.0, hi_w)) + show(f"A 49 %, B 40 %, both able, {tag}, house {hi_w:.0f} W", rows, st) + mf = period_max(rows, lambda r: r["floor"]) + held_rows = [(r, m) for r, m in zip(rows, mf) + if r["hold"] and r["floor"] > 0.0 and r["hi_u"] is not None] + n = max(1, len(held_rows)) + over = sum(1 for r, m in held_rows if r["share_u"] > m + 2.0) + # the whole floor, as far as A's rating lets it be A's limit + under = sum(1 for r, m in held_rows + if r["hi_u"] < min(m, rp.RATING) - 500.0) + print(f" {len(held_rows)} paused cycles with a guard floor (mean " + f"{sum(r['floor'] for r, _m in held_rows) / n:.0f} W, period's " + f"largest {sum(m for _r, m in held_rows) / n:.0f} W): A's limit " + f"{sum(r['hi_u'] for r, _m in held_rows) / n:.0f} W, A " + f"{sum(r['share_u'] for r, _m in held_rows) / n:.0f} W, B " + f"{sum(r['share_o'] for r, _m in held_rows) / n:.0f} W; A over the " + f"period's largest floor in {over}; A's limit 500 W or more under it" + f" (or A's rating) in {under}") + check(f"{tag}: the pause holds with a guard floor for over 5 min", + len(held_rows) >= 300) + check("...A never gives more than the largest guard floor of the period " + "(owner 2026-11-13)", over == 0) + if ch is not None: + check("...B is placed part of the floor, so A's limit is its own part," + " 500 W or more under the period's largest floor (or A's " + "rating) in 9 of 10 cycles (not the whole floor)", + under >= 0.9 * len(held_rows)) + check("...every quarter at or under the month aim", quarters_ok(st)) + +rows, st, c = scen(soc=18.2, other=30.0, house=stepped_house(3000.0, 12000.0)) +show("A 18 % under its reserve, B 30 % able, house 12000 W", rows, st) +check("the guard places its claim on the other pack: A under its reserve " + "gives under 0.05 kWh and its SoC stays at 18 %", + sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.05 + and min(r["soc"] for r in rows) >= 18.0) + +# ============================================================================ # +print("\n3. mirror: B in CHARGE at 18 % (under its reserve), A on Cheap hours") +rows, st, c = scen(unit="b", soc=18.2, other=30.0, + house=stepped_house(3000.0, 6500.0)) +show("B 18 %", rows, st) +check("B gives nothing for the house: its SoC never goes under 18 %", + min(r["soc"] for r in rows) >= 18.0) +check("every quarter at or under the month aim", quarters_ok(st)) + +# ============================================================================ # +print("\n4. unchanged: the MAINT-24 live shape with the lagging reading") +rl, sl, cl = scen(house_w=3000.0, dur=600.0) +body = rl[len(rl) // 6:] +mean_a = sum(r["u"] for r in body) / len(body) +mean_b = sum(r["o"] for r in body) / len(body) +print(f" A {mean_a:+.0f} W, B {mean_b:+.0f} W, pause " + f"{sum(r['hold'] for r in rl)} cycles") +check("A charges about 1475 W (-1500..-1440) and the pause never comes", + -1500.0 <= mean_a <= -1440.0 and not any(r["hold"] for r in rl)) +check("B does not trade with the grid (Cheap hours, nothing to charge it)", + abs(mean_b) < 100.0) + +# ============================================================================ # +print("\n5. peakguard.shaping_held() with `settled`; Controller._batts_settled()") +check("the rule exists (MAINT_STEP_W, _batts_settled)", + hasattr(ctl_trade, "MAINT_STEP_W") and hasattr(ctl_trade.TradeMixin, + "_batts_settled")) +if not hasattr(ctl_trade, "MAINT_STEP_W"): + print(f"\n{len(fails)} FAILED:") + for f in fails: + print(f" - {f}") + sys.exit(1) +U = peakguard.MAINT_RESUME_UNDER_W +idle = peakguard.PeakDecision(engaged=False, floor_w=None, ceiling_w=6000.0, + ceiling_from="x", target_w=AIM) + + +def held(was, fc, house, settled): + return peakguard.shaping_held( + peakguard.shaping_suspended(idle, fc, AIM), was, fc, AIM, house, + settled) + + +check("held, house under the line but not settled: stays paused", + held(True, AIM - 1, AIM - U - 100.0, False)) +check("held, house under the line and settled: resumes", + not held(True, AIM - 1, AIM - U - 100.0, True)) +check("not held: settled makes no difference", + not held(False, AIM - 1, 6500.0, False)) +check("neither the house nor the forecast read: no pause, settled or not " + "(SAFETY-63)", + not held(True, None, None, False)) + + +class _Fresh: + def __init__(self, changed): + self.changed_mono = changed + + +class _Inv: + def __init__(self, name, cmd, changed): + self.name, self.cmd = name, cmd + self.batt_fresh = _Fresh(changed) + + def standing_w(self): + return self.cmd + + +class _Ctl: + _batts_settled = getattr(ctl_trade.TradeMixin, "_batts_settled", None) + + def __init__(self, invs): + self.inverters, self._m28_step, self._now_mono = invs, {}, 0.0 + + +S = freshness.PLANT_SETTLE_S +a = _Inv("a", -1500.0, 0.0) +k = _Ctl([a, _Inv("b", 0.0, 0.0)]) +check("no step seen yet: settled", k._batts_settled()) +a.cmd = 500.0 +k._now_mono = 100.0 +check("a step up of the command: not settled", not k._batts_settled()) +k._now_mono = 100.0 + S - 0.5 +a.batt_fresh.changed_mono = 100.0 + S - 1.0 +check("...a reading that arrived before step + settle does not count", + not k._batts_settled()) +a.batt_fresh.changed_mono = 100.0 + S +k._now_mono = 100.0 + S +check("...one that arrived at step + settle does", k._batts_settled()) +a.cmd = 3000.0 +k._now_mono = 200.0 +check("a second step up; the old reading is not current", + not k._batts_settled()) +k._now_mono = 200.0 + freshness.BATT_OBLIGED_S +check("...and a reading that never changes is waited for no longer than " + "BATT_OBLIGED_S", k._batts_settled()) +a.cmd = -1500.0 +k._now_mono = 400.0 +check("a step down (less give: the estimate errs high) is no step", + k._batts_settled()) +a2 = _Inv("a", -1500.0, 0.0) +k2 = _Ctl([a2]) +k2._batts_settled() +a2.cmd, k2._now_mono = -1500.0 + ctl_trade.MAINT_STEP_W - 1.0, 1.0 +check("a move under ctl_trade.MAINT_STEP_W is no step", k2._batts_settled()) +a2.cmd = -1500.0 + ctl_trade.MAINT_STEP_W +check("...one of ctl_trade.MAINT_STEP_W is", not k2._batts_settled()) + +# Per unit: the cap so far lives under each unit's own key, and only one pack +# can be in maintenance CHARGE at all (maintenance.arbitrate(): one slot). +au, ab = (cl.actuator_of("maintenance", i) for i in cl.inverters) +t = T0 + timedelta(seconds=60) +cl._m28_hi = {au: (int(t.timestamp() // 900), 3000.0)} +check("the period's cap is kept per unit: A's 3000 W is not B's", + cl._m28_was(au, t) == 3000.0 and cl._m28_was(ab, t) == 0.0) +check("...and it starts again from 0 W in the next 15-minute period", + cl._m28_was(au, t + timedelta(seconds=900)) == 0.0) + + +class _M: + def __init__(self, name): + self.name, self.phase = name, maintenance.CHARGE + + +check("maintenance.arbitrate(): with two packs in CHARGE only the first keeps " + "the slot", maintenance.arbitrate([_M("a"), _M("b")], t)[1] != "" + and maintenance.arbitrate([_M("a"), _M("b")], t)[0] == "") + +print() +if fails: + print(f"{len(fails)} FAILED:") + for f in fails: + print(f" - {f}") + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/test_packshare.py b/goodwe_controller/test_packshare.py index b4e3893..b0c43a4 100644 --- a/goodwe_controller/test_packshare.py +++ b/goodwe_controller/test_packshare.py @@ -1148,7 +1148,10 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE "could do - and no fixture anywhere could see that, because every check " "calls `resolve()` for itself. A method, not a dict, because the " "weighting is direction-dependent and the direction is stage 2's output", - len(_resolve_calls) == 1 and _w_arg == ["self.pack_weights"]) + # MAINT-28 added a second call (`_m28_share()`'s split without the + # paused pack's cap); it must pass the same method, as the cycle's does. + len(_resolve_calls) >= 1 + and _w_arg == ["self.pack_weights"] * len(_resolve_calls)) # ⚠️ AC 3 SAYS `unmet_w` MUST BE **VISIBLE**, AND `Allocation.explain()` IS ONLY # HALF OF THAT. The other half is main.py putting it in the action log, which no diff --git a/goodwe_controller/test_safety84.py b/goodwe_controller/test_safety84.py index 0b0c128..a546491 100644 --- a/goodwe_controller/test_safety84.py +++ b/goodwe_controller/test_safety84.py @@ -484,7 +484,12 @@ print("\n8. the N=1 and N=2 digests") # MAINT-28 re-pinned n2_charge_banked: in the 5000 W load (over the 3950 W # aim) the maintenance charge no longer restarts at -125 W after each guard # stop (t=1550 s, 64 of 480 rows); the pause is held until the load is gone -# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). The other five are equal. +# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). MAINT-28 fix-forward +# re-pinned it again: while the pause holds A gives at most the largest part +# of the guard's floor placed on it this period, so rows 288-479 (t = 1440 s +# on, 192 of 480) differ: a "peak hold" limit claim on A and A's share. Its +# banked quarters stay under the aim 3950 W (3349 and 3217 W; f3f9483 3349 and +# 2527 W), graded below. The other five are equal. PINNED = { "n1_charge": "00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9", @@ -497,8 +502,14 @@ PINNED = { "n2_charge_dear": "aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db", "n2_charge_banked": - "5d523562219245c135a4e9aa856826bc2939a3157c6ae0320cafc2cd1a8a6cf1", + "2da0c3bc9f0ff3857402146efcd6119180a7d4d1cc1297f1c15ec0b8c0104f42", } +_nb_rows, _nb = s84.run(**s84.SCENARIOS["n2_charge_banked"]) +print(f" n2_charge_banked banked quarters {[round(v, 1) for v in banked(_nb)]}" + f" W (aim 3950)") +check("...and in the re-pinned n2_charge_banked every banked quarter is at or " + "under the aim 3950 W", + len(banked(_nb)) >= 2 and all(v <= 3950.0 + 1.0 for v in banked(_nb))) _now = s84.digests() for k in PINNED: print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'} " diff --git a/goodwe_controller/tools/opt11p3_replay.py b/goodwe_controller/tools/opt11p3_replay.py index 589c22f..c3bbad0 100644 --- a/goodwe_controller/tools/opt11p3_replay.py +++ b/goodwe_controller/tools/opt11p3_replay.py @@ -116,10 +116,15 @@ class Lag: after the reading (the HA source's debounce), so a cycle just after a quarter boundary can still see the old block; `tick` the cycle period; `tau_s` / `ramp_w_s` the battery's own lag (TEST-23's plant: `dead_s` 1, - no lag or ramp).""" + no lag or ramp). `batt_period_s` (MAINT-28): the battery register is + published every that many seconds and held between (the rig: 10 s), so its + reading is `batt_s` plus up to a period older than the meter's; None keeps + the constant age.""" def __init__(self, dead_s=1.0, meter_s=1.0, batt_s=1.0, p1_s=0.35, - tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0): + tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0, + batt_period_s=None): + self.batt_period_s = batt_period_s self.dead_s, self.meter_s, self.batt_s = dead_s, meter_s, batt_s self.p1_s, self.tick, self.sub = p1_s, tick, sub self.tau_s, self.ramp_w_s = tau_s, ramp_w_s @@ -211,6 +216,11 @@ class Hass: if e.startswith("sensor.batt"): # A real reading jitters by a few watts; a dead-flat one is a # stale register to the add-on (SAFETY-37), which is not modelled. + if self.lag is not None and self.lag.batt_period_s: + per = self.lag.batt_period_s + ts = math.floor((self.t - self.lag.batt_s) / per) * per + j = (ord(u) * 31 + int(ts)) % 7 - 3 + return float(round(self._past(self.t - ts)[1][u]) + j) j = (ord(u) * 31 + int(self.t)) % 7 - 3 p = self.plants[u].p if self.lag is None else \ self._past(self.lag.batt_s)[1][u] -- 2.54.0 From de06a2c6f5b0c7d7afedc26472c06c240fb36d7d Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 14 Nov 2026 16:40:54 +0100 Subject: [PATCH 7/8] MAINT-28 fix round 5: while the pause holds, the batteries give only what the guard needs H-1: one site limit while the pause holds (the period's largest floor, never under this cycle's), so the other pack no longer gives for the house. New live-try test (A 11 %, B 25 %, 90 min at 6.5 kW) red on 07d23d2. M-1: a 45-min rig-shape test that goes red when the settled check is forced True. M-2/L-2: DECISIONS. CHANGELOG line. test_safety84 n2_charge_banked re-pinned (quarters 3349 and 3950 W, aim 3950 W). Co-Authored-By: Claude --- goodwe_controller/CHANGELOG.md | 4 ++ .../MAINT-28-hold-the-pause-in-a-peak.md | 14 +++-- goodwe_controller/app/ctl_trade.py | 17 +++++- goodwe_controller/app/main.py | 1 + goodwe_controller/test_maint28b.py | 61 ++++++++++++++++++- goodwe_controller/test_safety84.py | 10 +-- 6 files changed, 93 insertions(+), 14 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index da70474..8d039a5 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### Not in a release yet + +- During a real peak, a battery in its maintenance charge now stays paused instead of switching between charging and giving, and the batteries give only what keeps the peak under the limit ([[MAINT-28]]). + ## 0.22.0 **A minor bump: a battery's monthly charge now gets the cheap night, the plan knows about the 7-day wait, a slow meter is followed more gently, and one peak-guard stop no longer costs a night charge its whole period. No option changes.** 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 6b83d7b..1b07bcc 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,12 +14,12 @@ 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). 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. +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 pack in CHARGE gives only its share of the guard's floor (0 W if the guard holds it at its reserve), and the batteries together give only what the guard needs (see Fix-forward, F-2 and H-1). The first version let A give for the house; it was dropped. ## 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). -| 45 min load | base 02eac3b | 1b5f7c9 | MAINT-28 | +| 45 min load | base 02eac3b | 1b5f7c9 | MAINT-28 (f3f9483, without the lag) | |---|---|---|---| | 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) | @@ -40,14 +40,16 @@ The sim had no battery-reading lag, and the first version said the 300 W line is **F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 4 s after the load (12 s in test_maint28b), not 0: the first settled estimate. No cut-back is touched. -**F-2 and the cap.** Owner 2026-11-13 ("1" = cap): while the pause holds, the pack in maintenance CHARGE gives only what the guard claims for the peak, at any SoC; other packs unchanged. Owner 2026-11-13 ("ok max", relayed by the manager): accepted "while the hold is on, the pack in maintenance CHARGE gives at most the largest floor the peak guard claimed in the current quarter" (about 0.37 kWh per 45-min peak in the sim; quarters at or under the aim). +**F-2 and the cap.** Owner 2026-11-13 ("1" = cap): while the pause holds, the pack in maintenance CHARGE gives only what the guard claims for the peak, at any SoC; other packs unchanged (replaced by H-1 below). Owner 2026-11-13 ("ok max", relayed by the manager): accepted "while the hold is on, the pack in maintenance CHARGE gives at most the largest floor the peak guard claimed in the current quarter" (about 0.37 kWh per 45-min peak in the sim; quarters at or under the aim). + +**H-1 (review 2026-11-14, owner decision 2026-11-14): the batteries together give only what the guard needs.** With the cap on A alone, the loop gave for the whole house with B (Cheap hours): in the live-try shape (A 11 % in CHARGE, B 25 %, house 6.5 kW for 90 min) B gave 4.6 kW, went from 25 to 10 % in 43 min, and two quarters were 6502 and 6499 W against the month peak 6000 W. So while the pause holds, `_m28_share()` also files one site limit (source `charge`, `P_MAINT_SHAPE`, per-site `_m28_site`): `hi` = the largest floor of the period, never under this cycle's floor. The guard's claim is not cut: `hi` is at least the floor, `P_PEAK` outranks `P_MAINT_SHAPE`, and `_peak_on_able` still places the whole floor. Outside the hold nothing is filed. Live try, same shape (test_maint28b): quarters 5357 to 5911 W (aim 5950), B 0.86 kWh and ends at 20.6 % (1b5f7c9: 1.0 kWh, 19.8 %), B switches give/idle once, A never. The re-pinned `n2_charge_banked` quarters are 3349 and 3950 W (aim 3950). The rule as built (`Controller._m28_share()`): while the pause holds (N>=2, month aim above the cap) the pack gets a unit limit `hi` = the largest part of the guard's floor placed on it in this 15-minute period. Its part in a cycle is the floor less what the other units are placed at in this cycle's split without that limit, so a part the guard's split puts on the other pack is not this pack's. A pack the guard holds at its reserve (`peak_guard.held`) gets 0 W and its part starts again from 0 W. The largest part is kept per unit (`_m28_hi`, keyed by the unit) and dropped in a new period or when the pause ends; only one pack can be in maintenance CHARGE anyway (`maintenance.arbitrate()`, one slot per site). Why the largest of the period and not the present part: the guard alone lands the quarter 9.5-10 W over its aim in the sim (SAFETY-126: its own projection ends on 5950.0 W but the meter reading it counts lags the true grid), and with the present part only, test_maint28 part 1 ends at 5960.1 W; the largest of the period lets the loop keep A up and it ends at 5925 W. Why not `hi` = 0 W: with the other pack under its reserve the guard needs this one (test_maint28b, house 12 kW, B at 18 %: A gives 1.07 kWh for the guard). -Tests (test_maint28b, 13-min load, A 5 kW): A at 49 %: SoC drop 0.64 point (the owner's 0.37 kWh per 45 min is 0.70 point here; the check is <= 0.70). Both packs able, B on Cheap hours: B is placed nothing, A's limit is the period's largest floor (3087 W) and A gives 3081 W. B on Solar first (gives for the house): A's limit 3294 W against a period's largest floor of 6284 W, under it by 500 W or more in 564 of 569 paused cycles; A still gives 3294 W, so the quarter ends 3843 W, far under the aim: that is the cost of the largest-of-period rule when the other pack gives for the house. +Tests (test_maint28b, 13-min load, A 5 kW): A at 49 %: SoC drop 0.64 point (the owner's 0.37 kWh per 45 min is 0.70 point here; the check is <= 0.70). Both packs able, B on Cheap hours: B is placed nothing, A's limit is the period's largest floor (3087 W) and A gives 3081 W. B on Solar first (it gives for the house): A's limit stays under the period's largest floor by 500 W or more in 740 of 744 paused cycles, and the quarters stay under the aim. -Sim, rig shape, 45 min, house 6500 W: A in CHARGE at 49 %: A gives 0.17 kWh (f3f9483 0.44), SoC 50.5 -> 49.0 %, charge/give flips 1, give/idle switches 3, quarters 5358 1663 1899 2572 4476 W, charge back 4 s after the load; B (Cheap hours, not in maintenance) gives 3.04 kWh for the house (f3f9483 0.32). B at 18.2 % in CHARGE: f3f9483 gave 1.89 kWh to 10.0 %; now 0.00 kWh, 18.2 %, quarters 5948 and 5952 W (the guard alone, on A). test_safety84 n2_charge_banked: banked quarters 3349 and 3217 W against the aim 3950 W. +Sim, rig shape, 45 min, house 6500 W (load from minute 10), A in CHARGE at 49 %, B 30 % on Cheap hours: A gives 0.17 kWh (f3f9483 0.44), SoC 50.5 -> 49.0 %, charge/give flips 1, give/idle switches 1, quarters 5358 5897 5911 5176 4477 W, charge back 4 s after the load; B gives 0.23 kWh (before H-1 3.04, f3f9483 0.32). test_safety84 n2_charge_banked: banked quarters 3349 and 3950 W against the aim 3950 W. -Mutants, each red: the freshness check dropped (always settled); the cap dropped; the other units' placement dropped (part = the whole floor); one cap for the site instead of per unit; the 0 W for a held pack dropped (all in `test_maint28b.py`); the period's largest dropped (present part only: `test_maint28.py` part 1 quarter 5960 W). +Mutants, each red: the freshness check dropped (always settled); the cap dropped; the other units' placement dropped (part = the whole floor); one cap for the site instead of per unit; the 0 W for a held pack dropped (all in `test_maint28b.py`); the period's largest dropped (present part only: `test_maint28.py` part 1 quarter 5960 W); `_batts_settled()` replaced by True at the hold in ctl_cycle.py (the 45-min rig shape in `test_maint28b.py`, one stale release); the site limit dropped (the live try in `test_maint28b.py`: quarters 6502 and 6499 W, B 2.91 kWh to 10.0 %). diff --git a/goodwe_controller/app/ctl_trade.py b/goodwe_controller/app/ctl_trade.py index df0d21a..963e3fe 100644 --- a/goodwe_controller/app/ctl_trade.py +++ b/goodwe_controller/app/ctl_trade.py @@ -747,7 +747,9 @@ class TradeMixin: reserve) set to the largest part of the guard's floor left to it in this 15-minute period: the floor less what the other units are placed at in this cycle's split without the cap. Kept per unit in `_m28_hi`, - dropped while the unit is not paused or is held. + dropped while the unit is not paused or is held. Also one site limit, + the period's largest floor, so the other pack does not give for the + house (`_m28_site`). DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md""" floor = max((c.lo for c in claims if c.source == peakguard.SOURCE), default=0.0) @@ -755,6 +757,8 @@ class TradeMixin: held = {self.actuator_of("maintenance", inv) for inv in self.inverters if g is not None and inv.name in g.held} q, seen = int(now.timestamp() // 900), set() + site = any(c.source == "maintenance" and c.reason == M28_REASON + and c.actuator != ACTUATOR_SITE for c in claims) for i, c in enumerate(claims): if c.source != "maintenance" or c.reason != M28_REASON \ or c.actuator == ACTUATOR_SITE or c.actuator in held: @@ -771,6 +775,17 @@ class TradeMixin: # A unit not paused (or held at its reserve) starts again from 0 W. for u in set(self._m28_hi) - seen: del self._m28_hi[u] + # Site: while the pause holds, the packs together give at most the + # period's largest floor, never under this cycle's (the guard's claim + # and P_PEAK outrank this one anyway). + if not site: + self._m28_site = (None, 0.0) + return + hi = max(floor, self._m28_site[1] if self._m28_site[0] == q else 0.0) + self._m28_site = (q, hi) + claims.append(Claim.limit("charge", P_MAINT_SHAPE, hi=hi, + reason=M28_REASON, + actuator=ACTUATOR_SITE)) def _m28_was(self, u, now) -> float: """MAINT-28: unit `u`'s cap so far in this 15-minute period, 0 W in diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 6939231..04e323b 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -508,6 +508,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self._maint_peak_hold = False # MAINT-28 self._m28_step = {} # MAINT-28: unit -> (command, step at) self._m28_hi = {} # MAINT-28: unit -> (period, cap) + self._m28_site = (None, 0.0) # MAINT-28: (period, site cap) # 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/test_maint28b.py b/goodwe_controller/test_maint28b.py index 0b1d3bf..8e87b9f 100644 --- a/goodwe_controller/test_maint28b.py +++ b/goodwe_controller/test_maint28b.py @@ -28,6 +28,9 @@ peak forecast like the live sensor, house +-50 W every 5 s, 3000 W then 4. Unchanged: the MAINT-24 live shape (A 1475 W, B no grid trade), with the same lag on. 5. `peakguard.shaping_held()` with `settled`, and `Controller._batts_settled()`. +6. H-1 (owner 2026-11-14): while the pause holds the batteries together give + only what the guard needs. The live try (A 11 %, B 25 %, 90 min at 6.5 kW) + and, for the F-1 wiring, the 45-min rig shape. Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md. Read MUTATION.md before you report a survivor. """ @@ -69,11 +72,11 @@ PRE, LOAD, POST = 120.0, 780.0, 120.0 # s (CI time: short runs) RESERVE = 20.0 # peak_soc_floor default -def stepped_house(base_w, hi_w): +def stepped_house(base_w, hi_w, load=LOAD, pre=PRE): 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 + 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 @@ -206,6 +209,27 @@ check("after the load A's charge is back within 20 cycles (1 s each: a 10 s " "register period plus its settle time, 16 s)", back is not None and back <= 20) +# M-1 (review 2026-11-14): the F-1 wiring, `_batts_settled()` read at the hold +# in ctl_cycle.py. With `settled=True` there the 13-min load above still passes; +# the rig shape (the load starts 10 min into the quarter, 45 min long) does not. +RIG_PRE, RIG_LOAD = 600.0, 2700.0 +rows, st, c = scen(house=stepped_house(3000.0, 6500.0, RIG_LOAD, RIG_PRE), + dur=RIG_PRE + RIG_LOAD + POST) +load45 = [r for r in rows if RIG_PRE <= r["t"] < RIG_PRE + RIG_LOAD] +rel45 = [(r, s_) for p, r in zip(rows, rows[1:]) + if RIG_PRE <= r["t"] < RIG_PRE + RIG_LOAD and p["hold"] + and not r["hold"] + for s_ in [r["est"] is not None and r["est"] < LINE + and r["house"] >= LINE]] +print(f" A 49 %, 45 min: flips {flips(load45)}, releases {len(rel45)} " + f"(stale {sum(1 for _r, s_ in rel45 if s_)}), quarters " + f"{[round(a[0] / a[1]) for a in st['quarters'].values()]}") +check("45 min: A flips charge/give 3 times or fewer (f3f9483: 148)", + flips(load45) <= 3) +check("45 min: no release of the pause on a stale estimate (f3f9483: 83 of 83)", + not any(s_ for _r, s_ in rel45)) +check("45 min: every quarter at or under the month aim", quarters_ok(st)) + # ============================================================================ # print("\n2. the cap: while the pause holds, A gives at most the largest part " "of the guard's floor placed on it this period") @@ -292,6 +316,37 @@ check("the guard places its claim on the other pack: A under its reserve " sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.05 and min(r["soc"] for r in rows) >= 18.0) +# H-1 (review 2026-11-14, owner decision): while the pause holds, the packs +# together give only what the guard needs. The live try: A 11 % in CHARGE (under +# its reserve), B 25 % on Cheap hours, 90 min at 6.5 kW. +LIVE = 5400.0 +rp.CAP_KWH = 20.48 # the rig's B; A gives nothing here +rows, st, c = scen(soc=11.0, other=25.0, + house=stepped_house(3000.0, 6500.0, LIVE, RIG_PRE), + dur=RIG_PRE + LIVE + POST) +rp.CAP_KWH = 15.36 +qs = [a[0] / a[1] for a in st["quarters"].values()] +load = [r for r in rows if RIG_PRE <= r["t"] < RIG_PRE + LIVE] +gb = sum(r["o"] for r in load if r["o"] > 0) / 3.6e6 + + +def switches(k): + g = [("give" if r[k] > 50 else "idle") for r in load] + return sum(1 for x, y in zip(g, g[1:]) if x != y) + + +print(f" live try: quarters {[round(q) for q in qs]}, B gives {gb:.2f} kWh, " + f"B ends {rows[-1]['soc_o']:.1f} %, give/idle switches A " + f"{switches('u')}, B {switches('o')}") +check("live try: every quarter at or under the aim + 10 W (07d23d2: 6502 and " + "6499 W against the month peak 6000 W)", max(qs) <= AIM + 10.0) +check("live try: B gives about its guard part, under 1.5 kWh (1b5f7c9 1.0; " + "07d23d2 4.6 kW, 25 -> 10 % in 43 min)", gb < 1.5) +check("live try: B ends at 19.8 % or more (1b5f7c9 19.8)", + rows[-1]["soc_o"] >= 19.8) +check("live try: give/idle switches of B at most 40 in the 90 min", + switches("o") <= 40) + # ============================================================================ # print("\n3. mirror: B in CHARGE at 18 % (under its reserve), A on Cheap hours") rows, st, c = scen(unit="b", soc=18.2, other=30.0, diff --git a/goodwe_controller/test_safety84.py b/goodwe_controller/test_safety84.py index a546491..1281250 100644 --- a/goodwe_controller/test_safety84.py +++ b/goodwe_controller/test_safety84.py @@ -487,9 +487,11 @@ print("\n8. the N=1 and N=2 digests") # (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). MAINT-28 fix-forward # re-pinned it again: while the pause holds A gives at most the largest part # of the guard's floor placed on it this period, so rows 288-479 (t = 1440 s -# on, 192 of 480) differ: a "peak hold" limit claim on A and A's share. Its -# banked quarters stay under the aim 3950 W (3349 and 3217 W; f3f9483 3349 and -# 2527 W), graded below. The other five are equal. +# on, 192 of 480) differ: a "peak hold" limit claim on A and A's share. Round 5 +# (H-1) re-pinned it again: one site limit, the period's largest floor, so the +# batteries give only what the guard needs. Its banked quarters stay at or +# under the aim 3950 W (3349 and 3950 W; f3f9483 3349 and 2527 W), graded +# below. The other five are equal. PINNED = { "n1_charge": "00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9", @@ -502,7 +504,7 @@ PINNED = { "n2_charge_dear": "aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db", "n2_charge_banked": - "2da0c3bc9f0ff3857402146efcd6119180a7d4d1cc1297f1c15ec0b8c0104f42", + "358ebd2a23fe7c7f52f441b60391e40c6605e32bcfb109fa0ff4502d4070745e", } _nb_rows, _nb = s84.run(**s84.SCENARIOS["n2_charge_banked"]) print(f" n2_charge_banked banked quarters {[round(v, 1) for v in banked(_nb)]}" -- 2.54.0 From 79dfcde2bbe2925592500e51fee7d4185c8a2a04 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 14 Nov 2026 17:19:15 +0100 Subject: [PATCH 8/8] MAINT-28: test_maint28b live try reads B's lowest SoC through min() (test_named_absence) Co-Authored-By: Claude --- goodwe_controller/test_maint28b.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/goodwe_controller/test_maint28b.py b/goodwe_controller/test_maint28b.py index 8e87b9f..fb90586 100644 --- a/goodwe_controller/test_maint28b.py +++ b/goodwe_controller/test_maint28b.py @@ -335,15 +335,16 @@ def switches(k): return sum(1 for x, y in zip(g, g[1:]) if x != y) +b_low = min((r["soc_o"] for r in rows), default=0.0) print(f" live try: quarters {[round(q) for q in qs]}, B gives {gb:.2f} kWh, " - f"B ends {rows[-1]['soc_o']:.1f} %, give/idle switches A " + f"B lowest {b_low:.1f} %, give/idle switches A " f"{switches('u')}, B {switches('o')}") check("live try: every quarter at or under the aim + 10 W (07d23d2: 6502 and " "6499 W against the month peak 6000 W)", max(qs) <= AIM + 10.0) check("live try: B gives about its guard part, under 1.5 kWh (1b5f7c9 1.0; " "07d23d2 4.6 kW, 25 -> 10 % in 43 min)", gb < 1.5) -check("live try: B ends at 19.8 % or more (1b5f7c9 19.8)", - rows[-1]["soc_o"] >= 19.8) +check("live try: B's SoC never goes under 19.8 % (1b5f7c9 19.8)", + b_low >= 19.8) check("live try: give/idle switches of B at most 40 in the 90 min", switches("o") <= 40) -- 2.54.0