Files
goodwe-addon/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md
glenn schrooyenandClaude Opus 5.5 6836f61bd4
checks / unit (pull_request) Successful in 34m52s
infra-pin / fresh (pull_request) Successful in 1s
MAINT-13 round 3: provider site, hourly fixed-tariff check, faster tests
- A price-provider site with no fixed tariff is not a gate (its plan never
  sets `cheap`); DECISIONS §3 says so and a test pins it.
- The fixed-tariff check is worked out once an hour, not every tick.
- test_maint13: run() steps really are `step` minutes; the long loops use
  5-minute steps (the per-tick cap); wall backstop checked "not before".
- test_safety93: the drain bound is the rig's configured drain rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:03:55 +02:00

6.5 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._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.