# 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`.