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