MAINT-13: a full pack that stops at 98-99 % is not a failed charge
checks / unit (pull_request) Successful in 34m21s
infra-pin / fresh (pull_request) Successful in 1s

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 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-08 09:36:06 +02:00
co-authored by Claude Opus 5.5
parent b527e99f13
commit 28eff568b5
9 changed files with 776 additions and 16 deletions
@@ -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`.
+23
View File
@@ -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_<name>` | SAFETY-42 |
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
`/config/app/<slug>/documentation`; 2026.5 removed the old
+23 -4
View File
@@ -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),
+119 -7
View File
@@ -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:
+29 -1
View File
@@ -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"),
]
+2 -1
View File
@@ -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:
+468
View File
@@ -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_<name>" 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")
+10 -3
View File
@@ -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, '<<absent>>')!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."])
+4
View File
@@ -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