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")