From 54a738dd8795e8f9221fadb09601382197a246d5 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 06:52:15 +0100 Subject: [PATCH 1/7] MAINT-24: a started maintenance charge gets the cheap night While a pack is in its maintenance CHARGE phase the mode trade files no grid charge for the other packs (why "cycle", mirror of SAFETY-78). The maintenance shaping check and charge floor aim at peakguard.quarter_target_w, month peak included (SAFETY-80). Tests through cycle(): test_maint24.py. Co-Authored-By: Claude --- .../DECISIONS/MAINT-24-charge-first.md | 68 +++++ goodwe_controller/app/ctl_cycle.py | 9 +- goodwe_controller/app/ctl_trade.py | 4 +- goodwe_controller/app/modetrade.py | 13 +- goodwe_controller/test_maint24.py | 243 ++++++++++++++++++ 5 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 goodwe_controller/DECISIONS/MAINT-24-charge-first.md create mode 100644 goodwe_controller/test_maint24.py diff --git a/goodwe_controller/DECISIONS/MAINT-24-charge-first.md b/goodwe_controller/DECISIONS/MAINT-24-charge-first.md new file mode 100644 index 0000000..2aae299 --- /dev/null +++ b/goodwe_controller/DECISIONS/MAINT-24-charge-first.md @@ -0,0 +1,68 @@ +# MAINT-24 - a started maintenance charge gets the cheap night + +**Status: for review.** Tests: `test_maint24.py`. No option added; the MAINT-13 +timeout numbers are unchanged. Folds in SAFETY-80. SAFETY-111 stays on its card (section 3). + +**What happened (live 0.21.0, A, 2026-11-08..11).** A sat in its maintenance +CHARGE phase for more than 75 h and stalled at 49-50 %. At night B's battery-mode +grid charge took the cheap run: A is not idle, so `modetrade.chargers()` never +lists it, B is the only charger, and the allocator hands the whole negative +target to B. A's own shaping then paused or shrank (the check compared the peak +forecast with `cap - margin` = 3450 W, while B's trade and the guard aim at this +month's peak, about 4.9 kW), and A gave power back on guard stops against a peak +that B's own charge made. The timeout (MAINT-13) counted that starved time as +charging time and ended the cycle. Report: `reports/MAINT-24-investigation-report.md`. + +## 1. The maintenance charge goes first in the cheap run + +`modetrade.decide(maint_charging=)`, set in `_mode_roles()` when any pack is in +its maintenance CHARGE phase. It is the mirror of `draining` (SAFETY-78): no +grid-charge trade for the other packs, why `cycle`, the same Tick. The other +packs keep their solar roles, so they may still take spare solar. B's night +charge waits for the nights A needs. At N=1 the only pack is the one in +maintenance, so nothing changes. + +## 2. The shaping aims where the guard aims (SAFETY-80) + +The pause (`shaping_suspended()`) and the floor (`maintenance_charge_floor()`) +get `peakguard.quarter_target_w(cap, margin, month reading)` instead of +`peak_cap_w - peak_margin_w`. With no banked peak above the cap that is the same +number. SAFETY-63 (no forecast: no pause, configured rate) and SAFETY-125 (late +reading: `charge_room_w`) are untouched. `peak_aim_w()` stays for the day plan. + +## 3. SAFETY-111 is not folded in + +`peak_fc` holds the pack's own charge, so the floor `aim - peak_fc` settles at +(aim - house) / 2, here 1475 W instead of 2000 W. Taking the own charge off the +forecast was tried: it lifts A to 2000 W, but it changes the N=1 charge that +`test_safety119.py` pins (a static 800 W forecast then no longer shapes the +charge), so it is a change of its own. 1475 W fills a 10 kWh pack inside the +night (sim below). It stays on its card. + +## 4. A gives nothing while no real peak exists + +With 1 and 2 no grid charge of B makes a peak against the lower aim, and the pause +comes only when the forecast is over the month aim, so A is not made the only +battery that gives while B charges. A guard stop on a real peak +still lands on A (SAFETY-07 / MAINT-02, unchanged). + +## Numbers (sim, house 3000 W, cap 3500, margin 50, month peak 6000 W) + +| | base 02eac3b | MAINT-24 | +|---|---|---| +| A charge | 0 W (B charges 2500 W) | 1475 W = (5950 - 3000) / 2 | +| B grid trade | yes, quarters 5500 W | none, why `cycle` | +| quarter peak | 5500 W | 4475 W (aim 5950 W) | +| A at house 3800 W | 0 W | 1075 W, quarters 4876 W | + +Whole night (sim, `tools`-style replay, N=2, cheap 21:00-06:00, A from 10 %, B +from 30 %, 9 h): base A takes 1.41 kWh (10 -> 23 %), B takes 6.84 kWh by grid +trade; MAINT-24 A takes 9.47 kWh and is full (100 %) inside the night, B takes +no grid charge. Worst 15-minute average 5500 W on base, 4475 W now, A never +gives. + +## Not changed + +The MAINT-13 timeout, the peak aim itself, the month peak, `P_MAINT_SHAPE` and +`P_ARBITRAGE`. The Home card for B does not say "the other battery is cycling" +for a CHARGE phase (`other_cycle` reads "emptying"); that is a wording card. diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index 6f8ac9f..873e746 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -559,8 +559,13 @@ class CycleMixin: # peakguard.shaping_suspended(), NOT HERE. One suspension flag # per SITE (`arbitrate()` allows one pack in CHARGE). # (DECISIONS/MAIN-orchestration-archive.md) + # MAINT-24: the guard's aim, month peak included (SAFETY-80). + # DECISIONS/MAINT-24-charge-first.md + 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, self.peak_aim_w()): + self.peak_guard, self.peak_fc, maint_aim): # 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. @@ -582,7 +587,7 @@ class CycleMixin: # (DECISIONS/MAIN-orchestration-archive.md) floor = maintenance_charge_floor( self.maint.cfg.charge_w, - self.peak_fc, self.peak_aim_w()) + self.peak_fc, maint_aim) floor = self._charge_headroom(floor, claims) if floor > 0: hi, reason = -floor, "cheap-window charge" diff --git a/goodwe_controller/app/ctl_trade.py b/goodwe_controller/app/ctl_trade.py index ea3c078..09b92f6 100644 --- a/goodwe_controller/app/ctl_trade.py +++ b/goodwe_controller/app/ctl_trade.py @@ -422,7 +422,9 @@ class TradeMixin: blocked=self._charge_blocked_q == quarter, done_charge=done_c, done_sell=done_s, targets=targets, draining=any(r.phase == DRAIN for r in maint_results or ()), - wait=wait) + wait=wait, + maint_charging=any(r.phase == CHARGE + for r in maint_results or ())) self._latch_charge(now, runs, targets, packs) # A charge the guard or the headroom stopped stays off this period. # OPT-15: only a MEASURED stop latches; the first unmeasured headroom diff --git a/goodwe_controller/app/modetrade.py b/goodwe_controller/app/modetrade.py index 55184bd..2c07891 100644 --- a/goodwe_controller/app/modetrade.py +++ b/goodwe_controller/app/modetrade.py @@ -56,7 +56,9 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py): the probe (none: PROBE_W alone), so a probe never places charge on a battery in a maintenance phase or on one that rests; * no grid charge while another battery drains for its monthly cycle - (`draining`, SAFETY-78): it would charge against the drain. + (`draining`, SAFETY-78): it would charge against the drain; nor while + one is in its maintenance CHARGE (`maint_charging`, MAINT-24): the cheap + night's room is that pack's. """ from dataclasses import dataclass @@ -180,7 +182,8 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(), def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False, blocked=False, done_charge=(), done_sell=(), - targets=None, draining=False, wait=None) -> Tick: + targets=None, draining=False, wait=None, + maint_charging=False) -> Tick: """This tick's ranges, roles and site ask. `runs` is the kept `priceruns.RunPlan` or None (no fresh prices). @@ -194,7 +197,9 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, this 15-minute period. `targets` is the day plan (`charge_to()`). `draining` says a battery drains for its monthly cycle, so none may charge from the grid (why "cycle"). `wait` ("wait" or "settle", the - why) says no charge may start yet (SAFETY-135). + why) says no charge may start yet (SAFETY-135). `maint_charging` says a + battery is in its maintenance CHARGE phase: no grid charge for the others + either, same why (MAINT-24, DECISIONS/MAINT-24-charge-first.md). """ plan_on = runs is not None and runs.on in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful() @@ -204,7 +209,7 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, why = None ch = chargers(packs, ceiling_pct, done_charge, targets, probe=True) \ if in_cheap else [] - if ch and draining: + if ch and (draining or maint_charging): ch, why = [], "cycle" charging = False if ch: diff --git a/goodwe_controller/test_maint24.py b/goodwe_controller/test_maint24.py new file mode 100644 index 0000000..95a3c73 --- /dev/null +++ b/goodwe_controller/test_maint24.py @@ -0,0 +1,243 @@ +"""Runnable check for MAINT-24. `python3 test_maint24.py` + +A started maintenance charge at N=2 must get the cheap night. On the live site +(2026-11-08..11) battery B's grid charge (battery modes) took the night, and +A's own charge was switched off or sized at 200-450 W: its pause and its floor +compared the peak forecast with `cap - margin`, not with this month's peak +that the guard and B's trade aim at (SAFETY-80), and its floor counted its own +charge in the forecast (SAFETY-111). + +All on the real Controller through `cycle()` (the lagged N=2 replay, tools/ +opt20_scen.py): cheap window, house 3000 W, cap 3500 / margin 50, a month peak +of 6000 W banked (aim 5950 W), a peak forecast that follows the grid. + +1. A in maintenance CHARGE, B a Cheap hours charger: A charges at the room under + the month aim (half of it: the forecast holds A's own charge, SAFETY-111 + stays on its card), B files no grid trade, B's why is "cycle", every quarter is at or + under the month aim. +2. Forecast above 3450 W but under the month aim: shaping is not paused and A + gives nothing. A real peak (house over the month aim) still pauses it. +3. The mirror: B in maintenance CHARGE, A the charger. +4. modetrade.decide(): `maint_charging` files no charge, why "cycle"; solar + roles stay. +5. Unchanged: N=1 with no forecast entity, the SAFETY-78 drain, battery modes + off (digests printed on release/1.0 02eac3b). +Mutations: DECISIONS/MAINT-24-charge-first.md. +""" + +import logging +import os +import sys +from types import SimpleNamespace + +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 modetrade, packmode, tariff # noqa: E402 +from app.maintenance import DRAIN, MaintResult # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +A, B = sc.A, sc.B +MONTH_PEAK = 6000.0 +AIM = MONTH_PEAK - 50.0 # the guard's aim this month +DRAIN_R = MaintResult(DRAIN, True, 2000.0, False, []) + + +def scen(*, a=None, b=None, soc=(49.0, 30.0), house_w=3000.0, peak=MONTH_PEAK, + fc=True, n=2, variant="p3_ch_sell", choice=None, hours=0.75): + """`a` / `b`: that pack's forced maintenance result (or None). `peak`: the + banked month peak or None. `fc`: a peak forecast entity that follows the + grid. Returns (rows, stats, controller).""" + rows = [] + extra = {"cycle_cost_threshold_eur": 0.10} + + def patch(c): + for i, res in enumerate((a, b)): + if res is not None and i < len(c.maints): + c.maints[i].tick = ( + lambda now, soc_, blocked_by=None, gate_open=True, + res=res: res) + 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._m24_susp = susp + + async def cyc(): + c.cheap = True + if fc and c.grid is not None: + c.peak_fc = float(c.grid) + 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()})) + + stats, c = rp.run(variant, [(sc.UTC0.timestamp() - 10, house_w)], sc.WPTS, + sc.at(2.0), 3600 * hours, check=cb, lag=sc.LAG, n=n, + soc0={"a": soc[0], "b": soc[1]}, + choice=choice or sc.CH_CH, refuse=(), patch=patch, + extra=extra) + return rows, stats, c + + +def mean(rows, u): + r = rows[len(rows) // 6:] + return sum(x["p"][u] for x in r) / len(r) + + +def worst_quarter(stats): + return max(a[0] / a[1] for a in stats["quarters"].values()) + + +CH = sc.MAINT_CHARGE + +# ============================================================================ # +print("1. A in maintenance CHARGE, B a charger in the cheap window") +rows, st, c = scen(a=CH) +ma, mb = mean(rows, "a"), mean(rows, "b") +print(f" A {ma:+.0f} W, B {mb:+.0f} W, worst quarter {worst_quarter(st):.0f}" + f" W, why {rows[-1]['why']}, roles {rows[-1]['roles']}") +check("A charges at (5950 - 3000) / 2 = 1475 W, not 0 or a few hundred W", + -1500.0 <= ma <= -1440.0) +check("B files no grid trade: no trade action in the run, B near 0 W", + all("CHARGE" not in str(r["action"]) for r in rows) and mb > -50.0) +check("roles: B does not charge, and the why is 'cycle'", + all(r["roles"].get(B) != "charge" for r in rows) + and rows[-1]["why"] == "cycle") +check("every quarter at or under the month aim", + worst_quarter(st) <= AIM + 1.0) +check("A never gives back while there is no real peak", + max(r["p"]["a"] for r in rows) <= 0.0) +check("the shaping was never paused", not any(c._m24_susp)) + +# ============================================================================ # +print("\n2. forecast between 3450 W and the month aim; a real peak") +rows2, st2, c2 = scen(a=CH, house_w=3800.0) +ma2 = mean(rows2, "a") +print(f" house 3800: A {ma2:+.0f} W, worst quarter " + f"{worst_quarter(st2):.0f} W") +check("forecast 4900 W (over 3450, under 5950): no pause, A charges " + "(5950 - 3800) / 2 = 1075 W and gives nothing", + not any(c2._m24_susp) and -1100.0 <= ma2 <= -1040.0 + and max(r["p"]["a"] for r in rows2) <= 0.0 + and worst_quarter(st2) <= AIM + 1.0) +rows3, st3, c3 = scen(a=CH, house_w=6500.0) +print(f" house 6500: paused {sum(c3._m24_susp)}/{len(c3._m24_susp)} " + f"cycles, A max {max(r['p']['a'] for r in rows3):+.0f} W") +check("a real peak (house over the month aim) still pauses the shaping " + "(SAFETY-07)", sum(c3._m24_susp) > len(c3._m24_susp) // 2) +rows4, st4, c4 = scen(a=CH, house_w=3000.0, peak=None) +print(f" no banked peak: A {mean(rows4, 'a'):+.0f} W, worst quarter " + f"{worst_quarter(st4):.0f} W") +check("no banked peak: the aim is cap - margin (3450 W) as before, so A " + "charges (3450 - 3000) / 2 = 225 W, B does not trade, the quarter " + "stays under 3450 W", + -240.0 <= mean(rows4, "a") <= -210.0 and not any(c4._m24_susp) + and all("CHARGE" not in str(r["action"]) for r in rows4) + and worst_quarter(st4) <= 3450.0 + 1.0) + +# ============================================================================ # +print("\n3. the mirror: B in maintenance CHARGE, A the charger") +rowsm, stm, cm = scen(b=CH, soc=(30.0, 49.0)) +mam, mbm = mean(rowsm, "a"), mean(rowsm, "b") +print(f" A {mam:+.0f} W, B {mbm:+.0f} W, worst quarter " + f"{worst_quarter(stm):.0f} W, why {rowsm[-1]['why']}") +check("B charges at 1475 W, A files no grid trade, why 'cycle'", + -1500.0 <= mbm <= -1440.0 and mam > -50.0 + and all("CHARGE" not in str(r["action"]) for r in rowsm) + and rowsm[-1]["why"] == "cycle" + and all(r["roles"].get(A) != "charge" for r in rowsm)) +check("...every quarter at or under the month aim", + worst_quarter(stm) <= AIM + 1.0) + +# ============================================================================ # +print("\n4. modetrade.decide(maint_charging=)") +_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True), + charge_useful=lambda: True, give_at=lambda n: None, + sell=None) + + +def _dec(packs, **kw): + args = dict(charge_ok=True, sell_ok=True, trade_why=None, price_now=0.1, + headroom_w=3000.0, floor_pct=20.0, ceiling_pct=95.0, + house_w=800.0) + args.update(kw) + return modetrade.decide(_runs, sc.UTC0, packs, **args) + + +_pa = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 50.0, 10.0, + idle=False) +_pb = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0) +_off = _dec([_pa, _pb]) +_on = _dec([_pa, _pb], maint_charging=True) +_dr = _dec([_pa, _pb], draining=True) +check("without it B is the charger (as before)", + _off.action == modetrade.CHARGE and _off.roles[B] == modetrade.CHARGE) +check("with it: no charge, why 'cycle', B keeps a hold role, no ask", + _on.action is None and _on.why == "cycle" and _on.ask_w == 0.0 + and _on.roles[B] == modetrade.HOLD) +check("same answer as the SAFETY-78 drain", _on == _dr) +check("it files nothing outside a cheap run (why None)", + _dec([_pa, _pb], maint_charging=True, + ).why == "cycle" and modetrade.decide( + SimpleNamespace(on=False), sc.UTC0, [_pa, _pb], charge_ok=True, + sell_ok=True, trade_why=None, price_now=0.1, headroom_w=3000.0, + floor_pct=20.0, ceiling_pct=95.0, house_w=800.0, + maint_charging=True).why is None) + +# ============================================================================ # +print("\n5. unchanged: N=1 (no forecast entity), the drain, modes off") +d1 = sc.digest(scen(a=CH, n=1, fc=False, peak=None, soc=(49.0, 30.0))[0]) +dd = sc.digest(scen(a=DRAIN_R, soc=(60.0, 30.0), peak=None, fc=False)[0]) +dm = sc.digest(scen(a=CH, variant="modes_off", peak=None, fc=False)[0]) +AT_BASE = { + "n1": "44816d132984ecd64d5a2510223debe102f29decd1d5127ae2b255b45dbf8bd4", + "drain": "8fd86d0816626ee5ae86bc973cb0121b1b5108e56c3b0fdd4b4be6c1f403f748", + "off": "a007f962793611bce15b11f576a76aa52063adcfde82b2a2dc0b0c075b60e60b", +} +got = {"n1": d1, "drain": dd, "off": dm} +for k in AT_BASE: + print(f" {k} {'same' if got[k] == AT_BASE[k] else 'DIFFERS'} " + f"{got[k][:16]}") +check("N=1 with A in maintenance charge and no forecast entity: shares, " + "grid, trade, roles and plant powers equal release/1.0 02eac3b", + got["n1"] == AT_BASE["n1"]) +check("the SAFETY-78 drain beside a charger: equal to 02eac3b", + got["drain"] == AT_BASE["drain"]) +check("battery modes off: equal to 02eac3b", got["off"] == AT_BASE["off"]) + +print() +if fails: + print(f"{len(fails)} FAILED:") + for f in fails: + print(f" - {f}") + sys.exit(1) +print("all checks passed") From 0f5e513f1dc041415401b34b06544b7198e61c0b Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 07:33:04 +0100 Subject: [PATCH 2/7] MAINT-24: keep the refusal probe beside a maintenance charge The mode trade still lets a SAFETY-73 probe (PROBE_W alone) go on while a pack is in its maintenance CHARGE phase; test_opt20.py g7 pins it. ch_ch_maint digest moved (B no longer grid-charges beside A's charge). Co-Authored-By: Claude --- goodwe_controller/DECISIONS/MAINT-24-charge-first.md | 6 ++++-- goodwe_controller/app/modetrade.py | 9 +++++++-- goodwe_controller/test_maint24.py | 11 +++++++++++ goodwe_controller/test_opt20.py | 9 ++++++--- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/goodwe_controller/DECISIONS/MAINT-24-charge-first.md b/goodwe_controller/DECISIONS/MAINT-24-charge-first.md index 2aae299..8cdbe82 100644 --- a/goodwe_controller/DECISIONS/MAINT-24-charge-first.md +++ b/goodwe_controller/DECISIONS/MAINT-24-charge-first.md @@ -1,6 +1,6 @@ # MAINT-24 - a started maintenance charge gets the cheap night -**Status: for review.** Tests: `test_maint24.py`. No option added; the MAINT-13 +**Status: for review.** Tests: `test_maint24.py` (`test_opt20.py`'s `ch_ch_maint` digest moved with it). No option added; the MAINT-13 timeout numbers are unchanged. Folds in SAFETY-80. SAFETY-111 stays on its card (section 3). **What happened (live 0.21.0, A, 2026-11-08..11).** A sat in its maintenance @@ -18,7 +18,9 @@ charging time and ended the cycle. Report: `reports/MAINT-24-investigation-repor `modetrade.decide(maint_charging=)`, set in `_mode_roles()` when any pack is in its maintenance CHARGE phase. It is the mirror of `draining` (SAFETY-78): no grid-charge trade for the other packs, why `cycle`, the same Tick. The other -packs keep their solar roles, so they may still take spare solar. B's night +packs keep their solar roles, so they may still take spare solar. A refusal probe +(SAFETY-73, PROBE_W alone, OPT-20) still goes on: it places no charge on the +pack in maintenance, and `test_opt20.py` g7 pins it. The drain still blocks it. B's night charge waits for the nights A needs. At N=1 the only pack is the one in maintenance, so nothing changes. diff --git a/goodwe_controller/app/modetrade.py b/goodwe_controller/app/modetrade.py index 2c07891..75f6c58 100644 --- a/goodwe_controller/app/modetrade.py +++ b/goodwe_controller/app/modetrade.py @@ -199,7 +199,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, charge from the grid (why "cycle"). `wait` ("wait" or "settle", the why) says no charge may start yet (SAFETY-135). `maint_charging` says a battery is in its maintenance CHARGE phase: no grid charge for the others - either, same why (MAINT-24, DECISIONS/MAINT-24-charge-first.md). + either, same why, but a refusal probe goes on (MAINT-24, + DECISIONS/MAINT-24-charge-first.md). """ plan_on = runs is not None and runs.on in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful() @@ -209,8 +210,12 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, why = None ch = chargers(packs, ceiling_pct, done_charge, targets, probe=True) \ if in_cheap else [] - if ch and (draining or maint_charging): + if ch and draining: ch, why = [], "cycle" + elif ch and maint_charging: + # MAINT-24: a refusal probe (PROBE_W alone, OPT-20) may go on. + ch = [p for p in ch if p.refused < 0] + why = None if ch else "cycle" charging = False if ch: need = 0.0 if charging_now else START_W diff --git a/goodwe_controller/test_maint24.py b/goodwe_controller/test_maint24.py index 95a3c73..0309fea 100644 --- a/goodwe_controller/test_maint24.py +++ b/goodwe_controller/test_maint24.py @@ -205,6 +205,17 @@ check("with it: no charge, why 'cycle', B keeps a hold role, no ask", _on.action is None and _on.why == "cycle" and _on.ask_w == 0.0 and _on.roles[B] == modetrade.HOLD) check("same answer as the SAFETY-78 drain", _on == _dr) +_pr = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0, + refused=-1, probing=True) +_tp = _dec([_pa, _pr], maint_charging=True) +check("a refusal probe beside the maintenance charge goes on: PROBE_W alone " + "(OPT-20); beside a drain it does not", + _tp.action == modetrade.CHARGE and _tp.ask_w == -modetrade.PROBE_W + and _dec([_pa, _pr], draining=True).action is None) +check("a probe does not carry a normal charger along", + _dec([_pa, _pr, modetrade.Pack("inverter-c", True, packmode.CHEAP_HOURS, + False, 2500.0, 30.0, 10.0)], + maint_charging=True).ask_w == -modetrade.PROBE_W) check("it files nothing outside a cheap run (why None)", _dec([_pa, _pb], maint_charging=True, ).why == "cycle" and modetrade.decide( diff --git a/goodwe_controller/test_opt20.py b/goodwe_controller/test_opt20.py index 889d8fd..39f0816 100644 --- a/goodwe_controller/test_opt20.py +++ b/goodwe_controller/test_opt20.py @@ -248,11 +248,14 @@ print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e") # now counts those seconds, so ch_ch's quarters land on 3450.0 W where they # landed 1.3-1.4 W over it (bf9019e: 15fc25dd...), and ch_ch_maint's first, # partial quarter charges a little less at its end (bf9019e: e46e3c66...). +# MAINT-24 moved ch_ch_maint again: B no longer grid-charges beside A's +# maintenance charge, and A's charge is no longer paused (release/1.0 02eac3b: +# e56eddab...). AT_BASE = { "sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9", "ch_ch": "2b5879ec95810c39a5f0f82c04b9f8e358ea03ddce4ba187cc2b7e0885dfcbfa", "ch_ch_maint": - "e56eddabdb785f81793e7afa525f8e6da237b0b2165860aa8e0f8a6499b275a9", + "df86a451a3b2d365ca5d35b772cd1eb8502d164e6b63e6844b5cfb82f15aeb46", } _got = sc.no_probe_digests() for _k in AT_BASE: @@ -260,8 +263,8 @@ for _k in AT_BASE: f"{_got[_k][:16]}") check("with no refusal every share, grid, trade sentence, role and plant " "power of a scripted cheap run equals bf9019e's (A Solar first, A " - "charging, A in maintenance charge; the last two as SAFETY-125 moved " - "them)", _got == AT_BASE) + "charging, A in maintenance charge; the last two as SAFETY-125 and " + "MAINT-24 moved them)", _got == AT_BASE) print() if fails: From b44a648cabaf387dd903da95f60f3fb289d6b40d Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 07:51:28 +0100 Subject: [PATCH 3/7] MAINT-24: test_maint24.py points at MUTATION.md Co-Authored-By: Claude --- goodwe_controller/test_maint24.py | 1 + 1 file changed, 1 insertion(+) diff --git a/goodwe_controller/test_maint24.py b/goodwe_controller/test_maint24.py index 0309fea..e9f061c 100644 --- a/goodwe_controller/test_maint24.py +++ b/goodwe_controller/test_maint24.py @@ -23,6 +23,7 @@ of 6000 W banked (aim 5950 W), a peak forecast that follows the grid. 5. Unchanged: N=1 with no forecast entity, the SAFETY-78 drain, battery modes off (digests printed on release/1.0 02eac3b). Mutations: DECISIONS/MAINT-24-charge-first.md. +Read MUTATION.md before you report a survivor. """ import logging From 6004bfad9ae8c62b0ccb642cdf11c9e53a34591a Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 08:15:01 +0100 Subject: [PATCH 4/7] MAINT-24: read the last row without a bare index (test_named_absence) Co-Authored-By: Claude --- goodwe_controller/test_maint24.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/goodwe_controller/test_maint24.py b/goodwe_controller/test_maint24.py index e9f061c..273cf98 100644 --- a/goodwe_controller/test_maint24.py +++ b/goodwe_controller/test_maint24.py @@ -124,15 +124,16 @@ CH = sc.MAINT_CHARGE print("1. A in maintenance CHARGE, B a charger in the cheap window") rows, st, c = scen(a=CH) ma, mb = mean(rows, "a"), mean(rows, "b") +last = rows[-1] if rows else {} print(f" A {ma:+.0f} W, B {mb:+.0f} W, worst quarter {worst_quarter(st):.0f}" - f" W, why {rows[-1]['why']}, roles {rows[-1]['roles']}") + f" W, why {last.get('why')}, roles {last.get('roles')}") check("A charges at (5950 - 3000) / 2 = 1475 W, not 0 or a few hundred W", -1500.0 <= ma <= -1440.0) check("B files no grid trade: no trade action in the run, B near 0 W", all("CHARGE" not in str(r["action"]) for r in rows) and mb > -50.0) check("roles: B does not charge, and the why is 'cycle'", all(r["roles"].get(B) != "charge" for r in rows) - and rows[-1]["why"] == "cycle") + and last.get("why") == "cycle") check("every quarter at or under the month aim", worst_quarter(st) <= AIM + 1.0) check("A never gives back while there is no real peak", @@ -169,12 +170,13 @@ check("no banked peak: the aim is cap - margin (3450 W) as before, so A " print("\n3. the mirror: B in maintenance CHARGE, A the charger") rowsm, stm, cm = scen(b=CH, soc=(30.0, 49.0)) mam, mbm = mean(rowsm, "a"), mean(rowsm, "b") +lastm = rowsm[-1] if rowsm else {} print(f" A {mam:+.0f} W, B {mbm:+.0f} W, worst quarter " - f"{worst_quarter(stm):.0f} W, why {rowsm[-1]['why']}") + f"{worst_quarter(stm):.0f} W, why {lastm.get('why')}") check("B charges at 1475 W, A files no grid trade, why 'cycle'", -1500.0 <= mbm <= -1440.0 and mam > -50.0 and all("CHARGE" not in str(r["action"]) for r in rowsm) - and rowsm[-1]["why"] == "cycle" + and lastm.get("why") == "cycle" and all(r["roles"].get(A) != "charge" for r in rowsm)) check("...every quarter at or under the month aim", worst_quarter(stm) <= AIM + 1.0) From b80cfa969fd30e35e1e4a1e81205702fb6cd1f17 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 08:22:13 +0100 Subject: [PATCH 5/7] MAINT-24: test_peak_margin pins the maintenance checks to the guard's aim Co-Authored-By: Claude --- goodwe_controller/test_peak_margin.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/goodwe_controller/test_peak_margin.py b/goodwe_controller/test_peak_margin.py index 97aae45..e0e00f2 100644 --- a/goodwe_controller/test_peak_margin.py +++ b/goodwe_controller/test_peak_margin.py @@ -245,14 +245,16 @@ print(f" decide(cap_w={kw(_d, 'cap_w')}, margin_w={kw(_d, 'margin_w')})") print(f" shaping_suspended(..., {arg(_s, 2)})") print(f" maintenance_charge_floor(..., {arg(_m, 2)})") print(f" PeakTracker(cap_w={kw(_t, 'cap_w')})") +# MAINT-24: the maintenance forecast checks get the guard's own aim, cap - +# margin with this month's peak in (`maint_aim`), not the plain cap - margin. check("test_call_sites: the guard gets the real cap AND the margin; the " - "forecast checks get cap - margin; the month-peak tracker gets the " - "real cap", + "forecast checks get the guard's aim, cap - margin and the month " + "peak (MAINT-24); the month-peak tracker gets the real cap", len(_dec) == 1 and "peak_cap_w" in kw(_d, "cap_w") and kw(_d, "margin_w") == "self.peak_margin_w" and len(_shape) == 1 and len(_maint) == 1 - and arg(_s, 2) == "self.peak_aim_w()" - and arg(_m, 2) == "self.peak_aim_w()" + and arg(_s, 2) == "maint_aim" and arg(_m, 2) == "maint_aim" + and "maint_aim = peakguard.quarter_target_w(" in _src and len(_tracker) == 1 and kw(_t, "cap_w") == 'float(opts.get("peak_cap_w", 3500))') check("test_aim_w: `peak_aim_w()` is cap - margin, and the forecast sizing " From de0790e62d25989cf975489eb9424d7f0f87dd25 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 09:57:56 +0100 Subject: [PATCH 6/7] MAINT-25: the Home plan shows no cycle during the 7-day back-off homeview.maintenance_runs() ignored backoff_until(); it drew a cycle every night of the wait. Now no run and a plain line (EN, NL). test_maint25.py. Co-Authored-By: Claude --- .../DECISIONS/MAINT-24-charge-first.md | 13 +- goodwe_controller/app/homeview.py | 14 ++ goodwe_controller/app/ui_text/en.json | 1 + goodwe_controller/app/ui_text/nl.json | 1 + goodwe_controller/test_maint25.py | 195 ++++++++++++++++++ 5 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 goodwe_controller/test_maint25.py diff --git a/goodwe_controller/DECISIONS/MAINT-24-charge-first.md b/goodwe_controller/DECISIONS/MAINT-24-charge-first.md index 8cdbe82..2a84b3f 100644 --- a/goodwe_controller/DECISIONS/MAINT-24-charge-first.md +++ b/goodwe_controller/DECISIONS/MAINT-24-charge-first.md @@ -1,6 +1,6 @@ # MAINT-24 - a started maintenance charge gets the cheap night -**Status: for review.** Tests: `test_maint24.py` (`test_opt20.py`'s `ch_ch_maint` digest moved with it). No option added; the MAINT-13 +**Status: for review.** Also fixes MAINT-25 (last section). Tests: `test_maint24.py` (`test_opt20.py`'s `ch_ch_maint` digest moved with it). No option added; the MAINT-13 timeout numbers are unchanged. Folds in SAFETY-80. SAFETY-111 stays on its card (section 3). **What happened (live 0.21.0, A, 2026-11-08..11).** A sat in its maintenance @@ -68,3 +68,14 @@ gives. The MAINT-13 timeout, the peak aim itself, the month peak, `P_MAINT_SHAPE` and `P_ARBITRAGE`. The Home card for B does not say "the other battery is cycling" for a CHARGE phase (`other_cycle` reads "emptying"); that is a wording card. + +## MAINT-25: the Home plan honours the 7-day back-off + +After a failed cycle `wants_start()` waits 7 days (MAINT-13, `backoff_until()`), +but `homeview.maintenance_runs()` only asked `due()`, so the plan drew a cycle at +the start hour every night of the wait. During the back-off it now draws no run +for that battery and shows one line instead: "Battery A: the monthly cycle waits +until 19 November." (Dutch: "... wacht tot 19 november."). A cycle completed +after the failure ends it, as in `backoff_until()`. The other battery is planned +as before. Tests: `test_maint25.py` (N=1 and N=2, 1 day, 7 days, completion +before and after the failure, Dutch). diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index ce644f1..dc19a7b 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -630,6 +630,11 @@ def maintenance_runs(c, day0, day1, now, shadow): run_cycle_why(shadow), run_cycle_amount(hours))) continue + until = m.backoff_until(now) + if until is not None: + # MAINT-25: a failed cycle waits (MAINT-13); no run is planned. + lines.append(line_cycle_backoff(inv.name, until)) + continue if not m.due(now) and (m.next_due(now) is None or m.next_due(now) >= day1): continue @@ -1529,6 +1534,13 @@ def line_cycle_waits(name) -> str: return _t("cycle_waits", name=name) +def line_cycle_backoff(name, until) -> str: + """MAINT-25: the failed cycle waits until `until` (MAINT-13).""" + local = brussels(until) + return _t("cycle_backoff", name=name, day=local.day, + month=month_name(local)) + + def line_no_prices(since, now) -> str: """PRICE-01: no price covers now. `since` is an instant or None.""" if since is None: @@ -1669,6 +1681,8 @@ def _samples() -> list: ("plan", run_house_why()), ("plan", run_house_why(True)), ("plan", run_house_amount()), ("plan", line_cycle_waits("Battery B")), + ("plan", line_cycle_backoff("Battery B", datetime( + 2026, 11, 19, 12, 0, tzinfo=timezone.utc))), ("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0, tzinfo=timezone.utc))), ("plan", line_no_prices(datetime(2026, 10, 14, 0, 0, diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index 12859a8..3fd2f34 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -430,6 +430,7 @@ "give": "Tomorrow: gives power at {at}, {eur} per kWh.", "give_would": "Tomorrow: would give power at {at}, {eur} per kWh." }, + "cycle_backoff": "{name}: the monthly cycle waits until {day} {month}.", "cycle_waits": "{name} waits its turn for the monthly cycle.", "no_prices_yet": "No prices yet, maintenance waits.", "no_prices_since": "No prices since {at}, maintenance waits.", diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index 75cd441..10315ff 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -365,6 +365,7 @@ "no_level": "Nu geen handel op prijs: een laadniveau is onbekend." }, "tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."}, + "cycle_backoff": "{name}: de maandelijkse cyclus wacht tot {day} {month}.", "cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.", "no_prices_yet": "Nog geen prijzen, onderhoud wacht.", "no_prices_since": "Geen prijzen sinds {at}, onderhoud wacht.", diff --git a/goodwe_controller/test_maint25.py b/goodwe_controller/test_maint25.py new file mode 100644 index 0000000..1d8c1fa --- /dev/null +++ b/goodwe_controller/test_maint25.py @@ -0,0 +1,195 @@ +"""Runnable check for MAINT-25. `python3 test_maint25.py` + +After a failed monthly cycle the add-on waits 7 days (MAINT-13, `backoff_until`). +The Home plan ignored that and drew a cycle at the start hour every night of +the wait. Through the plan builder (`status()` as in test_home.py), N=1 and N=2: +during the back-off no cycle run is drawn and a short line says when it waits +until, in English and Dutch; after 7 days the run is drawn again; a cycle +completed after the failure ends the back-off. +Read MUTATION.md before you report a survivor. +""" + +import asyncio +import os +import sys +from datetime import datetime, timedelta, timezone + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +from app import homeview # noqa: E402 +from app.main import Controller # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +class _Store: + def __init__(self): + self.data = {} + + def set(self, k, v): + self.data[k] = v + + def get(self, k, d=None): + return self.data.get(k, d) + + def set_time(self, k, v): + self.data[k] = v.isoformat() + + def get_time(self, k): + v = self.data.get(k) + return None if v is None else datetime.fromisoformat(v) + + +class _Pub: + enabled = False + + def publish(self, _v): + pass + + def close(self): + pass + + +class _Hass: + session = None + token = "" + + async def limits(self, _e): + return (-5000.0, 5000.0, 1.0) + + async def number(self, _e, invert=False): + return None + + async def state(self, _e): + return None + + async def set_number(self, _e, _v): + return True + + async def call(self, *_a): + return True + + async def states(self): + return [] + + +ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid", + "soc_entity": "sensor.soc", "batt_entity": "sensor.batt", + "meter_source": "off", "max_w": 5000, "shadow_mode": "off", + "maintenance_enabled": False} +TWO = dict(ONE, inverters=[ + {"name": "Battery A", "setpoint_entity": "number.a", "soc_entity": + "sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500, + "capacity_kwh": 9.6}, + {"name": "Battery B", "setpoint_entity": "number.b", "soc_entity": + "sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500, + "capacity_kwh": 5.4}]) + +# Sunday 27 September 2026, 11:40 in Brussels (09:40 UTC): the mockup's day. +NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc) +DAY0 = datetime(2026, 9, 26, 22, 0, tzinfo=timezone.utc) +MOCK = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14, + .16, .19, .23, .28, .33, .36, .34, .29, .26, .24] + + +def make(opts, *, soc=(62.0, 35.0), batt=(-2500.0, -300.0), grid=10.0, + auto=True, wrote=0.0): + """`wrote` is each inverter's last accepted power request (None: none yet).""" + c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event()) + c.auto = auto + for i, inv in enumerate(c.inverters): + inv.soc, inv.batt = soc[i], batt[i] + inv.last_write = wrote + inv.dev_min, inv.dev_max = -5000.0, 5000.0 + c.grid = grid + return c + + + +NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc) +ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid", + "soc_entity": "sensor.soc", "batt_entity": "sensor.batt", + "meter_source": "off", "max_w": 5000, "shadow_mode": "off", + "maintenance_enabled": True, "maintenance_start_hour": 12} +TWO = dict(ONE, inverters=[ + {"name": "Battery A", "setpoint_entity": "number.a", "soc_entity": + "sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500, + "capacity_kwh": 9.6}, + {"name": "Battery B", "setpoint_entity": "number.b", "soc_entity": + "sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500, + "capacity_kwh": 5.4}]) + + +def make(opts, failed=None, done=None): + c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event()) + c.auto = True + for inv in c.inverters: + inv.soc, inv.batt, inv.last_write = 50.0, 0.0, 0.0 + inv.dev_min, inv.dev_max = -5000.0, 5000.0 + c.grid = 10.0 + m = c.maints[0] + if failed is not None: + m.store.set_time("last_failed", failed) + if done is not None: + m.store.set_time("last_completed", done) + return c + + +def plan(c, lang=None): + s = c.status_payload() + s.update(homeview.build(c, level=s["level"], checks=s["checks"], now=NOW, + **({} if lang is None else {"lang": lang}))) + p = s["plan"] + return [r for r in p["runs"] if r["kind"] == "cycle"], p["lines"] + + +D = timedelta(days=1) +print("1. a cycle failed 1 day ago (N=2: Battery A failed, B never ran)") +runs, lines = plan(make(TWO, failed=NOW - D)) +print(" ", [r["from"] for r in runs], lines) +check("the run tonight is B's, not A's (A waits out the back-off)", + len(runs) == 1 and not any("Battery A waits its turn" in x for x in lines)) +check("a plain line says Battery A waits until 3 October", + any("Battery A: the monthly cycle waits until 3 October." == x + for x in lines)) + +print("\n2. N=1") +runs1, lines1 = plan(make(ONE, failed=NOW - D)) +check("no cycle run is drawn, one line says when it waits until", + runs1 == [] and any("waits until 3 October" in x for x in lines1)) +runs1b, lines1b = plan(make(ONE)) +check("without a failure the run is drawn (non-vacuous)", + len(runs1b) == 1 and not any("waits until" in x for x in lines1b)) + +print("\n3. after the 7 days, and after a cycle that completed") +runs2, lines2 = plan(make(ONE, failed=NOW - timedelta(days=7, hours=1))) +check("7 days and an hour after the failure the run is drawn again, no line", + len(runs2) == 1 and not any("waits until" in x for x in lines2)) +runs3, lines3 = plan(make(ONE, failed=NOW - D, done=NOW - D / 2)) +check("a cycle completed after the failure ends the back-off (the run is " + "not drawn only if it is no longer due)", + not any("waits until" in x for x in lines3)) +runs3b, lines3b = plan(make(ONE, failed=NOW - 2 * D, done=NOW - 3 * D)) +check("a completion BEFORE the failure does not end it", + runs3b == [] and any("waits until" in x for x in lines3b)) + +print("\n4. Dutch") +_, lnl = plan(make(ONE, failed=NOW - D), lang="nl") +print(" ", lnl) +check("the Dutch line says it plainly", + any("de maandelijkse cyclus wacht tot 3 oktober" in x for x in lnl)) + +print() +if fails: + print(f"{len(fails)} FAILED:") + for f in fails: + print(f" - {f}") + sys.exit(1) +print("all checks passed") From 33cfc733d3892ca4a08ced524c611ae6e654c30f Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 11:08:30 +0100 Subject: [PATCH 7/7] MAINT-25: clean test_maint25.py, split its DECISIONS note Co-Authored-By: Claude --- .../DECISIONS/MAINT-24-charge-first.md | 13 ++------ .../DECISIONS/MAINT-25-plan-backoff.md | 10 ++++++ goodwe_controller/test_maint25.py | 33 ------------------- 3 files changed, 12 insertions(+), 44 deletions(-) create mode 100644 goodwe_controller/DECISIONS/MAINT-25-plan-backoff.md diff --git a/goodwe_controller/DECISIONS/MAINT-24-charge-first.md b/goodwe_controller/DECISIONS/MAINT-24-charge-first.md index 2a84b3f..240da11 100644 --- a/goodwe_controller/DECISIONS/MAINT-24-charge-first.md +++ b/goodwe_controller/DECISIONS/MAINT-24-charge-first.md @@ -1,6 +1,6 @@ # MAINT-24 - a started maintenance charge gets the cheap night -**Status: for review.** Also fixes MAINT-25 (last section). Tests: `test_maint24.py` (`test_opt20.py`'s `ch_ch_maint` digest moved with it). No option added; the MAINT-13 +**Status: for review.** Tests: `test_maint24.py` (`test_opt20.py`'s `ch_ch_maint` digest moved with it). No option added; the MAINT-13 timeout numbers are unchanged. Folds in SAFETY-80. SAFETY-111 stays on its card (section 3). **What happened (live 0.21.0, A, 2026-11-08..11).** A sat in its maintenance @@ -69,13 +69,4 @@ The MAINT-13 timeout, the peak aim itself, the month peak, `P_MAINT_SHAPE` and `P_ARBITRAGE`. The Home card for B does not say "the other battery is cycling" for a CHARGE phase (`other_cycle` reads "emptying"); that is a wording card. -## MAINT-25: the Home plan honours the 7-day back-off - -After a failed cycle `wants_start()` waits 7 days (MAINT-13, `backoff_until()`), -but `homeview.maintenance_runs()` only asked `due()`, so the plan drew a cycle at -the start hour every night of the wait. During the back-off it now draws no run -for that battery and shows one line instead: "Battery A: the monthly cycle waits -until 19 November." (Dutch: "... wacht tot 19 november."). A cycle completed -after the failure ends it, as in `backoff_until()`. The other battery is planned -as before. Tests: `test_maint25.py` (N=1 and N=2, 1 day, 7 days, completion -before and after the failure, Dutch). +MAINT-25 (the Home plan and the back-off) has its own note: `MAINT-25-plan-backoff.md`. diff --git a/goodwe_controller/DECISIONS/MAINT-25-plan-backoff.md b/goodwe_controller/DECISIONS/MAINT-25-plan-backoff.md new file mode 100644 index 0000000..c2e7c6a --- /dev/null +++ b/goodwe_controller/DECISIONS/MAINT-25-plan-backoff.md @@ -0,0 +1,10 @@ +# MAINT-25 - the Home plan honours the 7-day back-off + +**Status: for review.** Added to the MAINT-24 PR by the owner (2026-11-12). +Tests: `test_maint25.py`. After a failed cycle `wants_start()` waits 7 days +(MAINT-13, `backoff_until()`), but `homeview.maintenance_runs()` only asked +`due()`, so the plan drew a cycle at the start hour every night of the wait. +During the back-off it now draws no run for that battery and shows one line +instead: "Battery A: the monthly cycle waits until 19 November." (Dutch: "... +wacht tot 19 november."). A cycle completed after the failure ends it, as in +`backoff_until()`. The other battery is planned as before. diff --git a/goodwe_controller/test_maint25.py b/goodwe_controller/test_maint25.py index 1d8c1fa..715341f 100644 --- a/goodwe_controller/test_maint25.py +++ b/goodwe_controller/test_maint25.py @@ -80,39 +80,6 @@ class _Hass: return [] -ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid", - "soc_entity": "sensor.soc", "batt_entity": "sensor.batt", - "meter_source": "off", "max_w": 5000, "shadow_mode": "off", - "maintenance_enabled": False} -TWO = dict(ONE, inverters=[ - {"name": "Battery A", "setpoint_entity": "number.a", "soc_entity": - "sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500, - "capacity_kwh": 9.6}, - {"name": "Battery B", "setpoint_entity": "number.b", "soc_entity": - "sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500, - "capacity_kwh": 5.4}]) - -# Sunday 27 September 2026, 11:40 in Brussels (09:40 UTC): the mockup's day. -NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc) -DAY0 = datetime(2026, 9, 26, 22, 0, tzinfo=timezone.utc) -MOCK = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14, - .16, .19, .23, .28, .33, .36, .34, .29, .26, .24] - - -def make(opts, *, soc=(62.0, 35.0), batt=(-2500.0, -300.0), grid=10.0, - auto=True, wrote=0.0): - """`wrote` is each inverter's last accepted power request (None: none yet).""" - c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event()) - c.auto = auto - for i, inv in enumerate(c.inverters): - inv.soc, inv.batt = soc[i], batt[i] - inv.last_write = wrote - inv.dev_min, inv.dev_max = -5000.0, 5000.0 - c.grid = grid - return c - - - NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc) ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid", "soc_entity": "sensor.soc", "batt_entity": "sensor.batt",