From 35b2a699de816dde5b3b2aa8877107a7cb0cb206 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 10 Oct 2026 00:42:01 +0200 Subject: [PATCH] MAINT-15 round 2: in-repo differential check, full-hold with-reading check Co-Authored-By: Claude Opus 5.5 --- .../MAINT-15-hold-ends-without-soc.md | 8 ++- goodwe_controller/test_maint15.py | 56 +++++++++++++++++-- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/goodwe_controller/DECISIONS/MAINT-15-hold-ends-without-soc.md b/goodwe_controller/DECISIONS/MAINT-15-hold-ends-without-soc.md index 2230603..3da9bc0 100644 --- a/goodwe_controller/DECISIONS/MAINT-15-hold-ends-without-soc.md +++ b/goodwe_controller/DECISIONS/MAINT-15-hold-ends-without-soc.md @@ -37,7 +37,11 @@ line is true without a reading: it names minutes of hold, not a level. - 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`). +- With a reading, nothing changes. `test_maint15.py` section 7 compares the new + HOLD tick by tick with a frozen copy of the release/1.0 HOLD branch (phase, + setpoint, owner, events, `last_completed`) at four `hold_min` values. A wider + trace of the whole cycle (800 seeded runs against db9e231) was run outside the + repo; its script and log are kept under `scratch/MAINT-15/` of the dev + workspace, not in this repository. - A state file written by an older version with `phase: hold` loads and ends on time; the file is not changed in format. diff --git a/goodwe_controller/test_maint15.py b/goodwe_controller/test_maint15.py index ec5e8ee..8e0985f 100644 --- a/goodwe_controller/test_maint15.py +++ b/goodwe_controller/test_maint15.py @@ -16,9 +16,7 @@ import tempfile from datetime import datetime, timedelta, timezone from app.maintenance import ( - CHARGE, CHARGE_NEAR_FULL_PCT, CHARGE_TIMEOUT_MIN, CHARGE_WALL_TIMEOUT_MIN, - DRAIN, DRAIN_TIMEOUT_MIN, FAIL_BACKOFF_DAYS, HOLD, IDLE, NOSOC_NOTE_MIN, - MaintConfig, Maintenance, arbitrate, + CHARGE, DRAIN, HOLD, IDLE, NOSOC_NOTE_MIN, MaintConfig, Maintenance, ) from app.store import DEFAULTS, Store @@ -241,10 +239,56 @@ m = in_charge() res, _, out, _ = run(m, T0, 600, None) check("CHARGE with no SoC still keeps charging", out is None and res.phase == CHARGE and res.charge_only) + + +def old_hold_branch(phase_started, last_completed, now, hold_min): + """FROZEN copy of release/1.0 (db9e231) HOLD branch of step(), with a + reading: returns (phase, owns_setpoint, setpoint_w, events, + last_completed). Kept as a reference for the differential check below.""" + elapsed = (now - phase_started).total_seconds() / 60.0 + if elapsed >= hold_min: + return (IDLE, False, None, + [f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) " + "- handing back to the loop"], now) + return (HOLD, True, 0.0, [], last_completed) + + +print("7. with a reading: the new HOLD against the frozen release/1.0 branch") +for hm in (0, 5, 60, 120): + cfg = MaintConfig(enabled=True, interval_days=28, start_hour=19, + discharge_w=2000, charge_w=2000, soc_floor=11, + soc_target=100, hold_min=hm) + m = Maintenance(cfg, Mem()) + m.force_start(T0) + m.tick(T0, 11.0) + m.tick(H0, 100.0) + started, last, same, end = H0, None, True, None + for i in range(0, hm + 15): + t = H0 + i * MIN + if m.phase != HOLD: + break + want = old_hold_branch(started, last, t, hm) + r = m.tick(t, 100.0 - (i % 3)) + got = (r.phase, r.owns_setpoint, r.setpoint_w, r.events, + m.store.get_time("last_completed")) + same = same and got == want + last = want[4] + if r.phase == IDLE: + end = t + check(f"hold_min {hm}: every tick identical (phase, owns, W, events, " + "last_completed)", same) + check("...ends at hold_min, last_completed = that tick, one COMPLETE line", + end == H0 + hm * MIN and m.store.get_time("last_completed") == end + and m.phase == IDLE) + m = in_hold() -res, _, out, _ = run(m, H0, 30, 100.0) -check("HOLD with a reading is unchanged: 0 W, ends at hold_min", out is None - and res.setpoint_w == 0.0) +res, _, out, ev = run(m, H0, HOLD_MIN + 5, 100.0) +check("HOLD with a reading: 0 W until the end, then IDLE at hold_min", + out is not None and out[1] == H0 + HOLD_MIN * MIN and out[0].phase == IDLE) +check("...last_completed is that tick and the COMPLETE line is said once", + m.store.get_time("last_completed") == H0 + HOLD_MIN * MIN + and ev == ["maintenance: cycle COMPLETE (120 min hold) " + "- handing back to the loop"]) print() if fails: