From 28eff568b56a0b381d9072e2a849493e14bb90a1 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 8 Oct 2026 09:36:06 +0200 Subject: [PATCH] MAINT-13: a full pack that stops at 98-99 % is not a failed charge A charge is complete at SoC >= target, or at >= target - 3 once the SoC has made no new high for 60 min of allowed charging, or when the charging time runs out at >= target - 3. The 36 h timeout counts only the minutes the price gate let the pack charge (plus a 7 day wall-clock backstop). A cycle that fails (stuck drain, charge below target - 3) backs off 7 days, tells the operator once (EN + NL), and no cycle starts on that pack in that time unless the operator forces one. State survives a restart; old state files load. No option added. DECISIONS/MAINT-13-full-pack-is-complete.md. Co-Authored-By: Claude Opus 5.5 --- .../MAINT-13-full-pack-is-complete.md | 98 ++++ goodwe_controller/DOCS.md | 23 + goodwe_controller/app/main.py | 27 +- goodwe_controller/app/maintenance.py | 126 ++++- goodwe_controller/app/messages.py | 30 +- goodwe_controller/app/store.py | 3 +- goodwe_controller/test_maint13.py | 468 ++++++++++++++++++ goodwe_controller/test_maintenance.py | 13 +- goodwe_controller/test_safety93.py | 4 + 9 files changed, 776 insertions(+), 16 deletions(-) create mode 100644 goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md create mode 100644 goodwe_controller/test_maint13.py diff --git a/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md b/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md new file mode 100644 index 0000000..e28140a --- /dev/null +++ b/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md @@ -0,0 +1,98 @@ +# MAINT-13 - a full pack that stops at 98-99 % is not a failed charge + +**Status: for review.** Tests: `test_maint13.py`. No option added; every number +is a constant in `app/maintenance.py`. + +**What happened (live, B).** The drain ended 2026-10-04 18:24 UTC and the charge +began. It ran in pieces behind the price gate, reached 99 % at 2026-10-06 04:00, +fell back to 98 % at 04:15 while the loop still asked about -1370 W, and never +reached `maintenance_soc_target` 100. At 06:24 (2160 min = 36 h of wall clock +after the charge began) it aborted "charge stuck at 98 %". `last_completed` was +not set, so B was still due and would have drained again from 98 % to 11 % at the +next start hour, with no notice. Three faults: a pack may never report 100 % +while the BMS balances; a failed cycle came back the next day; and the 36 h +counted hours the price gate kept the pack waiting. + +## 1. The charge is complete when + +1. SoC >= target (as before), or +2. SoC >= target - 3 (`CHARGE_NEAR_FULL_PCT`) and the SoC has not beaten its + best for 60 min (`CHARGE_STALL_MIN`) of allowed charging, or +3. the charging time (`CHARGE_TIMEOUT_MIN`, 2160 min) runs out at SoC >= target - 3. + +Then the usual HOLD follows and `last_completed` is set after it. Each of 2 and +3 logs one plain line ("the battery takes no more charge at 98 %, so the charge +counts as done", "charge time is up at 98 %, close enough to full ..."). + +**Why "SoC has not beaten its best", and not asked/delivered watts.** SoC is the +one signal the live record gives us (99 at 04:00, 98 at 04:15); the asked power +was -1370 W but no delivered figure was kept, so a watt threshold (X, Y) would be +invented. SoC also covers the case where power flows but nothing is stored. +**Why 3 points.** The card's band; at target 100 it is 97, and the live pack sat +at 98-99. **Why 60 min.** A judgement, not a measurement: we have no per-minute record of +the pack taking charge near full. The one live fact is 2 h 24 min (04:00 to +06:24) without a new high, so 60 min is well inside it and long enough that a +pack still creeping up (one new high within the hour) keeps resetting the wait. +On the live run the rule fires after 60 gate-open minutes past the 99 %: 05:00 +UTC at the earliest, before 06:24 if the gate was open. If the pack turns out to +step up more slowly than that, raise the constant. Falling back by one point +does not reset the wait; only a new best does. + +## 2. A failed cycle backs off 7 days + +A drain stuck 12 h or a charge that times out below target - 3 is a failure +(`FAIL_BACKOFF_DAYS` = 7). The machine goes IDLE as before, `last_failed` is +stored, and `wants_start()` is false until 7 days later. The operator's forced +start ignores it; a cycle that completes after the failure ends it. An operator +abort, an interlock stop and a switched-off pack are not failures. + +**Why 7 days, not the 28 day interval.** A judgement: a failure may have a +short cause (several dull days with no cheap hours, a pack switched off), and +waiting the full interval leaves the coulomb counter uncalibrated for a month +more. A week is long enough that a bad pack costs one drain a week, with one +notice a week, not one a day. **Cost.** A truly faulty pack still drains once a week until someone looks; +the notice says so each time. + +**Notice, once.** The tick that fails carries `failed`; the Controller sends one +persistent notification "Battery cycle did not finish" (EN, with the Dutch +below it; per pack at N=2). It is not repeated, and Home Assistant restarts +wipe it, so the status page shows a "Maintenance paused" row with the date for +as long as the wait lasts. The operator may also see the log lines. + +## 3. The timeout counts only allowed time + +`Controller` passes `gate_open = self.cheap` (the same flag that lets the +cheap-window charge floor apply). Only those ticks add to `open_min`; one tick +adds at most 5 min, so a gap (add-on down) adds nothing. The stall wait pauses +while the gate is closed. The accounting is written to the state file at most +every 5 min (`charge_track`); a restart loses at most that. A state file from +before this change, or with no record for the current charge, starts from the +wall-clock minutes so far, which is the old behaviour. + +**Backstop.** A site whose gate never opens (no tariff, no sun) would never time +out, so a charge also ends after 7 days of wall clock (`CHARGE_WALL_TIMEOUT_MIN`): +complete at >= target - 3, a failure below. + +**MAINT-12.** This is the smallest change that makes the timeout honest: one +boolean in, one counter. MAINT-12's block plan can replace the boolean (the +minutes it plans are the allowed ones) without touching the rest. Two things it +must keep: the back-off is in `wants_start()`, so any new entry point (such as +`price_forecast.begin_phase`, which is not wired today) must go through it or +check `backoff_until()`; and `gate_open` must stay "the pack is allowed to +charge now". The peak guard pausing the charge shaping is not seen as a closed +gate: that time still counts. + +## 4. What does not change, and what this does not cover + +- The drain, the HOLD, the held-battery rules and `arbitrate()` are unchanged. A + backed-off pack does not want a start, so at N=2 the other pack may take the + slot. One pack at a time at N=1 and N=2. +- `last_failed` is a maintenance key and moves with the others when a second + inverter is added (`MAINT_UNIT_KEYS`). The charge record is not moved; it + restarts from the wall-clock minutes. +- It does not find out why a pack stopped. A pack that stops at 96 % and never + gets near is still a failure. +- The Dutch text is only in the notification. The status row and the log lines + are English, like the rest of the status page. +- It does not change the target: a site that wants a different stop point sets + `maintenance_soc_target`. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 8771c2a..6523d3f 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -1667,6 +1667,20 @@ hour saved. If you set an earlier hour to get an evening start in UTC (for example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved value. *For installers:* DECISIONS/MAINT-10-local-start-hour.md. +**When the charge counts as done.** A battery may stop at 98 or 99 % while it +balances, so it need not reach `maintenance_soc_target`. The charge counts as +done when the battery is within 3 points of the target and has taken no more +charge for 60 minutes of charging time, or when the charging time (36 hours) runs +out within 3 points of the target. Only the minutes the cheap-hours gate let the +battery charge count. Then the usual hold follows. + +**When a cycle fails.** If the battery does not empty in 12 hours, or does not +get within 3 points of the target in 36 hours of charging time, the cycle +stops, you get the notification *Battery cycle did not finish*, and it is not +tried again for 7 days. The status page shows *Maintenance paused* with the +date. Starting a cycle by hand ignores the wait. *For installers:* +DECISIONS/MAINT-13-full-pack-is-complete.md. + ### Tariff (all optional) | option | meaning | @@ -3250,6 +3264,14 @@ reading is stuck. Control keeps running and keeps following the reading, so a stuck reading can raise this month's peak. Check the meter. If it is stuck, stop control on the add-on page. +### Battery cycle did not finish + +The monthly battery cycle stopped because the battery did not empty, or did not +fill up, in time. The battery is back under normal control. The add-on waits 7 +days before it tries again, so it does not empty the battery every night. To +try sooner, start a cycle on the add-on page. If it keeps happening, check that +the battery follows its commands and that the cheap hours leave time to charge. + ### For installers: notifications This part is for installers. The texts live in `app/messages.py` and follow @@ -3269,6 +3291,7 @@ default `info` hides it. | Inverters working against each other | alarm | `goodwe_circulating_power` | SAFETY-31 | | *name* is not delivering | alarm | `goodwe_not_delivering_` | SAFETY-42 | | Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 | +| Battery cycle did not finish | warning | `goodwe_maint_failed_` | MAINT-13 | **The Details link.** Home Assistant 2026.2 and newer show add-on pages at `/config/app//documentation`; 2026.5 removed the old diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index c255a73..3b8a60a 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -77,7 +77,7 @@ from .inverter import (LINK_STALE_S, UNIT_A, build_roster, roster_entries, roster_names) from .maintenance import ( CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance, - MaintResult, arbitrate as maint_arbitrate, site_zone, + FAIL_BACKOFF_DAYS, MaintResult, arbitrate as maint_arbitrate, site_zone, ) from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON, MqttPublisher, announced_units, mode_configs, @@ -3923,10 +3923,24 @@ class Controller: if blocked is None: maint_results.append(MaintResult(IDLE, False, None, False, [])) continue - res = machine.tick(now, inv.soc, blocked_by=blocked) + # MAINT-13: the charge timeout counts only the minutes the price + # gate (`self.cheap`) let the pack charge. + res = machine.tick(now, inv.soc, blocked_by=blocked, + gate_open=bool(self.cheap)) for msg in res.events: - self.log_event(msg, hint=messages.hint("maintenance", - phase=machine.phase)) + self.log_event(msg, hint=messages.hint( + "maint_failed" if res.failed else "maintenance", + phase=machine.phase)) + if res.failed: + # Said once: only the tick that failed carries `res.failed`. + multi = len(self.inverters) > 1 + slug = (unit_slugs(self.units)[self.inverters.index(inv)] + if multi else "battery") + await self.hass.call( + "persistent_notification", "create", + messages.maint_failed( + res.failed, FAIL_BACKOFF_DAYS, + unit=inv.name if multi else "", slug=slug).data()) maint_results.append(res) # ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The # deferral is edge-logged (it lasts as long as the other pack's cycle), @@ -7100,6 +7114,7 @@ class Controller: # date, unchanged. _now = datetime.now(timezone.utc) _dues = [m.next_due(_now) for m in self.maints] + _waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u] next_due = None if any(d is None for d in _dues) else min(_dues) rows = [ ("Grid", w(self.grid)), @@ -7147,6 +7162,10 @@ class Controller: *((("Maintenance waiting", "; ".join(f"{n}: {why}" for n, why in self._maint_deferred)),) if self._maint_deferred else ()), + # MAINT-13: only while a failed cycle waits, so a site with nothing + # failed carries exactly the rows it carried before. + *((("Maintenance paused", messages.row_maint_waits( + min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()), ("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")), ("Cheap window", "yes" if self.cheap else "no"), ("Tariff", self.tariff_words), diff --git a/goodwe_controller/app/maintenance.py b/goodwe_controller/app/maintenance.py index 8710775..6883331 100644 --- a/goodwe_controller/app/maintenance.py +++ b/goodwe_controller/app/maintenance.py @@ -65,10 +65,26 @@ POWER_MOVING = (DRAIN, CHARGE, HOLD) # 12 h means the battery is not following commands - a fault, not a slow day. The charge phase deliberately waits for sun and for cheap hours, so # it is expected to span a night; 36 h still bounds it. DRAIN_TIMEOUT_MIN = 720 +# MAINT-13: minutes the pack was ALLOWED to charge (the price gate open), not +# wall-clock minutes since the charge began. See CHARGE_WALL_TIMEOUT_MIN. CHARGE_TIMEOUT_MIN = 2160 # After an abort, do not immediately re-trigger in the same start hour. RESTART_COOLDOWN_MIN = 90 +# MAINT-13 (DECISIONS/MAINT-13-full-pack-is-complete.md). Constants, not options. +# A pack that reaches target - 3 and then takes no more charge for this many +# minutes of allowed charging counts as full. +CHARGE_NEAR_FULL_PCT = 3 +CHARGE_STALL_MIN = 60 +# Wall-clock backstop for a charge whose gate never opens (no cheap hours, no sun). +CHARGE_WALL_TIMEOUT_MIN = 10080 +# A failed cycle (stuck drain or charge) is not retried before this many days. +FAIL_BACKOFF_DAYS = 7 +# One tick counts at most this many minutes: a gap means the add-on was down. +CHARGE_TICK_MAX_MIN = 5 +# The charge accounting is written to the state file at most this often. +CHARGE_SAVE_MIN = 5 + # ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR EVERY `maintenance_*` # OPTION, and the only place in the add-on where one of these numbers is typed. # @@ -257,6 +273,9 @@ class MaintResult: # showing an idle machine and a due date in the past. Empty on every path # that is not deferring, so a roster of one never sees it. deferred: str = "" + # MAINT-13: "drain" or "charge" on the one tick where a cycle FAILED, so + # the Controller can tell the operator once. Empty on every other tick. + failed: str = "" class UnitHistory: @@ -344,6 +363,10 @@ class Maintenance: # per cycle would flush the very context that explains it. Same pattern # as `Controller._arb_action` and `_maint_shape_suspended`. self._deferred = "" + # MAINT-13: the charge accounting (see _charge_account), in memory and + # written to the state file every CHARGE_SAVE_MIN minutes. + self._trk = None + self._trk_saved = None # -- state --------------------------------------------------------------- @property @@ -351,6 +374,9 @@ class Maintenance: return self.store.get("phase", IDLE) def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None: + self._trk = None + if self.store.get("charge_track") is not None: + self.store.set("charge_track", None) self.store.set("phase", phase) self.store.set_time("phase_started", now) _LOG.info("%s", msg) @@ -391,6 +417,21 @@ class Maintenance: return (last_try is None or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN)) + def backoff_until(self, now: datetime): + """MAINT-13: when a failed cycle may be tried again, or None. + + None when nothing failed, when the back-off is over, or when a cycle + completed after the failure (a forced start that worked ends it). + """ + failed = self.store.get_time("last_failed") + if failed is None: + return None + done = self.store.get_time("last_completed") + if done is not None and done >= failed: + return None + until = failed + timedelta(days=FAIL_BACKOFF_DAYS) + return until if now < until else None + def wants_start(self, now: datetime) -> bool: """Would this pack begin a cycle on this tick if the site let it? @@ -402,7 +443,8 @@ class Maintenance: return (self.phase == IDLE and self.cfg.enabled and in_start_slot(now, self.cfg.start_hour, self.tz) and self.due(now) - and self._cooled(now)) + and self._cooled(now) + and self.backoff_until(now) is None) # -- operations ---------------------------------------------------------- def force_start(self, now: datetime, blocked_by: str = "") -> list: @@ -430,6 +472,55 @@ class Maintenance: self._enter(DRAIN, now, events, "maintenance: forced start -> drain") return events + def _charge_account(self, now: datetime, soc: float, gate_open: bool) -> dict: + """MAINT-13: what this charge has used so far, as minutes of ALLOWED time. + + `open_min` counts only ticks with the price gate open; `flat_min` counts + gate-open minutes since the SoC last beat its best; a closed gate pauses + both. One tick adds at most CHARGE_TICK_MAX_MIN, so a gap (add-on down) + counts for nothing. A state file from before MAINT-13, or from another + charge, starts from the wall-clock minutes so far: the old behaviour. + """ + since = self.store.get("phase_started") + trk = self._trk + if trk is None: + raw = self.store.get("charge_track") + if isinstance(raw, dict) and raw.get("since") == since: + trk = dict(raw) + else: + trk = {"since": since, "open_min": self._elapsed_min(now), + "flat_min": 0.0, "best": float(soc), "at": None} + self._trk = trk + at = trk.get("at") + try: + last = datetime.fromisoformat(at) if at else None + except (TypeError, ValueError): + last = None + dt = 0.0 if last is None else min( + CHARGE_TICK_MAX_MIN, max(0.0, (now - last).total_seconds() / 60.0)) + trk["at"] = now.isoformat() + if soc > trk["best"]: + trk["best"], trk["flat_min"] = float(soc), 0.0 + elif gate_open: + trk["flat_min"] += dt + if gate_open: + trk["open_min"] += dt + saved = self._trk_saved + if saved is None or (now - saved).total_seconds() >= CHARGE_SAVE_MIN * 60: + self.store.set("charge_track", dict(trk)) + self._trk_saved = now + return trk + + def _fail(self, now: datetime, kind: str, why: str) -> MaintResult: + """MAINT-13: stand down, remember the failure, and tell the Controller.""" + events = self.abort(now, why) + self.store.set_time("last_failed", now) + msg = (f"maintenance: the cycle did not finish. It waits " + f"{FAIL_BACKOFF_DAYS} days before it tries again.") + _LOG.warning("%s", msg) + events.append(msg) + return MaintResult(IDLE, True, 0.0, False, events, failed=kind) + def abort(self, now: datetime, why: str) -> list: events = [] self._enter(IDLE, now, events, f"maintenance: ABORT ({why})") @@ -457,13 +548,16 @@ class Maintenance: # -- the machine --------------------------------------------------------- def tick(self, now: datetime, soc: float | None, - blocked_by: str = "") -> MaintResult: + blocked_by: str = "", gate_open: bool = True) -> MaintResult: """One cycle of this pack's state machine. `blocked_by` is `arbitrate()`'s answer for THIS pack: empty when the site's one maintenance slot is available to it, otherwise the reason it is not. It gates two things and nothing else - a start, and a phase that is already moving power on a pack that does not hold the slot. + + `gate_open` (MAINT-13) is whether the pack may charge now (the price + gate); only those minutes count towards the charge timeout. """ events: list = [] phase = self.phase @@ -531,8 +625,8 @@ class Maintenance: f"after {elapsed:.0f} min -> charge") return MaintResult(CHARGE, False, None, True, events) if elapsed > DRAIN_TIMEOUT_MIN: - events += self.abort(now, f"drain stuck at {soc:.0f} % after {elapsed:.0f} min") - return MaintResult(IDLE, True, 0.0, False, events) + return self._fail(now, "drain", f"drain stuck at {soc:.0f} % " + f"after {elapsed:.0f} min") return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events) if phase == CHARGE: @@ -541,9 +635,27 @@ class Maintenance: f"maintenance: charge complete at {soc:.0f} % after " f"{elapsed:.0f} min -> holding {self.cfg.hold_min} min to balance") return MaintResult(HOLD, True, 0.0, False, events) - if elapsed > CHARGE_TIMEOUT_MIN: - events += self.abort(now, f"charge stuck at {soc:.0f} % after {elapsed:.0f} min") - return MaintResult(IDLE, True, 0.0, False, events) + trk = self._charge_account(now, soc, gate_open) + near = soc >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT + # MAINT-13 rule 1: close to full and no longer taking charge. + if near and trk["flat_min"] >= CHARGE_STALL_MIN: + self._enter(HOLD, now, events, + f"maintenance: the battery takes no more charge at " + f"{soc:.0f} %, so the charge counts as done -> holding " + f"{self.cfg.hold_min} min to balance") + return MaintResult(HOLD, True, 0.0, False, events) + if (trk["open_min"] > CHARGE_TIMEOUT_MIN + or elapsed > CHARGE_WALL_TIMEOUT_MIN): + # MAINT-13 rule 2: out of time close to full is complete. + if near: + self._enter(HOLD, now, events, + f"maintenance: charge time is up at {soc:.0f} %, " + f"close enough to full, so the charge counts as " + f"done -> holding {self.cfg.hold_min} min to balance") + return MaintResult(HOLD, True, 0.0, False, events) + return self._fail(now, "charge", + f"charge stuck at {soc:.0f} % after " + f"{trk['open_min']:.0f} min of charging time") return MaintResult(CHARGE, False, None, True, events) if phase == HOLD: diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 6ad9814..45e4a74 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -102,6 +102,7 @@ class Notice: title: str body: str section: str = "" # the DOCS.md heading, when it is not the title itself + body_nl: str = "" # MAINT-13: the same words in Dutch, shown under the English @property def docs(self) -> str: @@ -110,7 +111,9 @@ class Notice: def data(self) -> dict: """The `persistent_notification.create` payload.""" return {"notification_id": self.id, "title": self.title, - "message": f"{self.body}\n\n{details(self.docs)}"} + "message": f"{self.body}\n\n" + + (f"{self.body_nl}\n\n" if self.body_nl else "") + + details(self.docs)} # --- the catalogue ---------------------------------------------------------- @@ -124,6 +127,8 @@ T_CIRCULATING = "Inverters working against each other" T_NOT_DELIVERING = "{unit} is not delivering" S_NOT_DELIVERING = "An inverter is not delivering" T_METER_FROZEN = "Meter reading may be stuck" +T_MAINT_FAILED = "Battery cycle did not finish" +T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond" ID_LOOP_BOUND = "goodwe_integrator_bound" ID_OUTPUT_CLAMP = "goodwe_output_clamp" @@ -132,6 +137,7 @@ ID_PEAK_RESERVE = "goodwe_peak_soc_floor" ID_CIRCULATING = "goodwe_circulating_power" ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}" ID_METER_FROZEN = "goodwe_meter_frozen" +ID_MAINT_FAILED = "goodwe_maint_failed_{slug}" # Which limit cut a command (clamp.ClampResult.bound, plus the loop's own). # The setting is named by its label, "Inverter rating (W)". @@ -295,6 +301,24 @@ def meter_frozen(unchanged_s) -> Notice: f"stop control on the add-on page.") +def maint_failed(kind: str, days: int, unit: str = "", slug: str = "") -> Notice: + """MAINT-13: a monthly cycle stopped before it was done. Said once.""" + full = kind == "charge" + who = unit or "The battery" + who_nl = unit or "De batterij" + en = f"{who} did not {'fill up' if full else 'empty'}, so its cycle " \ + f"stopped and tries again in {days} days." + nl = f"{who_nl} raakte niet {'vol' if full else 'leeg'}, dus de cyclus is " \ + f"gestopt en probeert het over {days} dagen opnieuw." + return Notice(ID_MAINT_FAILED.format(slug=slug or "battery"), WARNING, + T_MAINT_FAILED, en, body_nl=f"{T_MAINT_FAILED_NL}. {nl}") + + +def row_maint_waits(until_text: str) -> str: + """MAINT-13: the status row while a failed cycle waits.""" + return f"Waits until {until_text} after a cycle that did not finish." + + # --- the status page (UX-08 phase 2b) ---------------------------------------- # The rows of the Commissioning list and the banner at the top of the add-on # page. `main.py` decides WHEN a row shows and its colour (ok / warn / bad); @@ -947,6 +971,7 @@ HINTS = { "not_delivering_cleared": "{unit}: not-delivering alarm cleared", "maintenance": "Battery maintenance: {phase}", "maint_paused": "Maintenance charging paused to stay under the peak limit", + "maint_failed": "The battery cycle did not finish, so it waits before trying again", "pack_on": "{unit}: battery switched on", "pack_off": "{unit}: battery switched off, it is asked for 0 W", "pack_mode": "{unit}: battery mode changed", @@ -1118,6 +1143,7 @@ def status_samples() -> list: ("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1, True)), ("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)), + ("row_maint_waits", AMBER, row_maint_waits("2026-10-13")), ] @@ -1148,6 +1174,8 @@ def samples() -> list: not_delivering("inverter-b", "inverter_b"), meter_frozen(600), meter_frozen(90), + maint_failed("charge", 7), + maint_failed("drain", 7, "inverter-b", "inverter_b"), ] diff --git a/goodwe_controller/app/store.py b/goodwe_controller/app/store.py index c716fd6..406975c 100644 --- a/goodwe_controller/app/store.py +++ b/goodwe_controller/app/store.py @@ -94,6 +94,7 @@ DEFAULTS = { "phase_started": None, "last_completed": None, "last_start_attempt": None, + "last_failed": None, "sign_tripped": False, "sign_reason": "", "circ_tripped": False, @@ -171,7 +172,7 @@ MAINT_SCHEMA_KEY = "maint_schema_version" # silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the # SAFETY-02 pair. MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed", - "last_start_attempt") + "last_start_attempt", "last_failed") def unit_key(unit: str, key: str) -> str: diff --git a/goodwe_controller/test_maint13.py b/goodwe_controller/test_maint13.py new file mode 100644 index 0000000..0377498 --- /dev/null +++ b/goodwe_controller/test_maint13.py @@ -0,0 +1,468 @@ +"""MAINT-13: a full pack that stops at 98-99 % is not a failed charge. +`python3 test_maint13.py` + +Graded here: the stall rule, the near-full timeout rule, the 7 day back-off +after a failed cycle (and what ends it), the timeout counting only the minutes +the price gate was open, restart and old-state-file behaviour, the notification +(once), and the Controller wiring at one and two packs. +See DECISIONS/MAINT-13-full-pack-is-complete.md. + +Mutating what this file grades? Read MUTATION.md first (stale bytecode). +""" + +import asyncio +import json +import os +import re +import sys +import tempfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from app import messages +from app.maintenance import ( + CHARGE, CHARGE_NEAR_FULL_PCT, CHARGE_STALL_MIN, CHARGE_TIMEOUT_MIN, + CHARGE_WALL_TIMEOUT_MIN, DRAIN, FAIL_BACKOFF_DAYS, HOLD, IDLE, + MaintConfig, Maintenance, arbitrate, +) +from app.main import Controller +from app.store import (DEFAULTS, MAINT_UNIT_KEYS, Store, migrate_maint_keys, + unit_key) + +HERE = Path(__file__).resolve().parent +UTC = timezone.utc +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +# The live site: target 100, shipped hold. Start hour 19 UTC for the schedule. +CFG = MaintConfig(enabled=True, interval_days=28, start_hour=19, + discharge_w=2000, charge_w=2000, soc_floor=11, + soc_target=100, hold_min=120) +T0 = datetime(2026, 10, 4, 18, 24, tzinfo=UTC) # B's drain ended (live) +MIN = timedelta(minutes=1) + + +class Mem: + """Store's surface, no disk; times kept as the real Store keeps them.""" + + def __init__(self, data=None): + self.data = dict(DEFAULTS, **(data or {})) + + def set(self, k, v): + self.data[k] = v + + def set_time(self, k, when): + self.data[k] = when.astimezone(UTC).isoformat() if when else None + + def get_time(self, k): + raw = self.data.get(k) + return datetime.fromisoformat(raw) if raw else None + + def save(self): + pass + + +def in_charge(store=None, cfg=CFG, at=T0): + """A pack that has just finished its drain and is charging at `at`.""" + m = Maintenance(cfg, store or Mem()) + m.force_start(at) + m.tick(at, 11.0) # drain complete -> charge + assert m.phase == CHARGE + return m + + +def run(m, start, minutes, soc, gate=True, step=1): + """Tick every `step` minutes for `minutes`; `soc` is a number or f(minute). + Returns (the last result, the time after the run, the first non-CHARGE result).""" + t, res, out = start, None, None + for i in range(0, minutes, step): + t = start + i * step * MIN + s = soc(i * step) if callable(soc) else soc + res = m.tick(t, s, gate_open=gate) + if out is None and res.phase != CHARGE: + out = (res, t) + break + return res, start + minutes * MIN, out + + +print("1. the stall rule: near full and no longer taking charge") +# Live record: 99 % at 04:00, back to 98 % at 04:15, never 100. +m = in_charge() +res, end, out = run(m, T0 + 60 * MIN, 400, lambda i: 99.0 if i < 15 else 98.0) +check("a pack that reaches 99 and falls back to 98 completes (HOLD) after " + f"{CHARGE_STALL_MIN} min without a new high", + out is not None and out[0].phase == HOLD) +check("...at 60 min after the last new high (the 99), not before", + out is not None and out[1] == T0 + 120 * MIN) +check("...with one plain log line", + out is not None and len(out[0].events) == 1 + and "takes no more charge at 98 %" in out[0].events[0]) +m = in_charge() +res, _, out = run(m, T0, 59, 98.0) +check("59 minutes flat is not enough", out is None and res.phase == CHARGE) +m = in_charge() +res, _, out = run(m, T0, 61, lambda i: 97.0) +check("target - 3 (97 of 100) counts as near full", out and out[0].phase == HOLD) +m = in_charge() +res, _, out = run(m, T0, 300, lambda i: 96.0) +check("target - 4 (96 of 100) does not complete however long it is flat", + out is None) +cfg99 = MaintConfig(enabled=True, start_hour=19, soc_target=99, hold_min=120) +m = in_charge(cfg=cfg99) +res, _, out = run(m, T0, 61, lambda i: 96.0) +check("the shipped target 99 works the same way (96 is near full)", + out and out[0].phase == HOLD) +m = in_charge() +res, _, out = run(m, T0, 400, lambda i: min(99.0, 94.0 + i // 30)) +check("a pack still rising 1 point per 30 min is not stalled: it completes 60 " + "min after the 99 at minute 150", out and out[1] == T0 + 210 * MIN) +m = in_charge() +res, _, out = run(m, T0, 130, lambda i: 97.0 if i < 50 else (98.0 if i < 100 else 98.0)) +check("a new high resets the wait: 97 flat for 50 min, 98 at 50 min -> " + "completes at 110, not 60", out and out[1] == T0 + 110 * MIN) +m = in_charge() +res, _, out = run(m, T0, 300, lambda i: 97.0, gate=False) +check("minutes with the gate closed do not count as 'no more charge'", + out is None) +m = in_charge() +_, t, _ = run(m, T0, 30, 98.0) +_, t, _ = run(m, t, 120, 98.0, gate=False) +res, _, out = run(m, t, 31, 98.0) +check("30 open + 120 closed + 31 open minutes complete (the closed ones " + "pause the count)", out and out[0].phase == HOLD) + +print("2. SoC at the target completes at once") +m = in_charge() +r = m.tick(T0 + MIN, 100.0) +check("100 of 100 -> HOLD on that tick", r.phase == HOLD) +check("...with the old log line", "charge complete at 100 %" in r.events[0]) +m = in_charge(cfg=cfg99) +check("99 of 99 -> HOLD", m.tick(T0 + MIN, 99.0).phase == HOLD) + +print("3. a charge that runs out of time") +m = in_charge() +res, _, out = run(m, T0, CHARGE_TIMEOUT_MIN + 10, + lambda i: 60.0 + i / 55.0, step=1) +check("still rising 1 point per 55 min (never 60 flat) and near full when the " + "time is up -> counts as complete", out and out[0].phase == HOLD) +check("...with one plain line saying so", + out and len(out[0].events) == 1 and "charge time is up" in out[0].events[0] + and "counts as done" in out[0].events[0]) +check("...and no failure is recorded", m.store.get_time("last_failed") is None) +m = in_charge() +res, _, out = run(m, T0, CHARGE_TIMEOUT_MIN + 10, 70.0) +check("a pack stuck at 70 % fails when the time is up", + out and out[0].phase == IDLE and out[0].failed == "charge") +check("...once: the failure is on one tick, and the pack is idle after", + m.phase == IDLE and m.tick(out[1] + MIN, 70.0).failed == "") +check("...with the old technical line and one plain line", + len(out[0].events) == 2 and "ABORT" in out[0].events[0] + and "did not finish" in out[0].events[1] + and f"{FAIL_BACKOFF_DAYS} days" in out[0].events[1]) +check("...and last_failed is set, last_completed is not", + m.store.get_time("last_failed") == out[1] + and m.store.get_time("last_completed") is None) + +print("4. the back-off after a failed cycle") +fail_at = out[1] +day = lambda n: fail_at.replace(hour=19, minute=0) + timedelta(days=n) +check("the next day at the start hour: no cycle (it would begin with a drain)", + not m.wants_start(day(1)) and m.tick(day(1), 70.0).phase == IDLE) +check("every day for 6 days: still no cycle", + all(not m.wants_start(day(n)) for n in range(1, 7))) +r = [m.tick(day(n), 70.0) for n in range(1, 7)] +check("...and tick() keeps the pack idle and silent", + all(x.phase == IDLE and not x.events and x.failed == "" for x in r)) +until = m.backoff_until(day(1)) +check("backoff_until() names the date, 7 days after the failure", + until == fail_at + timedelta(days=FAIL_BACKOFF_DAYS)) +check("...it holds one minute before and is over at that minute", + m.backoff_until(until - MIN) == until and m.backoff_until(until) is None) +check("at 7 days, in the start hour, the pack wants to start again", + m.backoff_until(until) is None + and m.wants_start(until.replace(hour=19, minute=5))) +check("a forced start ignores the back-off", + m.force_start(day(1)) and m.phase == DRAIN) +m2 = in_charge() +run(m2, T0, CHARGE_TIMEOUT_MIN + 10, 70.0) +m2.force_start(day(2)) +m2.tick(day(2) + MIN, 11.0) +res, _, out2 = run(m2, day(2) + 2 * MIN, 400, 100.0) +check("a forced cycle that completes ends the back-off", + out2 and out2[0].phase == HOLD) +m2.tick(out2[1] + 130 * MIN, 100.0) +check("...(the hold completes, last_completed is set)", + m2.store.get_time("last_completed") is not None + and m2.backoff_until(day(3)) is None) +m3 = in_charge() +m3.store.set_time("last_failed", T0) +m3.abort(T0 + MIN, "operator") +check("an operator abort is not a failure and starts no back-off", + m3.store.get_time("last_failed") == T0 + and in_charge().store.get_time("last_failed") is None) +m4 = Maintenance(CFG, Mem()) +m4.force_start(T0) +r = m4.tick(T0 + timedelta(minutes=721), 40.0) +check("a drain that times out is a failure too, with the same back-off", + r.failed == "drain" and m4.store.get_time("last_failed") is not None + and not m4.wants_start(day(1))) + +print("5. only the minutes the pack was allowed to charge count") +m = in_charge() +res, t, out = run(m, T0, CHARGE_TIMEOUT_MIN * 2, 70.0, gate=False) +check("72 h with the gate closed: not timed out", out is None and m.phase == CHARGE) +res, t, out = run(m, t, CHARGE_TIMEOUT_MIN - 5, 70.0) +check("...and 35 h 55 min open on top: still charging", out is None) +res, t, out = run(m, t, 10, 70.0) +check("...36 h of open time in all: the charge fails", out and out[0].failed == "charge") +m = in_charge() +res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 5, 70.0, gate=False, step=5) +check("a gate that never opens still ends in a failure after 7 days", + out and out[0].failed == "charge") +m = in_charge() +res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 5, 98.0, gate=False, step=5) +check("...or in a complete charge when the pack is near full", + out and out[0].phase == HOLD) +m = in_charge() +res, t, out = run(m, T0, 10, 70.0) +res = m.tick(t + 2 * timedelta(hours=1), 70.0) +trk = m._trk +check("a gap in the ticks (add-on down) counts for at most 5 minutes", + trk["open_min"] < 20, ) + +print("6. restarts and old state files") +with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "state.json") + st = Store(path) + m = Maintenance(CFG, st) + m.force_start(T0) + m.tick(T0, 11.0) + _, t, out = run(m, T0, 60, 70.0, gate=False) # 60 min waiting + _, t, out = run(m, t, 40, 70.0) # 40 min charging + open_before = m._trk["open_min"] + # a fresh process on the same file + m_b = Maintenance(CFG, Store(path)) + r = m_b.tick(t + MIN, 70.0) + check("a restart mid-charge keeps the open minutes (40 of 100 min; within " + "the 5 min save interval), not the 100 wall-clock minutes", + m_b.phase == CHARGE + and open_before - 6 <= m_b._trk["open_min"] <= open_before + 2 + and m_b._trk["open_min"] < 60) + _, t2, out = run(m_b, t + 2 * MIN, CHARGE_TIMEOUT_MIN - 30, 70.0) + check("...so the timeout comes after 36 h in all, not 36 h from the restart", + out is not None and out[0].failed == "charge") + m = Maintenance(CFG, Store(path)) + m.store.set_time("last_failed", T0) + m_c = Maintenance(CFG, Store(path)) + check("a restart mid back-off keeps it (last_failed is in the file)", + m_c.backoff_until(T0 + timedelta(days=3)) == T0 + timedelta(days=7) + and not m_c.wants_start((T0 + timedelta(days=3)).replace(hour=19))) + check("...and the state file is plain JSON", + isinstance(json.load(open(path)).get("last_failed"), str)) + old = {"phase": "charge", + "phase_started": (T0 - timedelta(hours=35)).isoformat(), + "last_completed": None, "last_start_attempt": None, + "auto": True} + path2 = os.path.join(d, "old.json") + json.dump(old, open(path2, "w")) + mo = Maintenance(CFG, Store(path2)) + check("a state file from before this change loads with no failure and no " + "charge record", mo.backoff_until(T0) is None + and mo.store.get("charge_track") is None and mo.phase == CHARGE) + r = mo.tick(T0, 70.0) + check("...a charge in progress counts the hours it already ran (35 h)", + r.phase == CHARGE and 34 * 60 < mo._trk["open_min"] < 36 * 60) + res, _, out = run(mo, T0 + MIN, 120, 70.0) + check("...so it fails when the 36 h are up, as before", + out and out[0].failed == "charge") + mi = Maintenance(CFG, Store(os.path.join(d, "none.json"))) + check("no state file: due, no back-off", mi.due(T0) and mi.backoff_until(T0) is None) + +print("7. two packs (N=2)") +a, b = Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"), \ + Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b") +b.force_start(T0) +b.tick(T0, 11.0) +_, t, out = run(b, T0, CHARGE_TIMEOUT_MIN + 10, 70.0) +slot = datetime(2026, 10, 7, 19, 5, tzinfo=UTC) +check("B failed: B is backed off, A (due) is not", + out and out[0].failed == "charge" and not b.wants_start(slot) + and a.wants_start(slot)) +check("...so A takes the slot, and B is not named as waiting for anyone", + arbitrate([a, b], slot) == ["", ""]) +a.force_start(slot) +check("...and while A cycles, B is held as before", + "inverter-a" in arbitrate([a, b], slot + 3 * MIN)[1]) +check("B's failure is under B's own keys, not A's", + b.store.get_time("last_failed") is not None + and a.store.get_time("last_failed") is None) +mst = Mem({"last_failed": T0.isoformat(), "phase": "idle"}) +migrate_maint_keys(mst, "inverter-a", ["inverter-a", "inverter-b"]) +check("going from one pack to two moves the back-off to the first pack", + mst.data.get(unit_key("inverter-a", "last_failed")) == T0.isoformat() + and "last_failed" in MAINT_UNIT_KEYS) + + +print("8. the Controller: gate, notification, status row") + + +class Hass: + def __init__(self, numbers, limits): + self.numbers, self.limits_by, self.calls = dict(numbers), dict(limits), [] + + async def number(self, entity, invert=False): + v = self.numbers.get(entity) + return None if v is None else (-v if invert else v) + + async def limits(self, entity): + return self.limits_by.get(entity, (None, None, None)) + + async def set_number(self, entity, value): + return True + + async def call(self, domain, service, data): + self.calls.append((domain, service, data)) + return True + + +class Pub: + enabled = False + + def publish(self, *a, **k): + pass + + def close(self): + pass + + +OPTS1 = dict(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a", + batt_entity="sensor.batt_a", meter_source="off", + meter_entity="sensor.grid", max_w=5000.0, gain=0.6, slew_w=6000, + deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10, + stale_input_s=15, maintenance_discharge_w=2000, + maintenance_charge_w=2000, maintenance_enabled=True, + maintenance_soc_target=100, + price_now_entity="sensor.p_now", price_avg_entity="sensor.p_avg") +OPTS2 = {k: v for k, v in OPTS1.items() + if k not in ("setpoint_entity", "soc_entity", "batt_entity")} +OPTS2["inverters"] = [ + dict(name="inverter-a", setpoint_entity="number.sp_a", + soc_entity="sensor.soc_a", batt_entity="sensor.batt_a", max_w=5000.0), + dict(name="inverter-b", setpoint_entity="number.sp_b", + soc_entity="sensor.soc_b", batt_entity="sensor.batt_b", max_w=3000.0)] + + +def controller(opts, cheap, soc_a=70.0, soc_b=70.0): + p_now, p_avg = (0.05, 0.10) if cheap else (0.20, 0.10) + hass = Hass({"sensor.grid": 800.0, "sensor.soc_a": soc_a, + "sensor.batt_a": 0.0, "sensor.soc_b": soc_b, + "sensor.batt_b": 0.0, "sensor.p_now": p_now, + "sensor.p_avg": p_avg}, + {"number.sp_a": (-5000.0, 5000.0, 1.0), + "number.sp_b": (-3000.0, 3000.0, 1.0)}) + c = Controller(dict(opts), hass, Mem(), Pub(), stop=asyncio.Event()) + for inv in c.inverters: + inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w + c.auto = True + return c, hass + + +def one_cycle(c): + async def go(): + await c.read_inputs() + await c.cycle() + asyncio.run(go()) + + +def put_in_charge(c, which, hours_ago, open_min): + now = datetime.now(UTC) + m = c.maints[which] + m.store.set("phase", CHARGE) + m.store.set_time("phase_started", now - timedelta(hours=hours_ago)) + m.store.set("charge_track", { + "since": m.store.get("phase_started"), "open_min": open_min, + "flat_min": 0.0, "best": 70.0, + "at": (now - timedelta(minutes=3)).isoformat()}) + return m + + +def notes(h): + return [x for x in h.calls if x[0] == "persistent_notification"] + + +for cheap in (True, False): + c, h = controller(OPTS1, cheap) + m = put_in_charge(c, 0, 5, 100.0) + one_cycle(c) + got = m._trk["open_min"] - 100.0 + check(f"N=1, price gate {'open' if cheap else 'closed'}: the Controller " + f"{'adds' if cheap else 'does not add'} the 3 minutes since the last " + f"tick to the charging time (+{got:.1f})", + (2.5 < got < 3.5) if cheap else abs(got) < 0.01) + +c, h = controller(OPTS1, True) +m = put_in_charge(c, 0, 40, CHARGE_TIMEOUT_MIN + 1) +one_cycle(c) +n = notes(h) +check("N=1: a charge past its time at 70 % ends in IDLE with ONE notification", + m.phase == IDLE and len(n) == 1 + and n[0][2]["notification_id"] == "goodwe_maint_failed_battery" + and n[0][2]["title"] == "Battery cycle did not finish") +one_cycle(c) +one_cycle(c) +check("...and later cycles send no second one, and start no drain", + len(notes(h)) == 1 and m.phase == IDLE) +check("...the status page says when it waits until", + any(k == "Maintenance paused" and "Waits until" in v + for k, v in c.status_payload()["rows"])) +c0, _h0 = controller(OPTS1, True) +check("...and has no such row when nothing failed", + not any(k == "Maintenance paused" for k, v in c0.status_payload()["rows"])) + +c, h = controller(OPTS2, True) +m = put_in_charge(c, 1, 40, CHARGE_TIMEOUT_MIN + 1) +one_cycle(c) +n = notes(h) +check("N=2: pack B fails, B alone is told, by name, under its own id", + c.maints[1].phase == IDLE and c.maints[0].phase == IDLE and len(n) == 1 + and n[0][2]["notification_id"] == "goodwe_maint_failed_inverter_b" + and "inverter-b" in n[0][2]["message"]) +check("...and its failure is on B's keys", + c.maints[1].store.get_time("last_failed") is not None + and c.maints[0].store.get_time("last_failed") is None) + +print("9. the words") +en, nl = messages.maint_failed("charge", 7), messages.maint_failed("drain", 7) +for n_ in (en, nl): + print(" " + n_.title + " | " + n_.body + " | " + n_.body_nl) +check("EN charge text", en.body == "The battery did not fill up, so its cycle " + "stopped and tries again in 7 days.") +check("EN drain text", nl.body == "The battery did not empty, so its cycle " + "stopped and tries again in 7 days.") +check("NL texts", en.body_nl == "Batterijcyclus niet afgerond. De batterij " + "raakte niet vol, dus de cyclus is gestopt en probeert het over 7 dagen " + "opnieuw." and nl.body_nl.endswith("raakte niet leeg, dus de cyclus is " + "gestopt en probeert het over 7 dagen opnieuw.")) +check("the notification carries EN, NL and the Details line, in that order", + re.fullmatch(r"The battery .*\n\nBatterijcyclus .*\n\nDetails: .*", + en.data()["message"], re.S) is not None) +check("the DOCS section and table row exist", + "### Battery cycle did not finish" in (HERE / "DOCS.md").read_text() + and "goodwe_maint_failed_" in (HERE / "DOCS.md").read_text()) +check("no new option: the numbers are constants", + (CHARGE_NEAR_FULL_PCT, CHARGE_STALL_MIN, FAIL_BACKOFF_DAYS) + == (3, 60, 7)) + +print() +if fails: + print(f"{len(fails)} FAILED") + for f in fails: + print(" -", f) + sys.exit(1) +print("all passed") diff --git a/goodwe_controller/test_maintenance.py b/goodwe_controller/test_maintenance.py index b090c36..f38a054 100644 --- a/goodwe_controller/test_maintenance.py +++ b/goodwe_controller/test_maintenance.py @@ -622,6 +622,8 @@ def flat_store(**over): "phase_started": "2026-09-03T22:00:00+00:00", "last_completed": "2026-08-07T12:00:00+00:00", "last_start_attempt": "2026-09-03T10:00:00+00:00", + # MAINT-13: a failed cycle on record, so that key's move is graded too. + "last_failed": "2026-09-01T10:00:00+00:00", }, **over)) @@ -929,10 +931,11 @@ print(f" ok={_rep8.ok} lost={len(_rep8.lost)} kept={len(_rep8.kept)} " f"misdirected={len(_rep8.misdirected)}; marker " f"{_m8.data.get(MAINT_SCHEMA_KEY, '<>')!r}") check("PRECONDITION: this fixture reaches the marker through the `lost` guard " - "and ONLY that one - two keys in `kept`, two in `lost`, `misdirected` " + "and ONLY that one - two keys in `kept`, three in `lost` (the three the " + "fixture records; MAINT-13 added `last_failed`), `misdirected` " "empty. If it ever stops having a non-empty `kept` the mutation below " "goes invisible again, so the shape is asserted rather than assumed", - _rep8.ran and len(_rep8.kept) == 2 and len(_rep8.lost) == 2 + _rep8.ran and len(_rep8.kept) == 2 and len(_rep8.lost) == 3 and not _rep8.misdirected and not _rep8.ok) check("⚠️ THE MARKER IS NOT STAMPED ON A MIGRATION THAT LOST A MONTH OF " "HISTORY, and the two keys that DID land do not buy it one. `rep.ok` is " @@ -1010,10 +1013,14 @@ check("...and nothing about a deferral, a slot or another pack appears in any " # pressure. _solo2 = Maintenance(cfg, MemStore()) _solo2.force_start(t0) +# MAINT-13: a drain that times out is a failed cycle, so one plain line follows +# the abort line (the abort line itself is unchanged). check("...and so is the abort, which is the string an operator reads under " "pressure", _solo2.tick(t0 + timedelta(hours=13), 60.0).events - == ["maintenance: ABORT (drain stuck at 60 % after 780 min)"]) + == ["maintenance: ABORT (drain stuck at 60 % after 780 min)", + "maintenance: the cycle did not finish. It waits 7 days before it " + "tries again."]) diff --git a/goodwe_controller/test_safety93.py b/goodwe_controller/test_safety93.py index f9599dd..fa0ddda 100644 --- a/goodwe_controller/test_safety93.py +++ b/goodwe_controller/test_safety93.py @@ -203,6 +203,10 @@ def timed_out(end): s.data[unit_key(A, "phase_started")] = ( rig.BASE + timedelta(seconds=end - CHARGE_TIMEOUT_MIN * 60 - 1) ).isoformat() + # MAINT-13: a timed-out charge now backs off 7 days instead of restarting + # at once, so a due B would start its own drain here. B has finished its + # cycle, as in b_done(), so only the charge end is graded. + s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat() return s