Round 5 (2026-11-14, head 79dfcde). Branch brought back onto release/1.0 (0.22.0) without a force push: merge of release/1.0, revert of the revert 7bc8ad2 (#247 is back), then this PR's rounds 1-4 re-applied as one patch. git diff 1b5f7c9 07d23d2 equals git diff origin/release/1.0 <the re-apply commit> byte for byte, apart from CHANGELOG and config.yaml (0.22.0).
H-1 (owner decision 2026-11-14): while the pause holds, the batteries together give only what the guard needs._m28_share() also files one site limit (the period's largest floor, never under this cycle's floor; P_MAINT_SHAPE, so the guard's claim is never cut). Packs the guard does not need give 0 W for the house during the hold. Outside the hold nothing is filed.
New live-try test (test_maint28b: A 11 % in CHARGE, B 25 % on Cheap hours, 90 min at 6.5 kW, battery register held 10 s). 07d23d2 (red): quarters 5357 1596 1500 4755 6502 6499 5982 W, B gives 2.91 kWh and goes to 10.0 %. This head: quarters 5357 5897 5911 5895 5905 5900 5502 W (every one under the 5960 W bound), B gives 0.86 kWh and stays at 20.6 % or more (1b5f7c9: 1.0 kWh, 19.8 %), give/idle switches B 1, A 0.
Rig shape, 45 min, A 49 %, B 30 %: A gives 0.17 kWh, B 0.23 kWh (was 3.04), quarters 5358 5897 5911 5176 4477 W, A flips 1, give/idle switches A 1, B 1, 0 stale releases, charge back after 4 s.
M-1: a 45-min rig-shape scenario in test_maint28b goes red with settled=True at the hold in ctl_cycle.py (one stale release), green without.
M-2 / L-2: DECISIONS says what happens now; the Numbers column is labelled "f3f9483, without the lag". L-1 not done: _m28_share() reads the loop's claim and the split, so calling it before the SAFETY-85 loop bound would change its input; no effect was measured.
test_safety84n2_charge_banked re-pinned again (banked quarters 3349 and 3950 W, aim 3950 W, asserted). CHANGELOG: one line under "Not in a release yet".
No new option, nothing outside the MAINT-28 hold path changed. No rig, no deploy.
Earlier rounds:
MAINT-28 fix-forward after the rig FAIL (TEST-23 on f3f9483: F-1, F-2). Owner go 2026-11-13 (fallback: revert MAINT-28 if this has not passed the rig by 2026-11-17).
F-1. The pause ended at almost every guard step: grid + site_batt_w added a new meter reading to a battery reading up to 10 s old, so after a step charge -> give the estimate fell by the step, under the release line. Now the pause ends only when no battery reading may pre-date a step up of its command (Controller._batts_settled(): step = 100 W more give or less charge; settled once a changed reading arrived 6 s after it, or after 17 s). Sim (battery register held 10 s), 45 min, house 6500 W: 83 of 83 releases on a stale estimate -> 0; A flips 148 -> 1 (1b5f7c9: 210).
F-2 and the cap. Owner 2026-11-13 ("1" = cap; then "ok max"): while the pause holds, the pack in maintenance CHARGE gives at most the largest floor the peak guard claimed in the current quarter. Built as Controller._m28_share(): the pack's unit limit is the largest part of the guard's floor placed on it in this 15-minute period (the floor less what the other units are placed at in this cycle's split without the limit), 0 W while the guard holds it at its reserve. Kept per unit (_m28_hi), reset each period and when the pause ends. Only one pack can be in maintenance CHARGE (maintenance.arbitrate()).
Pins moved by design: test_safety84n2_charge_banked (192 of 480 rows from t = 1440 s; banked quarters 3349 and 3217 W against the aim 3950 W, asserted). test_packshare part 10 AST pin: it required exactly one resolve( call in the controller; _m28_share() adds a second, so it now requires every call to pass weights=self.pack_weights. test_maint28 unchanged (part 1 quarter 5925 W against the aim 5950 W).
No cut-back delayed or weakened. No option, no new user text. Peak aim, month peak, reserve values and MAINT-13 numbers unchanged. N=1, drain, modes off, SAFETY-63/125/128 unchanged (test_maint28 digests equal). Sim: Lag.batt_period_s (default None, no digest moves). Tests: test_maint28b.py (fails on f3f9483). Mutants: DECISIONS.
CHANGELOG: MAINT-28 fix-forward: the maintenance charge pause no longer ends on a house estimate that mixes a new meter reading with an old battery reading, and while the pause holds the maintenance pack gives at most the largest part of the peak guard's claim placed on it in the quarter.
**Round 5 (2026-11-14, head 79dfcde).** Branch brought back onto release/1.0 (0.22.0) without a force push: merge of release/1.0, revert of the revert 7bc8ad2 (#247 is back), then this PR's rounds 1-4 re-applied as one patch. `git diff 1b5f7c9 07d23d2` equals `git diff origin/release/1.0 <the re-apply commit>` byte for byte, apart from CHANGELOG and config.yaml (0.22.0).
- **H-1 (owner decision 2026-11-14): while the pause holds, the batteries together give only what the guard needs.** `_m28_share()` also files one site limit (the period's largest floor, never under this cycle's floor; `P_MAINT_SHAPE`, so the guard's claim is never cut). Packs the guard does not need give 0 W for the house during the hold. Outside the hold nothing is filed.
- **New live-try test** (test_maint28b: A 11 % in CHARGE, B 25 % on Cheap hours, 90 min at 6.5 kW, battery register held 10 s). 07d23d2 (red): quarters 5357 1596 1500 4755 6502 6499 5982 W, B gives 2.91 kWh and goes to 10.0 %. This head: quarters 5357 5897 5911 5895 5905 5900 5502 W (every one under the 5960 W bound), B gives 0.86 kWh and stays at 20.6 % or more (1b5f7c9: 1.0 kWh, 19.8 %), give/idle switches B 1, A 0.
- **Rig shape, 45 min, A 49 %, B 30 %:** A gives 0.17 kWh, B 0.23 kWh (was 3.04), quarters 5358 5897 5911 5176 4477 W, A flips 1, give/idle switches A 1, B 1, 0 stale releases, charge back after 4 s.
- **M-1:** a 45-min rig-shape scenario in test_maint28b goes red with `settled=True` at the hold in ctl_cycle.py (one stale release), green without.
- **M-2 / L-2:** DECISIONS says what happens now; the Numbers column is labelled "f3f9483, without the lag". L-1 not done: `_m28_share()` reads the loop's claim and the split, so calling it before the SAFETY-85 loop bound would change its input; no effect was measured.
- `test_safety84` `n2_charge_banked` re-pinned again (banked quarters 3349 and 3950 W, aim 3950 W, asserted). CHANGELOG: one line under "Not in a release yet".
- No new option, nothing outside the MAINT-28 hold path changed. No rig, no deploy.
---
Earlier rounds:
MAINT-28 fix-forward after the rig FAIL (TEST-23 on f3f9483: F-1, F-2). Owner go 2026-11-13 (fallback: revert MAINT-28 if this has not passed the rig by 2026-11-17).
**F-1.** The pause ended at almost every guard step: `grid + site_batt_w` added a new meter reading to a battery reading up to 10 s old, so after a step charge -> give the estimate fell by the step, under the release line. Now the pause ends only when no battery reading may pre-date a step up of its command (`Controller._batts_settled()`: step = 100 W more give or less charge; settled once a changed reading arrived 6 s after it, or after 17 s). Sim (battery register held 10 s), 45 min, house 6500 W: 83 of 83 releases on a stale estimate -> 0; A flips 148 -> 1 (1b5f7c9: 210).
**F-2 and the cap.** Owner 2026-11-13 ("1" = cap; then "ok max"): while the pause holds, the pack in maintenance CHARGE gives at most the largest floor the peak guard claimed in the current quarter. Built as `Controller._m28_share()`: the pack's unit limit is the largest part of the guard's floor placed on it in this 15-minute period (the floor less what the other units are placed at in this cycle's split without the limit), 0 W while the guard holds it at its reserve. Kept per unit (`_m28_hi`), reset each period and when the pause ends. Only one pack can be in maintenance CHARGE (`maintenance.arbitrate()`).
| sim 45 min, house 6500 W, 10 s battery register | 1b5f7c9 | f3f9483 | this head |
|---|---|---|---|
| A at 49 %: A give kWh | 0.28 | 0.44 | 0.17 |
| A lowest SoC (start 50.5) | 49.0 | 48.5 | 49.0 |
| charge/give flips of A | 210 | 148 | 1 |
| give/idle switches of A | - | - | 3 |
| stale releases | 0 | 83 of 83 | 0 |
| quarters W | 5358 5901 5906 5044 4478 | 5358 5710 5726 4758 4477 | 5358 1663 1899 2572 4476 |
| charge back after the load | 0 s | 0 s | 4 s |
| B (Cheap hours) give kWh | 0.20 | 0.32 | 3.04 |
| B in CHARGE at 18.2 %: give kWh / lowest SoC | 0.47 / 17.3 | 1.89 / 10.0 | 0.00 / 18.2 |
| live-shape night A / B | -1477 / 0 | -1477 / 0 | -1477 / 0 |
Pins moved by design: `test_safety84` `n2_charge_banked` (192 of 480 rows from t = 1440 s; banked quarters 3349 and 3217 W against the aim 3950 W, asserted). `test_packshare` part 10 AST pin: it required exactly one `resolve(` call in the controller; `_m28_share()` adds a second, so it now requires every call to pass `weights=self.pack_weights`. `test_maint28` unchanged (part 1 quarter 5925 W against the aim 5950 W).
No cut-back delayed or weakened. No option, no new user text. Peak aim, month peak, reserve values and MAINT-13 numbers unchanged. N=1, drain, modes off, SAFETY-63/125/128 unchanged (test_maint28 digests equal). Sim: `Lag.batt_period_s` (default None, no digest moves). Tests: `test_maint28b.py` (fails on f3f9483). Mutants: DECISIONS.
CHANGELOG: MAINT-28 fix-forward: the maintenance charge pause no longer ends on a house estimate that mixes a new meter reading with an old battery reading, and while the pause holds the maintenance pack gives at most the largest part of the peak guard's claim placed on it in the quarter.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Rig FAIL (TEST-23, f3f9483). F-1: the hold ended at each guard step because
grid + site_batt_w added a new meter reading to a battery reading up to 10 s
old. It now ends only when no battery reading may pre-date a step up of its
command (Controller._batts_settled()). F-2: while the pause holds, a pack in
maintenance CHARGE at or under its peak reserve is limited to 0 W, so it gives
only what the guard claims for the peak. Sim: Lag.batt_period_s (a held 10 s
register). Tests: test_maint28b.py. DECISIONS updated.
Co-Authored-By: Claude <noreply@anthropic.com>
Owner go 2026-11-13. The pack in maintenance CHARGE gets a unit limit with hi
= the guard's own floor while the pause holds, at any SoC (replaces the
under-reserve limit). Sim 45 min: A give 2.39 -> 0.02 kWh, SoC 49.0 %, charge
back 4 s. test_safety84 n2_charge_banked re-pinned (192 of 480 rows),
test_maint28 quarter bound to the month peak. Tests: test_maint28b.py.
Co-Authored-By: Claude <noreply@anthropic.com>
test_maint28 quarter bound back to aim + 1 W (now 5925 W; the guard alone landed
5960.1 W): the pack's limit is the largest floor the guard claimed in the 15-minute
period. A pack in peak_guard.held (the guard places its claim on the others) gets
0 W. test_safety84 n2_charge_banked re-pinned again, its banked quarters asserted
under the aim. New test: guard claims on the other pack only.
Co-Authored-By: Claude <noreply@anthropic.com>
Owner 2026-11-13 ("ok max", relayed by the manager): while the pause holds,
the pack in maintenance CHARGE gives at most the largest floor the guard
claimed in the quarter. The cap is now that pack's own part of the floor
(the floor less what the other units are placed at, split without the cap),
the largest in the 15-minute period, kept per unit (`_m28_hi`). 0 W while
the guard holds it at its reserve. test_maint28b: SoC check stated against
the owner-accepted give (0.70 point, measured 0.64); both-packs-able tests
(B on Cheap hours, B on Solar first); per-unit state and the one-slot
arbitrate. test_maint28 unchanged (5925 W). Pins: test_safety84
n2_charge_banked, test_packshare's one-resolve AST pin (a second call with
the same weights).
Co-Authored-By: Claude <noreply@anthropic.com>
Review of PR #248 at 07d23d2 (fix-forward round 4): NOT READY
Head MAINT-28-fix07d23d2eed, base release/1.0f3f9483 (ls-remote at start). CI 1645 checks / unit and 1646 infra-pin / fresh success on 07d23d2 (API).
The F-1 fix works: no release on a stale estimate, flips 148 -> 1, and the cap holds the maintenance pack to its part. But while the pause holds, the other pack gives its full rating for the house. In the live-try shape that can set a new month peak, which base and f3f9483 do not.
Findings
id
severity
where
what
H-1
High (safety)
app/ctl_cycle.py:579-603 (the held branch: no site bound while held)
While the pause holds and the guard idles, nothing bounds the site, so the loop gives for the whole house. The cap moves that give off the maintenance pack onto the other pack. Sim, live-try shape (A 11 % in maintenance CHARGE, B 25 % Cheap hours, 6.5 kW for 90 min, month peak 6000 W, 10 s battery register): B gives 4.6 kW from the first guard idle and reaches its 10 % floor after 43 min. Then neither pack can give, and the quarters are 6502 and 6499 W, 500 W over the month peak. 1b5f7c9: every quarter <= 5916 W, B 25 -> 19.8 %. f3f9483: <= 5888 W, B -> 18.1 %. Rig-shape 45 min (A 49 %, B 30 % Cheap hours): B gives 3.04 kWh, 30 -> 14.3 %, under its 20 % reserve (1b5f7c9 0.20 kWh, 29.0 %; f3f9483 0.32 kWh, 28.4 %; no maintenance at all 0.21 kWh). Cause: the original MAINT-28 hold drops the maintenance shaping claim, so no site charge ceiling is filed (SAFETY-84 _charge_ceiling, line 706). The F-1 fix makes the hold stick, so this now shows. The cap then puts all of it on the other pack.
M-1
Medium (test)
test_maint28b.py:199-202; app/ctl_cycle.py:581
No scenario grades the F-1 wiring. Mutant shaping_held(..., house, True) at ctl_cycle.py:581: test_maint28b all checks pass (13-min load: flips 1, releases 0). In the 45-min rig-shape sim, the same mutant gives back F-1 in full: 148 flips, 83 of 83 releases on a stale estimate. The dev's freshness mutant is red only through the direct _batts_settled() unit checks.
M-2
Medium (docs)
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md:17
Still says "while the pause is held the loop works as at 02eac3b, so A also gives for the house ... so the steady give is kept". The cap contradicts this. The note also never says that the other pack gives for the house while the pause holds.
L-1
Low
app/ctl_cycle.py:596-603 vs app/ctl_trade.py:745-772
The cap is filed at the maintenance pass as max(whole floor, period cap), and the loop bound (SAFETY-85, line 805) reads that. _m28_share() then lowers it to this pack's part, after the loop. So the bound can overstate this pack by the other pack's part of the floor. No effect measured.
L-2
Low (docs)
DECISIONS:19-27
The "Numbers" table's "MAINT-28" column is f3f9483 without the lag (A 2500 W). Label it as such.
Requested changes (dev round)
H-1: while the pause holds, limit what the site gives to what the guard needs. Suggestion: a site limit hi = the guard's floor (the period's largest, as for the pack) while held, so no pack gives for the house beyond the peak. Test, through cycle() with the lag: the live-try shape above (A 11 % CHARGE, B 25 % Cheap hours, 90 min at 6.5 kW). Every quarter at or under aim + 10 W. B gives at most the guard's part (base: 1.0 kWh, B >= 19.8 %). Must fail on 07d23d2. Report give/idle switches again (an earlier full cap gave 32 in 40 min). This changes B's behaviour back towards base; the owner's "other packs unchanged" did not cover a pack that now gives its full rating, so the manager should put it to the owner.
M-1: a scenario in test_maint28b that is red when ctl_cycle passes settled=True. For example, the 45-min rig shape, or whatever shape shows stale releases at this head with the wiring removed.
M-2 and L-2: rewrite DECISIONS line 17 for the capped state and the site limit, and label the old table.
What is fine
Numbers reproduce: 45-min rig shape (A 49 %), B in CHARGE at 18.2 %, A at 18.2 %, live-shape night, on all three trees. My values match the dev's to the 0.01 kWh.
F-1 fix: 0 stale releases. While held and settled, the estimate was at most 98-143 W under the real house (0 cycles over 300 W). With a 5 s and 7 s old meter it still holds (0 releases; f3f9483 100/100 and 23/23 stale).
The pause ends 3 s after the load, and the charge is back after 4 s (7 s and 9 s with the older meter). That costs under 5 Wh per peak; fine as is.
Cap safety: the part is the floor minus the other units' placement, so the cap never takes power from the guard. With A held, the guard's floor is on the able packs, or 0 W if none is able (decide() floor-limited). resolve() and pack_weights have no side effects; no tick is passed, so no licence to write. Per-unit state and the period reset are correct, and arbitrate() allows one slot.
N=1, modes off and drain: test_maint28 digests are unchanged.
Mutants: no cap (7 checks red), whole floor (1), site-wide key (1), held 0 W (dropped in either place or both: 1-2), present part (green in 28b, red in test_maint28 at 5960 W): all red. test_maint28b on f3f9483: 10 checks red.
Pins: test_safety84 n2_charge_banked differs in 192 of 480 rows, from t = 1440 s. The test now also asserts the banked quarters under the aim, so it is stricter. test_packshare part 10 requires every resolve( call to pass weights=self.pack_weights, which is the same guarantee.
Both packs able, B Solar first, 12 kW house, 45 min: A gives 1.94 kWh (1b5f7c9 2.56, f3f9483 3.72). That is within "ok max". The quarters sit about 1 kW under the aim because B gives for the house; H-1's site limit would change that too.
Local suite on 07d23d2 (checks image, --network none, hwsim at the infra pin, GOODWE_REQUIRE_HWSIM=1, CI_JOBS=10): 108 files, 1 failed, 1487 s. The failure was test_peakguard: 2 checks on the exact claim set of a real two-inverter cycle, while my sims loaded the host. Run alone it passes on 07d23d2 and on f3f9483, and it passed 8 of 8 runs at 8 in parallel (4 each tree). I could not reproduce it, so I cannot say whether base has it. CI 1645 ran it green on this head. No MAINT-28 code is on that fixture's path, which has no maintenance phase. test_maint28, test_maint28b, test_safety84, test_packshare, test_maint24 and test_tariff24_weather (235 s) all pass.
## Review of PR #248 at 07d23d2 (fix-forward round 4): NOT READY
Head `MAINT-28-fix` 07d23d2eedc008c8cb306bc76fbc66757ac6ccb4, base `release/1.0` f3f9483 (ls-remote at start). CI 1645 `checks / unit` and 1646 `infra-pin / fresh` success on 07d23d2 (API).
The F-1 fix works: no release on a stale estimate, flips 148 -> 1, and the cap holds the maintenance pack to its part. But while the pause holds, the **other** pack gives its full rating for the house. In the live-try shape that can set a new month peak, which base and f3f9483 do not.
### Findings
| id | severity | where | what |
|---|---|---|---|
| H-1 | **High** (safety) | `app/ctl_cycle.py:579-603` (the held branch: no site bound while held) | While the pause holds and the guard idles, nothing bounds the site, so the loop gives for the whole house. The cap moves that give off the maintenance pack onto the other pack. **Sim, live-try shape** (A 11 % in maintenance CHARGE, B 25 % Cheap hours, 6.5 kW for 90 min, month peak 6000 W, 10 s battery register): B gives 4.6 kW from the first guard idle and reaches its 10 % floor after 43 min. Then neither pack can give, and the **quarters are 6502 and 6499 W, 500 W over the month peak**. 1b5f7c9: every quarter <= 5916 W, B 25 -> 19.8 %. f3f9483: <= 5888 W, B -> 18.1 %. Rig-shape 45 min (A 49 %, B 30 % Cheap hours): B gives 3.04 kWh, 30 -> 14.3 %, under its 20 % reserve (1b5f7c9 0.20 kWh, 29.0 %; f3f9483 0.32 kWh, 28.4 %; no maintenance at all 0.21 kWh). Cause: the original MAINT-28 hold drops the maintenance shaping claim, so no site charge ceiling is filed (SAFETY-84 `_charge_ceiling`, line 706). The F-1 fix makes the hold stick, so this now shows. The cap then puts all of it on the other pack. |
| M-1 | Medium (test) | `test_maint28b.py:199-202`; `app/ctl_cycle.py:581` | No scenario grades the F-1 wiring. Mutant `shaping_held(..., house, True)` at ctl_cycle.py:581: **test_maint28b all checks pass** (13-min load: flips 1, releases 0). In the 45-min rig-shape sim, the same mutant gives back F-1 in full: 148 flips, 83 of 83 releases on a stale estimate. The dev's freshness mutant is red only through the direct `_batts_settled()` unit checks. |
| M-2 | Medium (docs) | `DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md:17` | Still says "while the pause is held the loop works as at 02eac3b, so A also gives for the house ... so the steady give is kept". The cap contradicts this. The note also never says that the other pack gives for the house while the pause holds. |
| L-1 | Low | `app/ctl_cycle.py:596-603` vs `app/ctl_trade.py:745-772` | The cap is filed at the maintenance pass as max(whole floor, period cap), and the loop bound (SAFETY-85, line 805) reads that. `_m28_share()` then lowers it to this pack's part, after the loop. So the bound can overstate this pack by the other pack's part of the floor. No effect measured. |
| L-2 | Low (docs) | DECISIONS:19-27 | The "Numbers" table's "MAINT-28" column is f3f9483 without the lag (A 2500 W). Label it as such. |
### Requested changes (dev round)
1. H-1: while the pause holds, limit what the site gives to what the guard needs. Suggestion: a site limit `hi` = the guard's floor (the period's largest, as for the pack) while held, so no pack gives for the house beyond the peak. Test, through `cycle()` with the lag: the live-try shape above (A 11 % CHARGE, B 25 % Cheap hours, 90 min at 6.5 kW). Every quarter at or under aim + 10 W. B gives at most the guard's part (base: 1.0 kWh, B >= 19.8 %). Must fail on 07d23d2. Report give/idle switches again (an earlier full cap gave 32 in 40 min). This changes B's behaviour back towards base; the owner's "other packs unchanged" did not cover a pack that now gives its full rating, so the manager should put it to the owner.
2. M-1: a scenario in test_maint28b that is red when ctl_cycle passes `settled=True`. For example, the 45-min rig shape, or whatever shape shows stale releases at this head with the wiring removed.
3. M-2 and L-2: rewrite DECISIONS line 17 for the capped state and the site limit, and label the old table.
### What is fine
- Numbers reproduce: 45-min rig shape (A 49 %), B in CHARGE at 18.2 %, A at 18.2 %, live-shape night, on all three trees. My values match the dev's to the 0.01 kWh.
- F-1 fix: 0 stale releases. While held and settled, the estimate was at most 98-143 W under the real house (0 cycles over 300 W). With a 5 s and 7 s old meter it still holds (0 releases; f3f9483 100/100 and 23/23 stale).
- The pause ends 3 s after the load, and the charge is back after 4 s (7 s and 9 s with the older meter). That costs under 5 Wh per peak; fine as is.
- Cap safety: the part is the floor minus the other units' placement, so the cap never takes power from the guard. With A held, the guard's floor is on the able packs, or 0 W if none is able (`decide()` floor-limited). `resolve()` and `pack_weights` have no side effects; no tick is passed, so no licence to write. Per-unit state and the period reset are correct, and `arbitrate()` allows one slot.
- N=1, modes off and drain: test_maint28 digests are unchanged.
- Mutants: no cap (7 checks red), whole floor (1), site-wide key (1), held 0 W (dropped in either place or both: 1-2), present part (green in 28b, red in test_maint28 at 5960 W): all red. test_maint28b on f3f9483: 10 checks red.
- Pins: test_safety84 `n2_charge_banked` differs in 192 of 480 rows, from t = 1440 s. The test now also asserts the banked quarters under the aim, so it is stricter. test_packshare part 10 requires every `resolve(` call to pass `weights=self.pack_weights`, which is the same guarantee.
- Both packs able, B Solar first, 12 kW house, 45 min: A gives 1.94 kWh (1b5f7c9 2.56, f3f9483 3.72). That is within "ok max". The quarters sit about 1 kW under the aim because B gives for the house; H-1's site limit would change that too.
Local suite on 07d23d2 (checks image, `--network none`, hwsim at the infra pin, `GOODWE_REQUIRE_HWSIM=1`, CI_JOBS=10): **108 files, 1 failed, 1487 s**. The failure was test_peakguard: 2 checks on the exact claim set of a real two-inverter cycle, while my sims loaded the host. Run alone it passes on 07d23d2 and on f3f9483, and it passed 8 of 8 runs at 8 in parallel (4 each tree). I could not reproduce it, so I cannot say whether base has it. CI 1645 ran it green on this head. No MAINT-28 code is on that fixture's path, which has no maintenance phase. test_maint28, test_maint28b, test_safety84, test_packshare, test_maint24 and test_tariff24_weather (235 s) all pass.
release/1.0 reverted #247 (7bc8ad2). The tree is taken as release/1.0 here; the next two commits revert that revert and re-apply this PR's four fix rounds, so no history is rewritten.
Co-Authored-By: Claude <noreply@anthropic.com>
Restores #247's code on this branch after the release/1.0 merge. The fix rounds follow in the next commit.
Co-Authored-By: Claude <noreply@anthropic.com>
The same diff as f3f9483..07d23d2 (cap, settled rule, tests, DECISIONS). The merge took the release/1.0 tree, so the rounds are applied as one patch on top of the restored #247.
Co-Authored-By: Claude <noreply@anthropic.com>
H-1: one site limit while the pause holds (the period's largest floor, never under this cycle's), so the other pack no longer gives for the house. New live-try test (A 11 %, B 25 %, 90 min at 6.5 kW) red on 07d23d2. M-1: a 45-min rig-shape test that goes red when the settled check is forced True. M-2/L-2: DECISIONS. CHANGELOG line. test_safety84 n2_charge_banked re-pinned (quarters 3349 and 3950 W, aim 3950 W).
Co-Authored-By: Claude <noreply@anthropic.com>
Review of round 5, head 79dfcde, against release/1.0 b700c0b: APPROVED, merging.
CI on 79dfcde: run 1661 attempt 2 checks / unit (pull_request) success; run 1662 infra-pin / fresh success. Combined status: success.
Checks
History.git diff 1b5f7c9 07d23d2 and git diff b700c0b 9a76f1a (CHANGELOG.md and config.yaml left out) have the same md5, de85f71d. git diff 1b5f7c9 b700c0b changes only CHANGELOG.md and config.yaml, so nothing from 0.22.0 is lost. b700c0b and 07d23d2 are both ancestors of 79dfcde, so nothing was rewritten.
H-1.ctl_trade.py:778-788 files one site charge limit at P_MAINT_SHAPE. Its hi is the largest floor of the period and is never under this cycle's floor, so it can never contradict the guard's site lo. The only other site lo is the drain, and maintenance.arbitrate() gives the site one slot, so a drain cannot run during a hold. The guard's placement (_peak_on_able) runs before this and is unchanged. The limit is filed only while an M28 claim exists (N>=2, aim above the cap), so N=1, modes off, drain, month start and restart are unchanged. Restart starts from (None, 0).
test_maint28 passes, and its digests are equal.
test_safety84 passes, with 5 of 6 digests equal.
Sim with no maintenance: 79dfcde and 07d23d2 give byte-identical output.
Live try (my run of test_maint28b and of m28f_repro.py, SOC=11, OTHER=25, LOAD=90):
79dfcde: quarters 5357 5897 5911 5895 5905 5900 5502 W; B gives 0.86 kWh; B's lowest SoC 20.6 %; switches A 0, B 1.
07d23d2: 3 checks red; quarters reach 6502 and 6499 W; B gives 2.91 kWh and drops to 10.0 %.
45-min rig shape on 79dfcde: A gives 0.17 kWh, B 0.23 kWh; quarters 5358 5897 5911 5176 4477 W; 0 stale releases; charge back after 4 s.
green in test_maint28b, red in test_maint28 part 1 (quarter 5960 W)
Tests.
test_safety84: only the comment and the n2_charge_banked digest changed. The assertion is still <= 3950 + 1, and the quarters are 3348.9 and 3950.0 W. B no longer gives for the house, so the quarter rises from 3217 W to the aim. The other five digests are equal.
test_packshare and opt11p3_replay.py are unchanged since round 4.
Every test line round 5 changed adds a check. None loosens one.
Docs.
DECISIONS line 17 is fixed (M-2), and the table label is fixed (L-2). The new H-1 paragraph is one paragraph.
CHANGELOG has one plain line under "Not in a release yet", and the 0.22.0 section is untouched.
Leaving out L-1 is sound: _m28_share() needs the loop's claim, which does not exist before the SAFETY-85 bound.
Local runs (goodwe-addon-checks:1, --network none, sim mounted, GOODWE_REQUIRE_HWSIM=1): test_maint28, test_maint28b, test_peakguard, test_safety84 and test_packshare all pass on 79dfcde.
Findings (none block the merge)
L-a (Low, test), test_maint28b.py:229. The M-1 scenario catches the mutant through a single stale release in a fixed-seed sim. Zero tolerance kills the mutant today, but a harmless change to the sim could drop it to 0 and let the mutant survive silently. Follow-up: also pin the call statically (the call passes self._batts_settled()), the way test_packshare pins weights=, or add a short scenario that steps a command up with a stale reading.
L-b (Low, info), ctl_cycle.py:805 vs ctl_trade.py:786. The SAFETY-85 loop bound cannot see the new site limit, because it is filed after the loop (the same root cause as L-1). No effect seen: B switches give/idle once in 90 min, and charge comes back 4 s after the load.
L-c (Low, docs). The DECISIONS note is still about 2000 words. Trimming it was out of scope for this round. Worth doing at the release cut.
**Review of round 5, head 79dfcde, against release/1.0 b700c0b: APPROVED, merging.**
CI on 79dfcde: run 1661 attempt 2 `checks / unit (pull_request)` success; run 1662 `infra-pin / fresh` success. Combined status: success.
**Checks**
1. **History.** `git diff 1b5f7c9 07d23d2` and `git diff b700c0b 9a76f1a` (CHANGELOG.md and config.yaml left out) have the same md5, de85f71d. `git diff 1b5f7c9 b700c0b` changes only CHANGELOG.md and config.yaml, so nothing from 0.22.0 is lost. b700c0b and 07d23d2 are both ancestors of 79dfcde, so nothing was rewritten.
2. **H-1.** `ctl_trade.py:778-788` files one site `charge` limit at `P_MAINT_SHAPE`. Its `hi` is the largest floor of the period and is never under this cycle's floor, so it can never contradict the guard's site `lo`. The only other site `lo` is the drain, and `maintenance.arbitrate()` gives the site one slot, so a drain cannot run during a hold. The guard's placement (`_peak_on_able`) runs before this and is unchanged. The limit is filed only while an M28 claim exists (N>=2, aim above the cap), so N=1, modes off, drain, month start and restart are unchanged. Restart starts from `(None, 0)`.
- test_maint28 passes, and its digests are equal.
- test_safety84 passes, with 5 of 6 digests equal.
- Sim with no maintenance: 79dfcde and 07d23d2 give byte-identical output.
- Live try (my run of test_maint28b and of `m28f_repro.py`, SOC=11, OTHER=25, LOAD=90):
- 79dfcde: quarters 5357 5897 5911 5895 5905 5900 5502 W; B gives 0.86 kWh; B's lowest SoC 20.6 %; switches A 0, B 1.
- 07d23d2: 3 checks red; quarters reach 6502 and 6499 W; B gives 2.91 kWh and drops to 10.0 %.
- 45-min rig shape on 79dfcde: A gives 0.17 kWh, B 0.23 kWh; quarters 5358 5897 5911 5176 4477 W; 0 stale releases; charge back after 4 s.
3. **Mutants** (test_maint28b unless named):
| mutant | result |
|---|---|
| `settled=True` at `ctl_cycle.py:581` | red, 1 check (1 stale release) |
| site limit dropped (`if not site` -> `if True`) | red, 3 checks (same numbers as 07d23d2) |
| period max dropped (`hi = floor`) | green in test_maint28b, red in test_maint28 part 1 (quarter 5960 W) |
4. **Tests.**
- test_safety84: only the comment and the `n2_charge_banked` digest changed. The assertion is still `<= 3950 + 1`, and the quarters are 3348.9 and 3950.0 W. B no longer gives for the house, so the quarter rises from 3217 W to the aim. The other five digests are equal.
- test_packshare and `opt11p3_replay.py` are unchanged since round 4.
- Every test line round 5 changed adds a check. None loosens one.
5. **Docs.**
- DECISIONS line 17 is fixed (M-2), and the table label is fixed (L-2). The new H-1 paragraph is one paragraph.
- CHANGELOG has one plain line under "Not in a release yet", and the 0.22.0 section is untouched.
- Leaving out L-1 is sound: `_m28_share()` needs the loop's claim, which does not exist before the SAFETY-85 bound.
6. **Local runs** (`goodwe-addon-checks:1`, `--network none`, sim mounted, `GOODWE_REQUIRE_HWSIM=1`): test_maint28, test_maint28b, test_peakguard, test_safety84 and test_packshare all pass on 79dfcde.
**Findings (none block the merge)**
- **L-a (Low, test), `test_maint28b.py:229`.** The M-1 scenario catches the mutant through a single stale release in a fixed-seed sim. Zero tolerance kills the mutant today, but a harmless change to the sim could drop it to 0 and let the mutant survive silently. Follow-up: also pin the call statically (the call passes `self._batts_settled()`), the way test_packshare pins `weights=`, or add a short scenario that steps a command up with a stale reading.
- **L-b (Low, info), `ctl_cycle.py:805` vs `ctl_trade.py:786`.** The SAFETY-85 loop bound cannot see the new site limit, because it is filed after the loop (the same root cause as L-1). No effect seen: B switches give/idle once in 90 min, and charge comes back 4 s after the load.
- **L-c (Low, docs).** The DECISIONS note is still about 2000 words. Trimming it was out of scope for this round. Worth doing at the release cut.
admin
merged commit 6c0546beb0 into release/1.02026-11-14 19:16:39 +01:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Round 5 (2026-11-14, head
79dfcde). Branch brought back onto release/1.0 (0.22.0) without a force push: merge of release/1.0, revert of the revert7bc8ad2(#247 is back), then this PR's rounds 1-4 re-applied as one patch.git diff 1b5f7c9 07d23d2equalsgit diff origin/release/1.0 <the re-apply commit>byte for byte, apart from CHANGELOG and config.yaml (0.22.0)._m28_share()also files one site limit (the period's largest floor, never under this cycle's floor;P_MAINT_SHAPE, so the guard's claim is never cut). Packs the guard does not need give 0 W for the house during the hold. Outside the hold nothing is filed.07d23d2(red): quarters 5357 1596 1500 4755 6502 6499 5982 W, B gives 2.91 kWh and goes to 10.0 %. This head: quarters 5357 5897 5911 5895 5905 5900 5502 W (every one under the 5960 W bound), B gives 0.86 kWh and stays at 20.6 % or more (1b5f7c9: 1.0 kWh, 19.8 %), give/idle switches B 1, A 0.settled=Trueat the hold in ctl_cycle.py (one stale release), green without._m28_share()reads the loop's claim and the split, so calling it before the SAFETY-85 loop bound would change its input; no effect was measured.test_safety84n2_charge_bankedre-pinned again (banked quarters 3349 and 3950 W, aim 3950 W, asserted). CHANGELOG: one line under "Not in a release yet".Earlier rounds:
MAINT-28 fix-forward after the rig FAIL (TEST-23 on
f3f9483: F-1, F-2). Owner go 2026-11-13 (fallback: revert MAINT-28 if this has not passed the rig by 2026-11-17).F-1. The pause ended at almost every guard step:
grid + site_batt_wadded a new meter reading to a battery reading up to 10 s old, so after a step charge -> give the estimate fell by the step, under the release line. Now the pause ends only when no battery reading may pre-date a step up of its command (Controller._batts_settled(): step = 100 W more give or less charge; settled once a changed reading arrived 6 s after it, or after 17 s). Sim (battery register held 10 s), 45 min, house 6500 W: 83 of 83 releases on a stale estimate -> 0; A flips 148 -> 1 (1b5f7c9: 210).F-2 and the cap. Owner 2026-11-13 ("1" = cap; then "ok max"): while the pause holds, the pack in maintenance CHARGE gives at most the largest floor the peak guard claimed in the current quarter. Built as
Controller._m28_share(): the pack's unit limit is the largest part of the guard's floor placed on it in this 15-minute period (the floor less what the other units are placed at in this cycle's split without the limit), 0 W while the guard holds it at its reserve. Kept per unit (_m28_hi), reset each period and when the pause ends. Only one pack can be in maintenance CHARGE (maintenance.arbitrate()).1b5f7c9f3f9483Pins moved by design:
test_safety84n2_charge_banked(192 of 480 rows from t = 1440 s; banked quarters 3349 and 3217 W against the aim 3950 W, asserted).test_packsharepart 10 AST pin: it required exactly oneresolve(call in the controller;_m28_share()adds a second, so it now requires every call to passweights=self.pack_weights.test_maint28unchanged (part 1 quarter 5925 W against the aim 5950 W).No cut-back delayed or weakened. No option, no new user text. Peak aim, month peak, reserve values and MAINT-13 numbers unchanged. N=1, drain, modes off, SAFETY-63/125/128 unchanged (test_maint28 digests equal). Sim:
Lag.batt_period_s(default None, no digest moves). Tests:test_maint28b.py(fails onf3f9483). Mutants: DECISIONS.CHANGELOG: MAINT-28 fix-forward: the maintenance charge pause no longer ends on a house estimate that mixes a new meter reading with an old battery reading, and while the pause holds the maintenance pack gives at most the largest part of the peak guard's claim placed on it in the quarter.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Owner 2026-11-13 ("ok max", relayed by the manager): while the pause holds, the pack in maintenance CHARGE gives at most the largest floor the guard claimed in the quarter. The cap is now that pack's own part of the floor (the floor less what the other units are placed at, split without the cap), the largest in the 15-minute period, kept per unit (`_m28_hi`). 0 W while the guard holds it at its reserve. test_maint28b: SoC check stated against the owner-accepted give (0.70 point, measured 0.64); both-packs-able tests (B on Cheap hours, B on Solar first); per-unit state and the one-slot arbitrate. test_maint28 unchanged (5925 W). Pins: test_safety84 n2_charge_banked, test_packshare's one-resolve AST pin (a second call with the same weights). Co-Authored-By: Claude <noreply@anthropic.com>Review of PR #248 at
07d23d2(fix-forward round 4): NOT READYHead
MAINT-28-fix07d23d2eed, baserelease/1.0f3f9483(ls-remote at start). CI 1645checks / unitand 1646infra-pin / freshsuccess on07d23d2(API).The F-1 fix works: no release on a stale estimate, flips 148 -> 1, and the cap holds the maintenance pack to its part. But while the pause holds, the other pack gives its full rating for the house. In the live-try shape that can set a new month peak, which base and
f3f9483do not.Findings
app/ctl_cycle.py:579-603(the held branch: no site bound while held)1b5f7c9: every quarter <= 5916 W, B 25 -> 19.8 %.f3f9483: <= 5888 W, B -> 18.1 %. Rig-shape 45 min (A 49 %, B 30 % Cheap hours): B gives 3.04 kWh, 30 -> 14.3 %, under its 20 % reserve (1b5f7c90.20 kWh, 29.0 %;f3f94830.32 kWh, 28.4 %; no maintenance at all 0.21 kWh). Cause: the original MAINT-28 hold drops the maintenance shaping claim, so no site charge ceiling is filed (SAFETY-84_charge_ceiling, line 706). The F-1 fix makes the hold stick, so this now shows. The cap then puts all of it on the other pack.test_maint28b.py:199-202;app/ctl_cycle.py:581shaping_held(..., house, True)at ctl_cycle.py:581: test_maint28b all checks pass (13-min load: flips 1, releases 0). In the 45-min rig-shape sim, the same mutant gives back F-1 in full: 148 flips, 83 of 83 releases on a stale estimate. The dev's freshness mutant is red only through the direct_batts_settled()unit checks.DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md:1702eac3b, so A also gives for the house ... so the steady give is kept". The cap contradicts this. The note also never says that the other pack gives for the house while the pause holds.app/ctl_cycle.py:596-603vsapp/ctl_trade.py:745-772_m28_share()then lowers it to this pack's part, after the loop. So the bound can overstate this pack by the other pack's part of the floor. No effect measured.f3f9483without the lag (A 2500 W). Label it as such.Requested changes (dev round)
hi= the guard's floor (the period's largest, as for the pack) while held, so no pack gives for the house beyond the peak. Test, throughcycle()with the lag: the live-try shape above (A 11 % CHARGE, B 25 % Cheap hours, 90 min at 6.5 kW). Every quarter at or under aim + 10 W. B gives at most the guard's part (base: 1.0 kWh, B >= 19.8 %). Must fail on07d23d2. Report give/idle switches again (an earlier full cap gave 32 in 40 min). This changes B's behaviour back towards base; the owner's "other packs unchanged" did not cover a pack that now gives its full rating, so the manager should put it to the owner.settled=True. For example, the 45-min rig shape, or whatever shape shows stale releases at this head with the wiring removed.What is fine
f3f9483100/100 and 23/23 stale).decide()floor-limited).resolve()andpack_weightshave no side effects; no tick is passed, so no licence to write. Per-unit state and the period reset are correct, andarbitrate()allows one slot.f3f9483: 10 checks red.n2_charge_bankeddiffers in 192 of 480 rows, from t = 1440 s. The test now also asserts the banked quarters under the aim, so it is stricter. test_packshare part 10 requires everyresolve(call to passweights=self.pack_weights, which is the same guarantee.1b5f7c92.56,f3f94833.72). That is within "ok max". The quarters sit about 1 kW under the aim because B gives for the house; H-1's site limit would change that too.Local suite on
07d23d2(checks image,--network none, hwsim at the infra pin,GOODWE_REQUIRE_HWSIM=1, CI_JOBS=10): 108 files, 1 failed, 1487 s. The failure was test_peakguard: 2 checks on the exact claim set of a real two-inverter cycle, while my sims loaded the host. Run alone it passes on07d23d2and onf3f9483, and it passed 8 of 8 runs at 8 in parallel (4 each tree). I could not reproduce it, so I cannot say whether base has it. CI 1645 ran it green on this head. No MAINT-28 code is on that fixture's path, which has no maintenance phase. test_maint28, test_maint28b, test_safety84, test_packshare, test_maint24 and test_tariff24_weather (235 s) all pass.Review of round 5, head
79dfcde, against release/1.0b700c0b: APPROVED, merging.CI on
79dfcde: run 1661 attempt 2checks / unit (pull_request)success; run 1662infra-pin / freshsuccess. Combined status: success.Checks
git diff 1b5f7c9 07d23d2andgit diff b700c0b 9a76f1a(CHANGELOG.md and config.yaml left out) have the same md5, de85f71d.git diff 1b5f7c9 b700c0bchanges only CHANGELOG.md and config.yaml, so nothing from 0.22.0 is lost.b700c0band07d23d2are both ancestors of79dfcde, so nothing was rewritten.ctl_trade.py:778-788files one sitechargelimit atP_MAINT_SHAPE. Itshiis the largest floor of the period and is never under this cycle's floor, so it can never contradict the guard's sitelo. The only other sitelois the drain, andmaintenance.arbitrate()gives the site one slot, so a drain cannot run during a hold. The guard's placement (_peak_on_able) runs before this and is unchanged. The limit is filed only while an M28 claim exists (N>=2, aim above the cap), so N=1, modes off, drain, month start and restart are unchanged. Restart starts from(None, 0).79dfcdeand07d23d2give byte-identical output.m28f_repro.py, SOC=11, OTHER=25, LOAD=90):79dfcde: quarters 5357 5897 5911 5895 5905 5900 5502 W; B gives 0.86 kWh; B's lowest SoC 20.6 %; switches A 0, B 1.07d23d2: 3 checks red; quarters reach 6502 and 6499 W; B gives 2.91 kWh and drops to 10.0 %.79dfcde: A gives 0.17 kWh, B 0.23 kWh; quarters 5358 5897 5911 5176 4477 W; 0 stale releases; charge back after 4 s.settled=Trueatctl_cycle.py:581if not site->if True)07d23d2)hi = floor)n2_charge_bankeddigest changed. The assertion is still<= 3950 + 1, and the quarters are 3348.9 and 3950.0 W. B no longer gives for the house, so the quarter rises from 3217 W to the aim. The other five digests are equal.opt11p3_replay.pyare unchanged since round 4._m28_share()needs the loop's claim, which does not exist before the SAFETY-85 bound.goodwe-addon-checks:1,--network none, sim mounted,GOODWE_REQUIRE_HWSIM=1): test_maint28, test_maint28b, test_peakguard, test_safety84 and test_packshare all pass on79dfcde.Findings (none block the merge)
test_maint28b.py:229. The M-1 scenario catches the mutant through a single stale release in a fixed-seed sim. Zero tolerance kills the mutant today, but a harmless change to the sim could drop it to 0 and let the mutant survive silently. Follow-up: also pin the call statically (the call passesself._batts_settled()), the way test_packshare pinsweights=, or add a short scenario that steps a command up with a stale reading.ctl_cycle.py:805vsctl_trade.py:786. The SAFETY-85 loop bound cannot see the new site limit, because it is filed after the loop (the same root cause as L-1). No effect seen: B switches give/idle once in 90 min, and charge comes back 4 s after the load.