Files
goodwe-addon/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md
T
glenn schrooyenandClaude Opus 5.5 28eff568b5
checks / unit (pull_request) Successful in 34m21s
infra-pin / fresh (pull_request) Successful in 1s
MAINT-13: a full pack that stops at 98-99 % is not a failed charge
A charge is complete at SoC >= target, or at >= target - 3 once the SoC has
made no new high for 60 min of allowed charging, or when the charging time
runs out at >= target - 3. The 36 h timeout counts only the minutes the price
gate let the pack charge (plus a 7 day wall-clock backstop). A cycle that
fails (stuck drain, charge below target - 3) backs off 7 days, tells the
operator once (EN + NL), and no cycle starts on that pack in that time unless
the operator forces one. State survives a restart; old state files load.

No option added. DECISIONS/MAINT-13-full-pack-is-complete.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:36:06 +02:00

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

  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.