MAINT-15: the balancing hold ends on time with no SoC reading
checks / unit (pull_request) Successful in 35m22s
infra-pin / fresh (pull_request) Successful in 1s

HOLD's time check moves into Maintenance._hold(), used by step() and
_no_soc(). A hold that loses its SoC now ends at hold_min, writes
last_completed and logs the same COMPLETE line. The 15 min HOLD line says
the hold still ends at its normal time. Behaviour with a reading is
unchanged (differential trace against db9e231).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 23:56:29 +02:00
co-authored by Claude Opus 5.5
parent db9e2315d1
commit cd1bf2a694
5 changed files with 324 additions and 20 deletions
@@ -58,10 +58,7 @@ allows discharge.
## What this does not cover
- **HOLD with no SoC** still holds 0 W and, as before, does not end while the
reading is missing: its time check sits behind the same early return. A HOLD
needs no level to end, so this is a candidate follow-up, not part of this fix. It is card MAINT-15. The "no battery level" line in
HOLD says only that the hold waits for the level.
- **HOLD with no SoC**: closed by MAINT-15 (`MAINT-15-hold-ends-without-soc.md`).
- It does not tell the operator why the reading is missing (sensor, register,
link). It does not reuse the stale-input gate; it is the cycle's own rule.
- The charge itself is still driven by the control loop, which has its own
@@ -0,0 +1,43 @@
# MAINT-15 - the balancing hold ends on time with no SoC reading
**Status: for review.** Tests: `test_maint15.py`. No option added.
**The fault.** `step()` sent every tick with no SoC to `_no_soc()`, and for
HOLD that returned 0 W without the time check. A hold that lost its reading
never ended: `last_completed` was never written and the next due date never
moved. Harmless for power (0 W), but the cycle was stuck. This was left open
in MAINT-14.
## Rule
1. **HOLD ends at `hold_min` whether or not there is a reading.** One helper,
`Maintenance._hold()`, holds the time check and is used by both paths. When
the time is up it writes `last_completed`, goes IDLE and logs the same
"cycle COMPLETE" line. Before the time is up it commands 0 W as before.
2. **Text.** The 15-min "no battery level" line in HOLD now reads "The hold
still ends at its normal time." Still once per episode.
## Why
HOLD is reached only after the charge counted as done. Every entry into HOLD
is in `step()` or `_no_soc()` and follows a charge that reached the target,
stalled near full (MAINT-13) or timed out near full (MAINT-13/14). Nothing else
sets the phase to HOLD (`_enter(HOLD` has four call sites, all after a done
charge). The hold is a wait for the BMS to balance; it never needed the level.
So a hold that ends with no reading is a completed cycle. The "cycle COMPLETE"
line is true without a reading: it names minutes of hold, not a level.
## Cost
- If the level was lost during the hold, the cycle is recorded as complete
without anyone seeing the final level. The charge had counted as done, so this
is the same trust MAINT-14 already gives a charge that ends with no reading.
## What this does not cover
- It does not check that the pack is still full at the end of the hold.
- It does not say why the reading was lost.
- With a reading, nothing changes (proved by a differential trace against
release/1.0 in `test_maint15.py`).
- A state file written by an older version with `phase: hold` loads and ends on
time; the file is not changed in format.