MAINT-13: a full pack that stops at 98-99 % is not a failed charge #181

Merged
admin merged 5 commits from MAINT-13-full-pack-complete into release/1.0 2026-10-08 13:45:11 +02:00
Owner

MAINT-13: a full pack that stops at 98-99 % no longer counts as a failed charge, and a failed cycle no longer drains again the next day.

Rules (constants in app/maintenance.py; no option added)

  • Charge complete at SoC >= target; or SoC >= target - 3 and no new SoC high for 60 min of allowed charging; or the charging time runs out at SoC >= target - 3. Then the usual hold.
  • A stuck drain (12 h) or a charge below target - 3 at timeout is a failure: 7 days back-off, one notification (EN + NL), no new cycle on that pack until then. The operator's force-start ignores it; a completed cycle ends it.
  • The 36 h charge timeout counts only minutes the price gate (self.cheap) was open. A 7 day wall-clock backstop covers a gate that never opens.
  • N=1 and N=2 (per-pack keys; the other pack may take the slot while one is backed off).

Why these numbers, the live figures, the cost, and the MAINT-12 interplay: DECISIONS/MAINT-13-full-pack-is-complete.md.

Strings

  • Title: Battery cycle did not finish / Batterijcyclus niet afgerond
  • EN: "The battery did not fill up, so its cycle stopped and tries again in 7 days." (drain: "did not empty")
  • NL: "De batterij raakte niet vol, dus de cyclus is gestopt en probeert het over 7 dagen opnieuw." (drain: "niet leeg")
  • Status row "Maintenance paused": "Waits until 2026-10-13 after a cycle that did not finish."

Tests: new test_maint13.py; test_maintenance.py (fixture gains last_failed, drain-abort pin gains the plain line) and test_safety93.py (B has finished its cycle, because A no longer restarts at once) adjusted.

Release type: no option change, so no config change for installers. Behaviour change: the first state file write after this adds last_failed (and charge_track while charging).

🤖 Generated with Claude Code

MAINT-13: a full pack that stops at 98-99 % no longer counts as a failed charge, and a failed cycle no longer drains again the next day. **Rules** (constants in `app/maintenance.py`; no option added) - Charge complete at SoC >= target; or SoC >= target - 3 and no new SoC high for 60 min of allowed charging; or the charging time runs out at SoC >= target - 3. Then the usual hold. - A stuck drain (12 h) or a charge below target - 3 at timeout is a failure: 7 days back-off, one notification (EN + NL), no new cycle on that pack until then. The operator's force-start ignores it; a completed cycle ends it. - The 36 h charge timeout counts only minutes the price gate (`self.cheap`) was open. A 7 day wall-clock backstop covers a gate that never opens. - N=1 and N=2 (per-pack keys; the other pack may take the slot while one is backed off). Why these numbers, the live figures, the cost, and the MAINT-12 interplay: `DECISIONS/MAINT-13-full-pack-is-complete.md`. **Strings** - Title: Battery cycle did not finish / Batterijcyclus niet afgerond - EN: "The battery did not fill up, so its cycle stopped and tries again in 7 days." (drain: "did not empty") - NL: "De batterij raakte niet vol, dus de cyclus is gestopt en probeert het over 7 dagen opnieuw." (drain: "niet leeg") - Status row "Maintenance paused": "Waits until 2026-10-13 after a cycle that did not finish." **Tests**: new `test_maint13.py`; `test_maintenance.py` (fixture gains `last_failed`, drain-abort pin gains the plain line) and `test_safety93.py` (B has finished its cycle, because A no longer restarts at once) adjusted. **Release type**: no option change, so no config change for installers. Behaviour change: the first state file write after this adds `last_failed` (and `charge_track` while charging). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
admin added 1 commit 2026-10-08 09:36:20 +02:00
MAINT-13: a full pack that stops at 98-99 % is not a failed charge
checks / unit (pull_request) Successful in 34m21s
infra-pin / fresh (pull_request) Successful in 1s
28eff568b5
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>
admin added 1 commit 2026-10-08 10:29:49 +02:00
MAINT-13 round 2: gate open when nothing holds the charge back; safety93 scenario restored
checks / unit (pull_request) Failing after 34m45s
infra-pin / fresh (pull_request) Successful in 2s
6f05b2e4be
- gate_open is true with no price gate set up, in a cheap window, or while the
  pack is taking charge; the stall rule and timeout now work on a no-tariff site.
- reset the save timer on a phase change; exact 5 min gap-cap test.
- test_safety93: original timeout scenario kept (B finished) plus a case where a
  due B takes the slot after A's failure.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
admin added 1 commit 2026-10-08 11:10:35 +02:00
MAINT-13 round 2: read the price gate without a new main.py option read
checks / unit (pull_request) Successful in 34m35s
infra-pin / fresh (pull_request) Successful in 1s
f7b83cc75c
test_static_tariff forbids main.py reading `static_profile`; a small
static_gated() in static_tariff.py answers it, and the Controller keeps the
answer in `_price_gated` beside `cheap`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
admin added 1 commit 2026-10-08 12:14:29 +02:00
MAINT-13 round 3: provider site, hourly fixed-tariff check, faster tests
checks / unit (pull_request) Successful in 34m52s
infra-pin / fresh (pull_request) Successful in 1s
6836f61bd4
- 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>
admin added 1 commit 2026-10-08 13:08:42 +02:00
MAINT-13 review: tests for a stale charge record and a clock step back; DOCS names the 7 day end
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Successful in 34m49s
ea31289ec0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

MAINT-13 review (head reviewed: 6836f61, then my test/docs commit ea31289)

Verdict: correct and safe to merge once checks / unit (pull_request) is green on ea31289. I found no behaviour change to ask for.

Tests I ran myself. In goodwe-addon-checks:1 with --network none, one docker run per file, 8 at a time: 68/68 pass on 6836f61 (wall 519 s; the slowest was test_modes_p4 at 486 s). On ea31289 I re-ran test_maint13, test_docs, test_messages, test_translations, plus the 7 files that read DOCS.md. All pass.

Rules, checked against the code

  • Complete at target, or at target-3 or above after 60 gate-open min with no new high, or a timeout at target-3 or above. A timeout below target-3 is a failure. A failure sets last_failed, sends one notification (only on the tick that carries failed) and makes wants_start() false for 7 days. Force-start ignores it. A completed cycle ends it. Operator abort, interlock and Off are not failures. OK.
  • The timeout counts only gate-open minutes, at most 5 per tick, and has a 7-day wall-clock backstop. A pack with no SoC reading still never times out, but that was already true before this PR (the soc is None return comes before the timeout).
  • _charge_gate_open by site type:
    • No tariff, or a price provider with no fixed tariff: there is no gate, so the time counts in full, as before.
    • Fixed tariff, or HA price entities: the gate is open in cheap hours or while the battery is charging at 50 W or more (sun).
    • No battery reading: the gate counts as closed, and the wall backstop still ends the charge.
  • No path leads to a pack that can never finish. The only ways back to IDLE without a completion or a failure are operator abort, Off and interlock, and none of them comes back daily by itself.
  • State: charge_track is saved every 5 min and matched against phase_started, so a record from another charge is ignored. An old state file loads with the wall-clock minutes. last_failed is in MAINT_UNIT_KEYS. On an install that has already migrated (marker stamped), it is simply written under the unit key from now on.
  • N=2: the arbitration is unchanged and a backed-off pack frees the slot. SAFETY-93 keeps the original timeout scenario (with B finished) and adds the B-due case.

Scope calls

  • Drain timeout also backs off: accepted, and safe. A stuck drain now repeats weekly, not daily.
  • NL text only in the notification: fine.
  • The notification is lost on an HA restart. The status row stays while the wait lasts. That is acceptable for a warning, which is not latched like an alarm.

UX-08. Title, EN body, NL body, status row and log lines are short and plain. OK.

Findings (none blocking)

  1. Low, main.py status row "Maintenance paused": at N=2 it shows the earliest date and does not say which battery waits. A follow-up could name the pack.
  2. Low, DOCS.md "When the charge counts as done": the 7-day end was not mentioned. Fixed in ea31289 (one clause).
  3. Test gaps found by my mutations: no check that a stale charge_track from another charge is ignored (R3), or that a clock step back adds nothing (R8). Two checks added in ea31289; both mutations are now killed.
  4. Before re-enabling maintenance live: B's failure on 2026-10-06 happened before this code, so B has no last_failed. B is due and will start a drain at the next start hour once maintenance is enabled again.

My mutations (not in the dev's 39), each run on test_maint13 + test_maintenance:

# mutation result
R1 a flat SoC resets the stall wait (>= best) killed
R2 target-3 itself is not near full (>) killed
R3 stored charge record loaded without the since check survived, then killed by the new check
R4 discharging pack counts as an open gate (sign flipped) killed
R5 every failure reported as a charge failure killed
R6 charge record saved every 120 min, not 5 killed
R7 _enter does not clear the stored record survives; equivalent while the since check stands
R8 a clock step back is not floored at 0 survived, then killed by the new check
R9 status row shows the latest wait, not the earliest survives (N=2 only; finding 1)
R10 a cheap window does not open a gated site killed
R11 N=2 notification does not name the pack killed
R12 an invalid fixed tariff counts as a gate survives; low, because the safe direction (no gate) is the shipped one and startup validation refuses invalid profiles
R13 wall backstop 7 d -> 36 h killed
R14 charging threshold 50 W -> 5000 W killed
R15 back-off counted from last_start_attempt killed
## MAINT-13 review (head reviewed: 6836f61, then my test/docs commit ea31289) **Verdict: correct and safe to merge** once `checks / unit (pull_request)` is green on ea31289. I found no behaviour change to ask for. **Tests I ran myself.** In `goodwe-addon-checks:1` with `--network none`, one `docker run` per file, 8 at a time: **68/68 pass** on 6836f61 (wall 519 s; the slowest was test_modes_p4 at 486 s). On ea31289 I re-ran test_maint13, test_docs, test_messages, test_translations, plus the 7 files that read DOCS.md. All pass. **Rules, checked against the code** - Complete at target, or at target-3 or above after 60 gate-open min with no new high, or a timeout at target-3 or above. A timeout below target-3 is a failure. A failure sets `last_failed`, sends one notification (only on the tick that carries `failed`) and makes `wants_start()` false for 7 days. Force-start ignores it. A completed cycle ends it. Operator abort, interlock and Off are not failures. OK. - The timeout counts only gate-open minutes, at most 5 per tick, and has a 7-day wall-clock backstop. A pack with no SoC reading still never times out, but that was already true before this PR (the `soc is None` return comes before the timeout). - `_charge_gate_open` by site type: - No tariff, or a price provider with no fixed tariff: there is no gate, so the time counts in full, as before. - Fixed tariff, or HA price entities: the gate is open in cheap hours or while the battery is charging at 50 W or more (sun). - No battery reading: the gate counts as closed, and the wall backstop still ends the charge. - No path leads to a pack that can never finish. The only ways back to IDLE without a completion or a failure are operator abort, Off and interlock, and none of them comes back daily by itself. - State: `charge_track` is saved every 5 min and matched against `phase_started`, so a record from another charge is ignored. An old state file loads with the wall-clock minutes. `last_failed` is in `MAINT_UNIT_KEYS`. On an install that has already migrated (marker stamped), it is simply written under the unit key from now on. - N=2: the arbitration is unchanged and a backed-off pack frees the slot. SAFETY-93 keeps the original timeout scenario (with B finished) and adds the B-due case. **Scope calls** - Drain timeout also backs off: accepted, and safe. A stuck drain now repeats weekly, not daily. - NL text only in the notification: fine. - The notification is lost on an HA restart. The status row stays while the wait lasts. That is acceptable for a warning, which is not latched like an alarm. **UX-08.** Title, EN body, NL body, status row and log lines are short and plain. OK. **Findings (none blocking)** 1. Low, `main.py` status row "Maintenance paused": at N=2 it shows the earliest date and does not say which battery waits. A follow-up could name the pack. 2. Low, `DOCS.md` "When the charge counts as done": the 7-day end was not mentioned. **Fixed in ea31289** (one clause). 3. Test gaps found by my mutations: no check that a stale `charge_track` from another charge is ignored (R3), or that a clock step back adds nothing (R8). **Two checks added in ea31289**; both mutations are now killed. 4. Before re-enabling maintenance live: B's failure on 2026-10-06 happened before this code, so B has no `last_failed`. B is due and will start a drain at the next start hour once maintenance is enabled again. **My mutations (not in the dev's 39)**, each run on test_maint13 + test_maintenance: | # | mutation | result | |---|---|---| | R1 | a flat SoC resets the stall wait (`>=` best) | killed | | R2 | target-3 itself is not near full (`>`) | killed | | R3 | stored charge record loaded without the `since` check | survived, then killed by the new check | | R4 | discharging pack counts as an open gate (sign flipped) | killed | | R5 | every failure reported as a charge failure | killed | | R6 | charge record saved every 120 min, not 5 | killed | | R7 | `_enter` does not clear the stored record | survives; equivalent while the `since` check stands | | R8 | a clock step back is not floored at 0 | survived, then killed by the new check | | R9 | status row shows the latest wait, not the earliest | survives (N=2 only; finding 1) | | R10 | a cheap window does not open a gated site | killed | | R11 | N=2 notification does not name the pack | killed | | R12 | an invalid fixed tariff counts as a gate | survives; low, because the safe direction (no gate) is the shipped one and startup validation refuses invalid profiles | | R13 | wall backstop 7 d -> 36 h | killed | | R14 | charging threshold 50 W -> 5000 W | killed | | R15 | back-off counted from `last_start_attempt` | killed |
admin merged commit 8a17711f60 into release/1.0 2026-10-08 13:45:11 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#181