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..af3aa27 --- /dev/null +++ b/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md @@ -0,0 +1,109 @@ +# 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._charge_gate_open()` says whether the pack may charge now. It is +true when no price gate is set up (no price entities, no fixed tariff: nothing +holds the charge back, and `cheap` is always false there), in a cheap window, or +while the pack is really taking charge (battery power at or below -50 W, which is +sun outside cheap hours). It is false only when a gate exists, is shut, and the +pack is not charging; a pack asked for charge that delivers nothing then does not +count, which is the case the clock cannot judge. A site with a price provider +(TARIFF-11) and no fixed tariff counts as having no gate: the provider's plan +reaches only the Tariff row, never `cheap`, so no cheap window ever opens a grid +top-up there and only the sun charges the pack, as on a site with no tariff. Its +time counts in full. When a provider plan starts to drive `cheap`, +`_price_gated` must become true for it. Whether a fixed tariff is set up is +worked out once an hour, not on every tick. Only open 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 gated site whose gate never opens (no cheap hours, 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..7c46df8 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -1667,6 +1667,21 @@ 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, and the charge ends after 7 days at the +latest. 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 +3265,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 +3292,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..b7a3fef 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, @@ -91,7 +91,7 @@ from .price import Absence, Uncertainty, brussels, price_option, provider_config from .price import required_spread as price_required_spread from .price import uncertainty_of as price_uncertainty_of from .price_history import PriceHistory -from .static_tariff import resolve_tariff, static_status +from .static_tariff import resolve_tariff, static_gated, static_status from .tariff import PeakStore, PeakTracker, next_quarter from .store import migrate_maint_keys, migrate_unit_keys from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse, @@ -1188,6 +1188,8 @@ class Controller: self.grid = None self.peak_fc = None self.cheap = False + self._price_gated = False # MAINT-13: is a price gate set up + self._gated_key = None # MAINT-13: (options, hour) of the cache # TARIFF-03. One line of prose for the status page saying WHICH tariff # window we are in and why it is or is not cheap - so "Cheap window: # no" is an evidenced answer rather than a bare no. @@ -3312,6 +3314,7 @@ class Controller: now_p = await self.hass.number(now_e) avg_p = await self.hass.number(avg_e) self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p) + self._price_gated = True self.tariff_words = "Prices from Home Assistant entities" else: # TARIFF-03. §9.2 makes a static day/night tariff a first-class @@ -3335,8 +3338,9 @@ class Controller: # ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile # alongside the two Home Assistant entities, because this `if` picks # one and the loser is silent. - self.cheap, self.tariff_words = static_status( - o, datetime.now(timezone.utc)) + _now = datetime.now(timezone.utc) + self.cheap, self.tariff_words = static_status(o, _now) + self._price_gated = self._static_gated(o, _now) # ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND # THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the @@ -3875,6 +3879,29 @@ class Controller: inv.refusal_note())] if n] + def _static_gated(self, o: dict, now: datetime) -> bool: + """`static_gated()`, worked out once an hour: it builds the same + month-long plan as `static_status()` (about 20 ms with a profile), and + whether a fixed tariff is set up does not change from tick to tick + (options change only with a restart).""" + key = (id(o), now.strftime("%Y-%m-%d %H")) + if key != self._gated_key: + self._gated_key, self._gated = key, static_gated(o, now) + return self._gated + + def _charge_gate_open(self, inv) -> bool: + """MAINT-13: may this pack charge now, for the maintenance timeout? + + True when no price gate is set up (no price entities, no fixed tariff: + nothing holds the charge back), in a cheap window, or while the pack is + really taking charge (sun outside cheap hours). Closed only when a gate + exists, it is shut, and the pack is not charging. + """ + if not self._price_gated or self.cheap: + return True + batt = inv.batt + return batt is not None and float(batt) <= -MAINT_CHARGING_W + async def cycle(self) -> None: """Gather claims, resolve precedence, write the winner. @@ -3923,10 +3950,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 pack was + # allowed to charge (see `_charge_gate_open`). + res = machine.tick(now, inv.soc, blocked_by=blocked, + gate_open=self._charge_gate_open(inv)) 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 +7141,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 +7189,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), @@ -7368,6 +7414,10 @@ class Controller: return None +# MAINT-13: a battery power reading at or below minus this many W is a pack +# that is taking charge. +MAINT_CHARGING_W = 50.0 + # SAFETY-61. Exit status when the control task dies; 70 is sysexits(3) # EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md. EXIT_CONTROL_DIED = 70 diff --git a/goodwe_controller/app/maintenance.py b/goodwe_controller/app/maintenance.py index 8710775..4edc5c9 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,10 @@ class Maintenance: return self.store.get("phase", IDLE) def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None: + self._trk = None + self._trk_saved = 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 +418,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 +444,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 +473,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 +549,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 +626,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 +636,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/static_tariff.py b/goodwe_controller/app/static_tariff.py index fc6659b..aa838c2 100644 --- a/goodwe_controller/app/static_tariff.py +++ b/goodwe_controller/app/static_tariff.py @@ -1232,6 +1232,16 @@ def static_plan(opts: dict, since: datetime, until: datetime, *, profile, "fallback" if provisional else "static") +def static_gated(opts: dict, now: datetime) -> bool: + """MAINT-13: is a fixed tariff set up, so that a cheap window can hold a + charge back? False for no profile or one that cannot be used.""" + try: + return bool(static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS), + now=now).has_prices) + except ProfileInvalid: + return False + + def static_status(opts: dict, now: datetime): """(cheap, words) for the status page and for main.py's cheap-window flag. 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..07881ed --- /dev/null +++ b/goodwe_controller/test_maint13.py @@ -0,0 +1,570 @@ +"""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 * MIN + s = soc(i) 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() +# 5 min steps: one tick counts at most 5 min, so this is the same count as +# 1 min steps in a fifth of the ticks. +res, t, out = run(m, T0, CHARGE_TIMEOUT_MIN * 2, 70.0, gate=False, step=5) +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, step=5) +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() +WALL = T0 + CHARGE_WALL_TIMEOUT_MIN * MIN +res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 10, 70.0, gate=False, step=5) +check("a gate that never opens still ends in a failure after 7 days (not before)", + out and out[0].failed == "charge" and WALL < out[1] <= WALL + 5 * MIN) +m = in_charge() +res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 10, 98.0, gate=False, step=5) +check("...or in a complete charge when the pack is near full", + out and out[0].phase == HOLD and WALL < out[1] <= WALL + 5 * MIN) +m = in_charge() +res, t, out = run(m, T0, 10, 70.0) +before = m._trk["open_min"] +res = m.tick(t + 2 * timedelta(hours=1), 70.0) +check("a gap in the ticks (add-on down) counts for exactly 5 minutes (the cap)", + abs(m._trk["open_min"] - before - 5.0) < 1e-9) +m.tick(t + 2 * timedelta(hours=1) + 2 * MIN, 70.0) +check("...and a 2 minute step counts 2", abs(m._trk["open_min"] - before - 7.0) < 1e-9) +_t = t + 2 * timedelta(hours=1) + 2 * MIN +m.tick(_t - 10 * MIN, 70.0) # the clock steps back 10 min +check("...and a step back in time counts nothing (not minus)", + abs(m._trk["open_min"] - before - 7.0) < 1e-9) + +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, step=5) + 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") + # A record left by another charge (an add-on rolled back and forward + # again does not clear it) must not be read as this charge's. + stale = {"phase": "charge", + "phase_started": (T0 - timedelta(hours=2)).isoformat(), + "charge_track": {"since": (T0 - timedelta(days=30)).isoformat(), + "open_min": 2000.0, "flat_min": 59.0, + "best": 99.0, "at": None}} + ms = Maintenance(CFG, Mem(stale)) + r = ms.tick(T0, 98.0) + check("a charge record from another charge is ignored: this charge counts " + "its own 2 h and its own best", + r.phase == CHARGE and 119 < ms._trk["open_min"] < 121 + and ms._trk["flat_min"] == 0.0 and ms._trk["best"] == 98.0) + 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) + +# a site with no price entities and no fixed tariff: nothing holds the charge back +NOTARIFF = {k: v for k, v in OPTS1.items() + if k not in ("price_now_entity", "price_avg_entity")} +c, h = controller(NOTARIFF, False) +m = put_in_charge(c, 0, 5, 100.0) +one_cycle(c) +got = m._trk["open_min"] - 100.0 +check("N=1, no tariff and no price entities: the gate counts as open, so the " + f"3 minutes count (+{got:.1f}) although `cheap` is false", + not c.cheap and 2.5 < got < 3.5) +c, h = controller(NOTARIFF, False) +m = put_in_charge(c, 0, 40, CHARGE_TIMEOUT_MIN + 1) +one_cycle(c) +check("...and a pack past its charging time there fails at once (not 7 days)", + m.phase == IDLE and len(notes(h)) == 1) +# a site with a price provider (TARIFF-11) and no fixed tariff: the provider's +# plan reaches only the Tariff row, never `cheap`, so no cheap window ever +# opens a grid top-up there. Like the site above, nothing gate-like holds the +# charge back, and the time counts. +PROVIDER = dict(NOTARIFF, price_provider="generic_dayahead", static_profile="") +c, h = controller(PROVIDER, False) +m = put_in_charge(c, 0, 5, 100.0) +one_cycle(c) +got = m._trk["open_min"] - 100.0 +check("N=1, price provider and no fixed tariff: not a gate (`cheap` is never " + f"set from the provider), so the 3 minutes count (+{got:.1f})", + c._price_gated is False and not c.cheap + and c._charge_gate_open(c.inverters[0]) is True and 2.5 < got < 3.5) +def read(c): + asyncio.run(c.read_inputs()) + + +c, h = controller(OPTS1, False) +read(c) +c.inverters[0].batt = -800.0 +check("price gate closed but the pack is taking 800 W (sun): gate open", + c._charge_gate_open(c.inverters[0]) is True) +c.inverters[0].batt = -10.0 +check("...closed and about idle: gate closed", + c._charge_gate_open(c.inverters[0]) is False) +c.inverters[0].batt = None +check("...no battery reading: gate closed", c._charge_gate_open(c.inverters[0]) is False) +c, h = controller(OPTS1, True) +read(c) +check("price gate open: gate open", c._charge_gate_open(c.inverters[0]) is True) +PROFILE = json.dumps({"periods": [ + {"name": "day", "from": "07:00", "to": "22:00", "days": "all", + "eur_per_kwh": 0.29}, + {"name": "night", "from": "22:00", "to": "07:00", "days": "all", + "eur_per_kwh": 0.19}]}) +c, h = controller(dict(NOTARIFF, price_provider="off", static_profile=PROFILE), + False) +read(c) +check("a fixed tariff set up counts as a gate: outside its cheap hours a pack " + "that is not charging is closed, inside them it is open", + c._price_gated is True + and c._charge_gate_open(c.inverters[0]) is c.cheap) +import app.main as main_mod # noqa: E402 +_real_gated, _calls = main_mod.static_gated, [] +main_mod.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now) +try: + c._gated_key = None + read(c) + read(c) + read(c) + c._static_gated(c.o, datetime.now(UTC) + timedelta(hours=1)) +finally: + main_mod.static_gated = _real_gated +check("...worked out once an hour, not on every tick (3 reads in one hour = 1 " + f"build; a new hour = 1 more): {len(_calls)} builds", + len(_calls) == 2 and c._price_gated is True) + +# the save timer is reset on a phase change +mm = in_charge() +mm.tick(T0 + MIN, 70.0) +saved_before = mm._trk_saved +mm.abort(T0 + 2 * MIN, "operator") +check("leaving CHARGE resets the save timer along with the record", + saved_before is not None and mm._trk is None and mm._trk_saved 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..7e27348 100644 --- a/goodwe_controller/test_safety93.py +++ b/goodwe_controller/test_safety93.py @@ -37,7 +37,7 @@ import safety79_identity as s79 # noqa: E402 import safety84_identity as s84 # noqa: E402 from app import packmode # noqa: E402 from app.main import Controller # noqa: E402 -from app.maintenance import (CHARGE_TIMEOUT_MIN, HOLD, IDLE, # noqa: E402 +from app.maintenance import (CHARGE_TIMEOUT_MIN, DRAIN, HOLD, IDLE, # noqa: E402 MaintResult) from app.store import unit_key # noqa: E402 from named_absence import first, present # noqa: E402 @@ -221,9 +221,16 @@ def switch_off(at): return before +def timed_out_b_done(end): + """timed_out(), with B's monthly cycle finished so it starts none.""" + s = timed_out(end) + s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat() + return s + + ENDS = { "timeout": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0, - store=timed_out(1780.0)), + store=timed_out_b_done(1780.0)), "Off": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0, store=b_done(), before=switch_off(1780.0), extra={"battery_modes_enabled": True}), @@ -246,6 +253,31 @@ for k, fn in ENDS.items(): f"the grid", at(old, 1780.0, "b") == -1600.0 and charging_hi(old, 1780, 1798, hi1) >= 1) +# MAINT-13: A's timed-out charge is a failed cycle and backs off, so A no longer +# restarts at once; a due B takes the slot instead and starts ITS drain. That is +# a drain start (a step to the drain rate by design), not a charge end. +rows, c = run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0, + store=timed_out(1780.0)) +print(f" timeout, B due: A {[cmd(r)['a'] for r in rows if 1776 <= r[0] <= 1790]}" + f" B {[cmd(r)['b'] for r in rows if 1776 <= r[0] <= 1790]}") +check("timeout with B due: the charge ends at 1780 s and A is not restarted", + phase_at(rows, 1778.0) == "charge" and phase_at(rows, 1780.0) == IDLE + and c.maints[0].phase == IDLE + and c.maints[0].backoff_until(rig.BASE + timedelta(seconds=1800)) + is not None) +# A command below 0 W is a charge, so B's commands must stay at or above 0; +# the drain rate is the one this rig's controller was configured with. +drain_w = c.maints[1].cfg.discharge_w +print(f" B drain rate configured on the rig: {drain_w:.0f} W") +check("⭐ timeout with B due: no battery charges from the grid after it", + charging_hi(rows, 1780, 1798, hi1) == 0 + and min(cmd(r)["b"] for r in rows if r[0] >= 1780) >= 0.0) +check("...B takes the slot and begins its own drain, which is the only large " + "step, to the configured drain rate and no higher", + c.maints[1].phase == DRAIN + and max(cmd(r)["b"] for r in rows if r[0] >= 1780) <= drain_w + and moves(rows, "a", 1780, 1798) <= SLEW) + # ============================================================================ # print("\n4. the charge ends while the guard has suspended it") # House 6000 W from 1500 s: the guard suspends the shaping, the loop has both