- 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>
110 lines
6.5 KiB
Markdown
110 lines
6.5 KiB
Markdown
# 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`.
|