MAINT-24 + MAINT-25: a started maintenance charge gets the cheap night; the plan honours the back-off #246

Merged
admin merged 7 commits from MAINT-24 into release/1.0 2026-11-12 16:09:33 +01:00
Owner

MAINT-24 + MAINT-25: a started maintenance charge gets the cheap night, and aims at the month peak like the guard.

Cause (investigation report reports/MAINT-24-investigation-report.md). While pack A is in its maintenance CHARGE phase, B's battery-mode grid charge took the cheap run, and A's shaping paused or shrank because it compared the peak forecast with cap - margin (3450 W) instead of this month's peak, which the guard and B's trade aim at.

Fix (smallest).

  1. modetrade.decide(maint_charging=): while any pack is in maintenance CHARGE, no grid-charge trade for the other packs, why cycle (mirror of SAFETY-78). Spare solar is still allowed, and a SAFETY-73 refusal probe (PROBE_W alone) still goes on (test_opt20.py g7).
  2. The maintenance pause and charge floor aim at peakguard.quarter_target_w(cap, margin, month reading) (folds in SAFETY-80). SAFETY-63 and SAFETY-125 unchanged.
  • SAFETY-111 is NOT folded in: taking the pack's own charge off the forecast changes the N=1 charge pinned by test_safety119.py. It stays on its card; A settles at (aim - house) / 2.
  • No option, no MAINT-13 timeout change.

Tests. test_maint24.py through the real cycle() (N=2 lagged replay): A charges 1475 W, B no trade (why cycle), quarters at most 4475 W against the 5950 W month aim; forecast under the month aim: no pause; real peak still pauses; mirror case; N=1 / drain / modes-off digests equal 02eac3b. 4 mutants red. Each charge test fails on base. Whole 9 h night (sim): A 1.41 -> 9.47 kWh and full, B no grid trade.

test_opt20.py: the ch_ch_maint digest is re-pinned (A in maintenance charge beside a charger B changed by design, same as SAFETY-125 moved two before).

DECISIONS: MAINT-24-charge-first.md.

CHANGELOG line: The charge of a started monthly cycle now gets the cheap night: the other battery no longer grid-charges ahead of it, and the charge aims at this month's peak.

Not merged; rig test on TEST-23 is the next step.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC

MAINT-25 (same PR, owner 2026-11-12). The Home plan ignored the 7-day back-off after a failed cycle and drew a cycle every night. homeview.maintenance_runs() now draws no run during backoff_until() and shows ": the monthly cycle waits until 19 November." (NL too). test_maint25.py. CHANGELOG line: After a failed monthly cycle the Home plan no longer shows a cycle every night; it says when the next try is.

MAINT-24 + MAINT-25: a started maintenance charge gets the cheap night, and aims at the month peak like the guard. **Cause (investigation report `reports/MAINT-24-investigation-report.md`).** While pack A is in its maintenance CHARGE phase, B's battery-mode grid charge took the cheap run, and A's shaping paused or shrank because it compared the peak forecast with `cap - margin` (3450 W) instead of this month's peak, which the guard and B's trade aim at. **Fix (smallest).** 1. `modetrade.decide(maint_charging=)`: while any pack is in maintenance CHARGE, no grid-charge trade for the other packs, why `cycle` (mirror of SAFETY-78). Spare solar is still allowed, and a SAFETY-73 refusal probe (PROBE_W alone) still goes on (`test_opt20.py` g7). 2. The maintenance pause and charge floor aim at `peakguard.quarter_target_w(cap, margin, month reading)` (folds in SAFETY-80). SAFETY-63 and SAFETY-125 unchanged. - SAFETY-111 is NOT folded in: taking the pack's own charge off the forecast changes the N=1 charge pinned by `test_safety119.py`. It stays on its card; A settles at (aim - house) / 2. - No option, no MAINT-13 timeout change. **Tests.** `test_maint24.py` through the real `cycle()` (N=2 lagged replay): A charges 1475 W, B no trade (why cycle), quarters at most 4475 W against the 5950 W month aim; forecast under the month aim: no pause; real peak still pauses; mirror case; N=1 / drain / modes-off digests equal 02eac3b. 4 mutants red. Each charge test fails on base. Whole 9 h night (sim): A 1.41 -> 9.47 kWh and full, B no grid trade. `test_opt20.py`: the `ch_ch_maint` digest is re-pinned (A in maintenance charge beside a charger B changed by design, same as SAFETY-125 moved two before). DECISIONS: `MAINT-24-charge-first.md`. CHANGELOG line: The charge of a started monthly cycle now gets the cheap night: the other battery no longer grid-charges ahead of it, and the charge aims at this month's peak. Not merged; rig test on TEST-23 is the next step. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC **MAINT-25 (same PR, owner 2026-11-12).** The Home plan ignored the 7-day back-off after a failed cycle and drew a cycle every night. `homeview.maintenance_runs()` now draws no run during `backoff_until()` and shows "<name>: the monthly cycle waits until 19 November." (NL too). `test_maint25.py`. CHANGELOG line: After a failed monthly cycle the Home plan no longer shows a cycle every night; it says when the next try is.
admin added 1 commit 2026-11-12 07:08:04 +01:00
MAINT-24: a started maintenance charge gets the cheap night
checks / unit (pull_request) Failing after 41m45s
infra-pin / fresh (pull_request) Successful in 2s
54a738dd87
While a pack is in its maintenance CHARGE phase the mode trade files no grid
charge for the other packs (why "cycle", mirror of SAFETY-78). The maintenance
shaping check and charge floor aim at peakguard.quarter_target_w, month peak
included (SAFETY-80). Tests through cycle(): test_maint24.py.

Co-Authored-By: Claude <noreply@anthropic.com>
admin added 1 commit 2026-11-12 07:33:07 +01:00
MAINT-24: keep the refusal probe beside a maintenance charge
checks / unit (pull_request) Failing after 45m44s
infra-pin / fresh (pull_request) Successful in 2s
0f5e513f1d
The mode trade still lets a SAFETY-73 probe (PROBE_W alone) go on while a pack
is in its maintenance CHARGE phase; test_opt20.py g7 pins it. ch_ch_maint
digest moved (B no longer grid-charges beside A's charge).

Co-Authored-By: Claude <noreply@anthropic.com>
admin added 1 commit 2026-11-12 07:51:31 +01:00
MAINT-24: test_maint24.py points at MUTATION.md
checks / unit (pull_request) Successful in 28m32s
infra-pin / fresh (pull_request) Successful in 1s
b44a648cab
Co-Authored-By: Claude <noreply@anthropic.com>
admin added 1 commit 2026-11-12 08:15:06 +01:00
MAINT-24: read the last row without a bare index (test_named_absence)
checks / unit (pull_request) Successful in 26m37s
infra-pin / fresh (pull_request) Successful in 1s
6004bfad9a
Co-Authored-By: Claude <noreply@anthropic.com>
admin added 1 commit 2026-11-12 08:22:18 +01:00
MAINT-24: test_peak_margin pins the maintenance checks to the guard's aim
checks / unit (pull_request) Successful in 29m32s
infra-pin / fresh (pull_request) Successful in 1s
b80cfa969f
Co-Authored-By: Claude <noreply@anthropic.com>
admin added 1 commit 2026-11-12 09:57:58 +01:00
MAINT-25: the Home plan shows no cycle during the 7-day back-off
checks / unit (pull_request) Successful in 27m21s
infra-pin / fresh (pull_request) Successful in 1s
de0790e62d
homeview.maintenance_runs() ignored backoff_until(); it drew a cycle every
night of the wait. Now no run and a plain line (EN, NL). test_maint25.py.

Co-Authored-By: Claude <noreply@anthropic.com>
admin changed title from MAINT-24: a started maintenance charge gets the cheap night to MAINT-24 + MAINT-25: a started maintenance charge gets the cheap night; the plan honours the back-off 2026-11-12 09:58:03 +01:00
admin added 1 commit 2026-11-12 11:08:33 +01:00
MAINT-25: clean test_maint25.py, split its DECISIONS note
checks / unit (pull_request) Successful in 26m39s
infra-pin / fresh (pull_request) Successful in 1s
33cfc733d3
Co-Authored-By: Claude <noreply@anthropic.com>
Author
Owner

Review: PR #246 (MAINT-24 + MAINT-25) at 33cfc733d3 vs release/1.0 02eac3b

Verdict: MERGE-READY if the owner accepts trade-off T1 below. No safety finding. No code finding above Low. Not merged, because the brief allows a merge only with no owner trade-off.

What I checked and the results

  • Numbers: I re-ran the dev's sims on base and head (real cycle(), lagged N=2 replay). They match the dev's table exactly. Live shape (house 3000 W, banked 6000 W, aim 5950 W): base A 0 W, B 2500 W, trade on, worst quarter 5500 W. Head A 1475 W, B 0 W, no trade, worst quarter 4475 W. 9 h night: base A 1.41 kWh (10 -> 23 %), B 6.84 kWh. Head A 9.47 kWh (10 -> 100 %), B 0. The mirror (B cycling) gives the same numbers with A and B swapped. A never gives back in either.
  • Safety: I ran these extra sims (head): no forecast (SAFETY-63) A 2000 W, worst quarter 5000 W; month start (no banked peak) A 225 W, worst quarter 3225 W, under 3450 W; house 5800 W A -75 W, worst quarter 5875 W, never gives (base: paused, A gave up to +2500 W); real peak at house 6500 W still pauses (1875/2700 cycles), A gives, worst quarter 5689 W (base 5951 W). N=1, drain and modes-off digests equal base (no banked peak). maint_aim is the same quarter_target_w(cap, margin, reading) the guard gets (ctl_cycle.py:395-402 vs :563-566), and a failed reading keeps the cap. The floor still goes through _charge_headroom (SAFETY-125 charge_room_w). shaping_suspended still trips on decision.engaged. Blocking B's grid charge cannot drain B: nothing in the add-on grid-charges a pack to protect a reserve, B still takes spare solar, and the block ends with A's CHARGE phase (MAINT-13 timeouts).
  • Mutants (my own copies, fresh trees, python3 -B): all red. Dropping the maint_charging branch: 10 checks fail. Passing maint_charging=False in ctl_trade: 6 fail. Aim reverted on both the pause and the floor: 3 fail. Floor only: 3. Pause only: 6. Probe carve-out removed: 2 in test_maint24 and opt20 g7. MAINT-25 back-off check dropped: 4. On base: test_maint24 has 7 FAILs and a TypeError, test_maint25 has 4 FAILs (the dev said 5, see L3).
  • CI / suite: run 1613 checks / unit success and 1614 infra-pin / fresh success on 33cfc73. Full local suite (goodwe-addon-checks:1, --network none, real hwsim at infra pin 3b4ff84, GOODWE_REQUIRE_HWSIM=1): 106 files, 0 failed, 1832 s. test_tariff24_weather.py PASS in 226 s, so the router's TIMEOUT came from its environment. test_split_patches.py (MAINT-21 tripwire) PASS.

Owner trade-off

T1 (design, money only). The block leaves half the room unused and costs B its cheap nights for the whole charge phase. The floor settles at (aim - house) / 2 because SAFETY-111 stays out: the forecast includes A's own charge. The other half of the room under the aim is not used by anyone, because B is blocked.

  • Live shape (banked 6000 W, house 3000 W): A is full within the night. 1475 W of room goes unused, which is fine.
  • House 4500 W: A 6.53 kWh, 10 -> 72 % in 9 h (base 0). Two nights.
  • Month start (no month peak above the cap yet), house 3000 W: A gets only 225 W, 2.03 kWh a night (base 0.13). B gets 0 (base 3.81 kWh a night). About 9 kWh at 2 kWh a night is past the 36 h counted timeout, so the cycle can still fail, and B has lost about 4 cheap nights for nothing.
  • 19 Nov is fine (November already has a banked peak). Follow-ups that would remove this: SAFETY-111, or let B take the room A's floor leaves (size B's trade to room - A's floor instead of blocking it).

Findings

  • L1 Low (docs / wording): homeview.py:1537 shows the day the 7-day wait ends. For the live failure (11-11 22:54 UTC) the line reads "18 November". The next try is 19 Nov 19:00. The line meets the brief ("the day the wait ends"), but three things in the PR say otherwise: the PR body's CHANGELOG line ("it says when the next try is"), the PR body example, and DECISIONS/MAINT-25-plan-backoff.md:8 ("19 November"). Fix the wording, or show the date of the first start slot after until.
  • L2 Low (UX, follow-up card): during A's maintenance CHARGE, B's Home card shows "Saves its power for higher prices. / Resting now.", and the plan still draws B's grid charge in the cheap run (e.g. "Charges up to 6.5 kWh from the grid"), which will not happen. I checked this on head through homeview.build on the sim controller. other_cycle only fires for DRAIN (homeview.py:902), so DECISIONS/MAINT-24-charge-first.md:70 is wrong when it says B "reads 'emptying'". The plan also draws a grid run for the cycling pack itself, as on base. Display only. Suggested card: "UX: while one battery's monthly cycle charges, the other battery's Home card and plan do not say it waits for the cycle".
  • L3 Low (test): test_maint25.py:124 says "the run tonight is B's" but checks only len(runs) == 1, so it also passes on base (A gets the run, B "waits its turn"). Check runs[0]["battery"] == 1.
  • I1 Info: test_peak_margin.py:260 (test_aim_w) still says the forecast sizing uses peak_aim_w(). That is true only with no banked peak.
  • I2 Info: N=1 and modes-off match base only without a banked peak. With 6000 W banked they change by design (SAFETY-80): N=1 A 225 -> 1475 W, worst quarter 3225 -> 4475 W. The PR body's "digests equal 02eac3b" is true only for the pinned scenarios.
  • I3 Info: a SAFETY-73 probe (200 W) beside A's floor uses the same measured room. This is the same double count base had with a full charger, and the guard corrects it.
  • I4 Info: if the cycling pack cannot charge (no SoC, refusing), B stays blocked until the MAINT-13 timeout. This mirrors SAFETY-78. Money only.

Design answers

  • Is (aim - house) / 2 intended? Not as a goal. It is the SAFETY-111 equilibrium. It is safe and enough for the live shape. Leaving SAFETY-111 out is right for this PR: folding it in changes the N=1 charge pinned by test_safety119, and it belongs on its own card.
  • Is "cycle" shown plainly? Only as the technical arb reason "battery modes: no trade (cycle)", the same as SAFETY-78. The Home page does not show it (L2).
  • Comments are one line plus a pointer. DECISIONS notes are short and accurate apart from L1 and L2. The CHANGELOG lines are in the PR body and there is no ## Unreleased. The new EN/NL text is short and plain (UX-08).
## Review: PR #246 (MAINT-24 + MAINT-25) at 33cfc733d3892ca4a08ced524c611ae6e654c30f vs release/1.0 02eac3b **Verdict: MERGE-READY if the owner accepts trade-off T1 below.** No safety finding. No code finding above Low. Not merged, because the brief allows a merge only with no owner trade-off. ### What I checked and the results - **Numbers:** I re-ran the dev's sims on base and head (real `cycle()`, lagged N=2 replay). They match the dev's table exactly. Live shape (house 3000 W, banked 6000 W, aim 5950 W): base A 0 W, B 2500 W, trade on, worst quarter 5500 W. Head A 1475 W, B 0 W, no trade, worst quarter 4475 W. 9 h night: base A 1.41 kWh (10 -> 23 %), B 6.84 kWh. Head A 9.47 kWh (10 -> 100 %), B 0. The mirror (B cycling) gives the same numbers with A and B swapped. A never gives back in either. - **Safety:** I ran these extra sims (head): no forecast (SAFETY-63) A 2000 W, worst quarter 5000 W; month start (no banked peak) A 225 W, worst quarter 3225 W, under 3450 W; house 5800 W A -75 W, worst quarter 5875 W, never gives (base: paused, A gave up to +2500 W); real peak at house 6500 W still pauses (1875/2700 cycles), A gives, worst quarter 5689 W (base 5951 W). N=1, drain and modes-off digests equal base (no banked peak). `maint_aim` is the same `quarter_target_w(cap, margin, reading)` the guard gets (ctl_cycle.py:395-402 vs :563-566), and a failed reading keeps the cap. The floor still goes through `_charge_headroom` (SAFETY-125 `charge_room_w`). `shaping_suspended` still trips on `decision.engaged`. Blocking B's grid charge cannot drain B: nothing in the add-on grid-charges a pack to protect a reserve, B still takes spare solar, and the block ends with A's CHARGE phase (MAINT-13 timeouts). - **Mutants (my own copies, fresh trees, `python3 -B`):** all red. Dropping the `maint_charging` branch: 10 checks fail. Passing `maint_charging=False` in ctl_trade: 6 fail. Aim reverted on both the pause and the floor: 3 fail. Floor only: 3. Pause only: 6. Probe carve-out removed: 2 in test_maint24 and opt20 g7. MAINT-25 back-off check dropped: 4. On base: test_maint24 has 7 FAILs and a TypeError, test_maint25 has 4 FAILs (the dev said 5, see L3). - **CI / suite:** run 1613 `checks / unit` success and 1614 `infra-pin / fresh` success on 33cfc73. Full local suite (`goodwe-addon-checks:1`, `--network none`, real hwsim at infra pin 3b4ff84, `GOODWE_REQUIRE_HWSIM=1`): **106 files, 0 failed, 1832 s**. `test_tariff24_weather.py` PASS in 226 s, so the router's TIMEOUT came from its environment. `test_split_patches.py` (MAINT-21 tripwire) PASS. ### Owner trade-off **T1 (design, money only).** The block leaves half the room unused and costs B its cheap nights for the whole charge phase. The floor settles at `(aim - house) / 2` because SAFETY-111 stays out: the forecast includes A's own charge. The other half of the room under the aim is not used by anyone, because B is blocked. - Live shape (banked 6000 W, house 3000 W): A is full within the night. 1475 W of room goes unused, which is fine. - House 4500 W: A 6.53 kWh, 10 -> 72 % in 9 h (base 0). Two nights. - **Month start (no month peak above the cap yet), house 3000 W:** A gets only 225 W, 2.03 kWh a night (base 0.13). B gets 0 (base 3.81 kWh a night). About 9 kWh at 2 kWh a night is past the 36 h counted timeout, so the cycle can still fail, and B has lost about 4 cheap nights for nothing. - 19 Nov is fine (November already has a banked peak). Follow-ups that would remove this: SAFETY-111, or let B take the room A's floor leaves (size B's trade to `room - A's floor` instead of blocking it). ### Findings - **L1 Low (docs / wording):** `homeview.py:1537` shows the day the 7-day wait ends. For the live failure (11-11 22:54 UTC) the line reads "**18** November". The next try is 19 Nov 19:00. The line meets the brief ("the day the wait ends"), but three things in the PR say otherwise: the PR body's CHANGELOG line ("it says when the next try is"), the PR body example, and `DECISIONS/MAINT-25-plan-backoff.md:8` ("19 November"). Fix the wording, or show the date of the first start slot after `until`. - **L2 Low (UX, follow-up card):** during A's maintenance CHARGE, B's Home card shows "Saves its power for higher prices. / Resting now.", and the plan still draws B's grid charge in the cheap run (e.g. "Charges up to 6.5 kWh from the grid"), which will not happen. I checked this on head through `homeview.build` on the sim controller. `other_cycle` only fires for DRAIN (`homeview.py:902`), so `DECISIONS/MAINT-24-charge-first.md:70` is wrong when it says B "reads 'emptying'". The plan also draws a grid run for the cycling pack itself, as on base. Display only. Suggested card: **"UX: while one battery's monthly cycle charges, the other battery's Home card and plan do not say it waits for the cycle"**. - **L3 Low (test):** `test_maint25.py:124` says "the run tonight is B's" but checks only `len(runs) == 1`, so it also passes on base (A gets the run, B "waits its turn"). Check `runs[0]["battery"] == 1`. - **I1 Info:** `test_peak_margin.py:260` (`test_aim_w`) still says the forecast sizing uses `peak_aim_w()`. That is true only with no banked peak. - **I2 Info:** N=1 and modes-off match base only without a banked peak. With 6000 W banked they change by design (SAFETY-80): N=1 A 225 -> 1475 W, worst quarter 3225 -> 4475 W. The PR body's "digests equal 02eac3b" is true only for the pinned scenarios. - **I3 Info:** a SAFETY-73 probe (200 W) beside A's floor uses the same measured room. This is the same double count base had with a full charger, and the guard corrects it. - **I4 Info:** if the cycling pack cannot charge (no SoC, refusing), B stays blocked until the MAINT-13 timeout. This mirrors SAFETY-78. Money only. ### Design answers - Is `(aim - house) / 2` intended? Not as a goal. It is the SAFETY-111 equilibrium. It is safe and enough for the live shape. Leaving SAFETY-111 out is right for this PR: folding it in changes the N=1 charge pinned by test_safety119, and it belongs on its own card. - Is "cycle" shown plainly? Only as the technical arb reason "battery modes: no trade (cycle)", the same as SAFETY-78. The Home page does not show it (L2). - Comments are one line plus a pointer. DECISIONS notes are short and accurate apart from L1 and L2. The CHANGELOG lines are in the PR body and there is no `## Unreleased`. The new EN/NL text is short and plain (UX-08).
admin merged commit 1b5f7c9ccb into release/1.0 2026-11-12 16:09:33 +01:00
Author
Owner

Owner accepted T1 2026-11-12; merged at 1b5f7c9ccb.

Owner accepted T1 2026-11-12; merged at 1b5f7c9ccb995e3d3baeb923a31770d26eaa1a08.
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#246