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>
5.7 KiB
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
- SoC >= target (as before), or
- 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 - 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_failedis 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.