OPT-11 phase P1 (design goodwe-session/OPT-11-design.md §6 P1; the owner said "go" on 2026-09-28). Base: release/1.0 a5dddad.
What it does
Mode claim source mode (unit scope, P_MODE = 35). UnitBounds gains mode_lo, mode_hi and mode_priority. pre_resolve() keeps them apart from the hard limits. Resolution.deciding_priority is the higher of the winning set and the limit that bound S. allocator.unit_interval() applies the rule: hard ∩ mode when deciding ≤ 35, hard only above that. At N=1, resolve() takes the mode out of stage 2 and clamps the site target by the same rule, so a mode never enters a lo > hi test at any roster.
Contains 0. A mode range without 0 W is dropped and recorded (Allocation.mode_faults, Resolution.mode_fault). main.py logs it once per battery as ERROR MODE_RANGE. compute() also ignores a bound without 0.
Loop bound.compute(bound=) = allocator.loop_bound(): the sum over live units of hard ∩ mode, where a pinned unit adds 0..pin so a SAFETY-73 probe stays reachable. The integrator (before the rail bound, so SAFETY-03 is not tripped) and the command stay inside it, and rounding stays inside it too. SAFETY-73's refusal claims are now filed before the loop so the bound sees them. They are still the last unit claims filed, so each unit's claim order is unchanged.
Weights. While charging, Leftover solar weighs 0 and Solar first keeps its OPT-09 weight (packmode.charge_weights). Giving power keeps the OPT-09 weights. The expensive-run order comes in P3.
Store. Per battery unit/<name>/pack_use and pack_mode (default: on, Solar first). pack_sell is reserved for P3 selling. Two new /action values, pack_use and pack_mode, take unit and value, write to the action log, and are refused with a plain reason when modes are off or the input is bad. Off is [0, 0]. Off stops a running maintenance cycle with a log line and is left out of the maintenance slot. A battery in any maintenance phase gets no mode claim.
Minimal Batteries cards. An On/Off switch, a Solar first / Leftover solar choice and one help line, in EN and NL (app/ui_text), shown only when mode_key is present in /status. UX-13 P3 replaces them. Option battery_modes_enabled (default false) is in config.yaml, translations and uilayout, with a DOCS section and a DECISIONS entry.
N=1 byte-identity with the feature off. See the evidence below.
test_peakguard part 10f: the claim-source set is updated on purpose to add mode, with the argument in the file. The 10g counts moved from 13 to 14 direct appends and from 8 to 9 unit appends.
Evidence (all run in goodwe-addon-ci:latest, --network none)
Byte-identity:tools/opt11_identity.py runs the real Controller for 540 cycles per scenario: house steps, a solar surplus, a battery reaching its cut-out level, and at N=2 a SAFETY-73 refusal with probes. I ran the same file on a5dddad and on this branch. The digests are equal for all four scenarios: n1/n1_off fc3bca17…, n2/n2_off a60003b1…. test_modes.py part 1 holds the branch to those digests and also spies that no mode claim and no bound appear.
alloc_sim --spill (admin/goodwe rig, read-only), A Solar first and B Leftover solar, 9 kW PV against a 1 kW house:
A has room: A −5000, B −3010, grid +10.
A full: A 0, B −5000, grid −3000.
A full and B off: both 0, grid −8000.
Frozen cycles 0 and unmet 0 in every phase. --all output: see comment below.
Full suite: 47/47 files pass locally; base a5dddad was 46/46.
Page: the served page, rendered in jsdom (not a real browser): the cards show the switch, the choice and the help line; clicking posts pack_use and pack_mode; no JS errors. Phone width and dark mode were not checked.
Mutations (26, each on a purged copy, graded over test_alloc/arbiter/control/modes/peakguard): all caught
#
mutation
red in
M1
release at 35 too (>=)
test_alloc "RELEASE AT 35"
M2
never released
test_alloc "RELEASE AT 50", test_arbiter N=2 no-contradiction
M3
always released
test_alloc, test_arbiter, test_modes Off
M4
contains-0 removed (allocator)
test_alloc "CONTAINS 0", test_arbiter, test_modes part 6
M5
mode joins hard limits in stage 1
test_arbiter N=2 + stage 1, test_modes
M6
N=1 mode enters stage 2
test_arbiter part 10 (peak floor contradiction)
M7
N=1 never released
test_arbiter part 10
M8
N=1 contains-0 removed
test_arbiter part 10
M9
deciding_priority ignores the binding limit
test_arbiter
M10
integrator not mode-bounded
test_control (SAFETY-03 flags on a 12 kW house)
M11
command not mode-bounded
test_control (mode closes mid-run)
M12
rounding steps outside the bound
test_control (1236 → 1230)
M13
compute contains-0 removed
test_control
M14
main passes no bound
test_modes part 3 (no freeze, nothing unmet)
M15
mode filed during maintenance
test_modes part 4
M16
Off does not stop maintenance
test_modes part 4
M17
Off battery still takes the maintenance slot
test_modes part 4
M18
Leftover solar not weighed 0
test_modes part 2
M19
Off does not close the range
test_modes part 3
M20
feature on by default
test_modes part 1, test_peakguard 10f sweeps
M21
fault logged every cycle
test_modes part 6
M22
loop_bound: a pin adds its interval
test_alloc part 13
M23
status rows change with modes off
test_modes part 7
M24
hard limit outranking the mode not handled
test_alloc part 13
M25
actions accepted with modes off
test_modes part 5
M26
carried integrator not re-bounded after the lead cap
test_control
Left out, and why
HA select/switch entities (P2), price runs, grid charging and selling (P3), and the forecast (P4) are later phases.
inverters[].mode (the starting mode as an option) is not built. The store default is Solar first. It is not needed for P1 and would add a roster field.
The arbitrage trade (P_ARBITRAGE 30) stays inside each mode but is still sized without the modes, so watts an Off battery cannot take show as unmet. P3 sizes it.
For the reviewer
Off is a mode range, so the peak guard releases it (design §2–3). An Off battery can still be asked to shave a peak. DOCS says so. This is an owner question: if Off should be absolute, it needs a hard unit limit at N>1 and a different mechanism at N=1.
When a mode closes, the loop command drops to the bound on the same cycle with no slew. The allocator would place the same watts anyway. Look at control.py, the OPT-11 blocks.
SAFETY-73's refusal claims moved above the loop call (main.py). Byte-identity at N=2 covers a refusal with probes.
OPT-11 phase P1 (design `goodwe-session/OPT-11-design.md` §6 P1; the owner said "go" on 2026-09-28). Base: release/1.0 a5dddad.
## What it does
1. **Mode claim source `mode`** (unit scope, `P_MODE` = 35). `UnitBounds` gains `mode_lo`, `mode_hi` and `mode_priority`. `pre_resolve()` keeps them apart from the hard limits. `Resolution.deciding_priority` is the higher of the winning set and the limit that bound S. `allocator.unit_interval()` applies the rule: hard ∩ mode when deciding ≤ 35, hard only above that. At N=1, `resolve()` takes the mode out of stage 2 and clamps the site target by the same rule, so a mode never enters a `lo > hi` test at any roster.
2. **Contains 0.** A mode range without 0 W is dropped and recorded (`Allocation.mode_faults`, `Resolution.mode_fault`). main.py logs it once per battery as ERROR `MODE_RANGE`. `compute()` also ignores a bound without 0.
3. **Loop bound.** `compute(bound=)` = `allocator.loop_bound()`: the sum over live units of hard ∩ mode, where a pinned unit adds 0..pin so a SAFETY-73 probe stays reachable. The integrator (before the rail bound, so SAFETY-03 is not tripped) and the command stay inside it, and rounding stays inside it too. SAFETY-73's refusal claims are now filed before the loop so the bound sees them. They are still the last unit claims filed, so each unit's claim order is unchanged.
4. **Weights.** While charging, Leftover solar weighs 0 and Solar first keeps its OPT-09 weight (`packmode.charge_weights`). Giving power keeps the OPT-09 weights. The expensive-run order comes in P3.
5. **Store.** Per battery `unit/<name>/pack_use` and `pack_mode` (default: on, Solar first). `pack_sell` is reserved for P3 selling. Two new `/action` values, `pack_use` and `pack_mode`, take `unit` and `value`, write to the action log, and are refused with a plain reason when modes are off or the input is bad. Off is `[0, 0]`. Off stops a running maintenance cycle with a log line and is left out of the maintenance slot. A battery in any maintenance phase gets no mode claim.
6. **Minimal Batteries cards.** An On/Off switch, a Solar first / Leftover solar choice and one help line, in EN and NL (`app/ui_text`), shown only when `mode_key` is present in `/status`. UX-13 P3 replaces them. Option `battery_modes_enabled` (default false) is in config.yaml, translations and uilayout, with a DOCS section and a DECISIONS entry.
7. **N=1 byte-identity with the feature off.** See the evidence below.
**test_peakguard part 10f:** the claim-source set is updated on purpose to add `mode`, with the argument in the file. The 10g counts moved from 13 to 14 direct appends and from 8 to 9 unit appends.
## Evidence (all run in `goodwe-addon-ci:latest`, `--network none`)
- **Byte-identity:** `tools/opt11_identity.py` runs the real Controller for 540 cycles per scenario: house steps, a solar surplus, a battery reaching its cut-out level, and at N=2 a SAFETY-73 refusal with probes. I ran the same file on a5dddad and on this branch. The digests are equal for all four scenarios: `n1`/`n1_off` fc3bca17…, `n2`/`n2_off` a60003b1…. `test_modes.py` part 1 holds the branch to those digests and also spies that no `mode` claim and no bound appear.
- **alloc_sim `--spill`** (admin/goodwe rig, read-only), A Solar first and B Leftover solar, 9 kW PV against a 1 kW house:
- A has room: A −5000, B −3010, grid +10.
- A full: A 0, B −5000, grid −3000.
- A full and B off: both 0, grid −8000.
- Frozen cycles 0 and unmet 0 in every phase. `--all` output: see comment below.
- **Full suite:** 47/47 files pass locally; base a5dddad was 46/46.
- **Page:** the served page, rendered in jsdom (not a real browser): the cards show the switch, the choice and the help line; clicking posts `pack_use` and `pack_mode`; no JS errors. Phone width and dark mode were not checked.
## Mutations (26, each on a purged copy, graded over test_alloc/arbiter/control/modes/peakguard): all caught
| # | mutation | red in |
|---|---|---|
| M1 | release at 35 too (`>=`) | test_alloc "RELEASE AT 35" |
| M2 | never released | test_alloc "RELEASE AT 50", test_arbiter N=2 no-contradiction |
| M3 | always released | test_alloc, test_arbiter, test_modes Off |
| M4 | contains-0 removed (allocator) | test_alloc "CONTAINS 0", test_arbiter, test_modes part 6 |
| M5 | mode joins hard limits in stage 1 | test_arbiter N=2 + stage 1, test_modes |
| M6 | N=1 mode enters stage 2 | test_arbiter part 10 (peak floor contradiction) |
| M7 | N=1 never released | test_arbiter part 10 |
| M8 | N=1 contains-0 removed | test_arbiter part 10 |
| M9 | deciding_priority ignores the binding limit | test_arbiter |
| M10 | integrator not mode-bounded | test_control (SAFETY-03 flags on a 12 kW house) |
| M11 | command not mode-bounded | test_control (mode closes mid-run) |
| M12 | rounding steps outside the bound | test_control (1236 → 1230) |
| M13 | compute contains-0 removed | test_control |
| M14 | main passes no bound | test_modes part 3 (no freeze, nothing unmet) |
| M15 | mode filed during maintenance | test_modes part 4 |
| M16 | Off does not stop maintenance | test_modes part 4 |
| M17 | Off battery still takes the maintenance slot | test_modes part 4 |
| M18 | Leftover solar not weighed 0 | test_modes part 2 |
| M19 | Off does not close the range | test_modes part 3 |
| M20 | feature on by default | test_modes part 1, test_peakguard 10f sweeps |
| M21 | fault logged every cycle | test_modes part 6 |
| M22 | loop_bound: a pin adds its interval | test_alloc part 13 |
| M23 | status rows change with modes off | test_modes part 7 |
| M24 | hard limit outranking the mode not handled | test_alloc part 13 |
| M25 | actions accepted with modes off | test_modes part 5 |
| M26 | carried integrator not re-bounded after the lead cap | test_control |
## Left out, and why
- HA select/switch entities (P2), price runs, grid charging and selling (P3), and the forecast (P4) are later phases.
- `inverters[].mode` (the starting mode as an option) is not built. The store default is Solar first. It is not needed for P1 and would add a roster field.
- The arbitrage trade (P_ARBITRAGE 30) stays inside each mode but is still sized without the modes, so watts an Off battery cannot take show as unmet. P3 sizes it.
## For the reviewer
- **Off is a mode range, so the peak guard releases it** (design §2–3). An Off battery can still be asked to shave a peak. DOCS says so. This is an owner question: if Off should be absolute, it needs a hard unit limit at N>1 and a different mechanism at N=1.
- When a mode closes, the loop command drops to the bound on the same cycle with no slew. The allocator would place the same watts anyway. Look at `control.py`, the OPT-11 blocks.
- SAFETY-73's refusal claims moved above the loop call (main.py). Byte-identity at N=2 covers a refusal with probes.
- Nothing was tested on TEST-23.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
A battery's mode is a soft unit limit (source `mode`, P_MODE 35) kept
apart from the hard limits in pre_resolve(). The allocator applies it
only while the site target is decided at or below 35 (new
Resolution.deciding_priority), so the peak floor releases it and a mode
`hi` never meets the site `lo`. At N=1 resolve() applies the same rule
to the site target after stage 2. A mode range without 0 W is dropped
as a software fault, so REFUSE_SIGN stays unreachable from a mode.
compute() gets the loop bound (sum over live units of hard limits with
modes applied), so the loop never asks for watts no battery may give
and the saturation freeze never parks on them. While charging, Leftover
solar weighs 0 so Solar first fills first. Store keys pack_use and
pack_mode per battery, set by two new /action values; Off stops and
skips maintenance with a log line. Minimal Batteries cards (EN/NL).
Off by default (battery_modes_enabled); off, commands are byte-identical
to a5dddad at N=1 and N=2 (test_modes.py part 1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/alloc_sim.py --all on d69fd8f (rig admin/goodwe mounted read-only):
rig: /goodwe/sim
add-on under test: /r
⚠️ inverter B's rating/ramp/lag are PLACEHOLDERS (plantsim.INVERTER_B, measured=False) - SPIKE-04 measures them
=== AC 3 / the ARCH-01 experiment: broadcast vs allocation ===
run tail p2p worst export commands
gain 0.6 broadcast 0 -400 [1000, 1000, 1000, 1000]
gain 0.6 even_split 0 30 [1970, 1970, 1970, 1970]
gain 0.6 alloc01:50/50 0 30 [1970, 1970, 1970, 1970]
gain 0.6 alloc01:90/10 0 10 [1990, 1990, 1990, 1990]
gain 0.6 alloc01:10/90 0 80 [1920, 1920, 1920, 1920]
gain 0.6 alloc01:99/1 0 10 [1990, 1990, 1990, 1990]
gain 0.6 alloc01:1/0 0 10 [1990, 1990, 1990, 1990]
gain 1.0 broadcast 4000 -2000 [1000, 2000, 1000, 0]
gain 1.0 even_split 0 0 [2000, 2000, 2000, 2000]
gain 1.0 alloc01:50/50 0 0 [2000, 2000, 2000, 2000]
gain 1.0 alloc01:90/10 0 0 [2000, 2000, 2000, 2000]
gain 1.0 alloc01:10/90 0 0 [2000, 2000, 2000, 2000]
gain 1.0 alloc01:99/1 0 0 [2000, 2000, 2000, 2000]
gain 1.0 alloc01:1/0 0 0 [2000, 2000, 2000, 2000]
=== AC 2 / split-invariance at gain 1.0, loop feedback = site ===
alloc01:50/50 reference: grid [1780, 1560, 1340, 1120, 900, 680] ...
last shares [1000, 1000], sum 2000 vs site command 2000
alloc01:90/10 IDENTICAL to alloc01:50/50 worst |delta| 0.000000 W
last shares [1800, 200], sum 2000 vs site command 2000
alloc01:10/90 IDENTICAL to alloc01:50/50 worst |delta| 0.000000 W
last shares [200, 1800], sum 2000 vs site command 2000
alloc01:99/1 IDENTICAL to alloc01:50/50 worst |delta| 0.000000 W
last shares [1980, 20], sum 2000 vs site command 2000
alloc01:1/0 IDENTICAL to alloc01:50/50 worst |delta| 0.000000 W
last shares [2000, 0], sum 2000 vs site command 2000
-> split-invariance HOLDS
=== AC 3 / gain sweep to 1.0 at every split ===
mode 0.05 0.2 0.4 0.6 0.8 0.9 1.0
broadcast 60 0 0 0 40 120 4000 worst 4000 W p2p
alloc01:50/50 0 0 0 0 0 0 0 worst 0 W p2p
alloc01:90/10 190 0 0 0 0 0 0 worst 190 W p2p
alloc01:10/90 0 0 0 0 0 0 0 worst 0 W p2p
alloc01:99/1 190 0 0 0 0 0 0 worst 190 W p2p
alloc01:1/0 190 0 0 0 0 0 0 worst 190 W p2p
(tail peak-to-peak grid swing, W. A limit cycle is a tail that does not go to zero.)
=== OPT-11 P1: surplus solar, A Solar first, B Leftover solar ===
A has room A -5000 W B -3010 W grid +10 W loop bound [-10000, 10000] unmet 0 W frozen cycles 0 (deadband)
A full A +0 W B -5000 W grid -3000 W loop bound [-5000, 10000] unmet 0 W frozen cycles 0 (mode-bound)
A full, B off A +0 W B +0 W grid -8000 W loop bound [0, 5000] unmet 0 W frozen cycles 0 (mode-bound)
-> spill HOLDS: A fills first, the rest spills to B, a full A and an Off B send the surplus to the grid, and the loop never freezes on watts no battery may take
Local suite on the branch: 47/47 test files pass.
`tools/alloc_sim.py --all` on d69fd8f (rig admin/goodwe mounted read-only):
```
rig: /goodwe/sim
add-on under test: /r
⚠️ inverter B's rating/ramp/lag are PLACEHOLDERS (plantsim.INVERTER_B, measured=False) - SPIKE-04 measures them
=== AC 3 / the ARCH-01 experiment: broadcast vs allocation ===
run tail p2p worst export commands
gain 0.6 broadcast 0 -400 [1000, 1000, 1000, 1000]
gain 0.6 even_split 0 30 [1970, 1970, 1970, 1970]
gain 0.6 alloc01:50/50 0 30 [1970, 1970, 1970, 1970]
gain 0.6 alloc01:90/10 0 10 [1990, 1990, 1990, 1990]
gain 0.6 alloc01:10/90 0 80 [1920, 1920, 1920, 1920]
gain 0.6 alloc01:99/1 0 10 [1990, 1990, 1990, 1990]
gain 0.6 alloc01:1/0 0 10 [1990, 1990, 1990, 1990]
gain 1.0 broadcast 4000 -2000 [1000, 2000, 1000, 0]
gain 1.0 even_split 0 0 [2000, 2000, 2000, 2000]
gain 1.0 alloc01:50/50 0 0 [2000, 2000, 2000, 2000]
gain 1.0 alloc01:90/10 0 0 [2000, 2000, 2000, 2000]
gain 1.0 alloc01:10/90 0 0 [2000, 2000, 2000, 2000]
gain 1.0 alloc01:99/1 0 0 [2000, 2000, 2000, 2000]
gain 1.0 alloc01:1/0 0 0 [2000, 2000, 2000, 2000]
=== AC 2 / split-invariance at gain 1.0, loop feedback = site ===
alloc01:50/50 reference: grid [1780, 1560, 1340, 1120, 900, 680] ...
last shares [1000, 1000], sum 2000 vs site command 2000
alloc01:90/10 IDENTICAL to alloc01:50/50 worst |delta| 0.000000 W
last shares [1800, 200], sum 2000 vs site command 2000
alloc01:10/90 IDENTICAL to alloc01:50/50 worst |delta| 0.000000 W
last shares [200, 1800], sum 2000 vs site command 2000
alloc01:99/1 IDENTICAL to alloc01:50/50 worst |delta| 0.000000 W
last shares [1980, 20], sum 2000 vs site command 2000
alloc01:1/0 IDENTICAL to alloc01:50/50 worst |delta| 0.000000 W
last shares [2000, 0], sum 2000 vs site command 2000
-> split-invariance HOLDS
=== AC 3 / gain sweep to 1.0 at every split ===
mode 0.05 0.2 0.4 0.6 0.8 0.9 1.0
broadcast 60 0 0 0 40 120 4000 worst 4000 W p2p
alloc01:50/50 0 0 0 0 0 0 0 worst 0 W p2p
alloc01:90/10 190 0 0 0 0 0 0 worst 190 W p2p
alloc01:10/90 0 0 0 0 0 0 0 worst 0 W p2p
alloc01:99/1 190 0 0 0 0 0 0 worst 190 W p2p
alloc01:1/0 190 0 0 0 0 0 0 worst 190 W p2p
(tail peak-to-peak grid swing, W. A limit cycle is a tail that does not go to zero.)
=== OPT-11 P1: surplus solar, A Solar first, B Leftover solar ===
A has room A -5000 W B -3010 W grid +10 W loop bound [-10000, 10000] unmet 0 W frozen cycles 0 (deadband)
A full A +0 W B -5000 W grid -3000 W loop bound [-5000, 10000] unmet 0 W frozen cycles 0 (mode-bound)
A full, B off A +0 W B +0 W grid -8000 W loop bound [0, 5000] unmet 0 W frozen cycles 0 (mode-bound)
-> spill HOLDS: A fills first, the rest spills to B, a full A and an Off B send the surplus to the grid, and the loop never freezes on watts no battery may take
```
Local suite on the branch: 47/47 test files pass.
- arbiter: a mode now gives way only as far as a site limit above P_MODE
needs (_unit_modes at N>1, _site_mode at N=1). Before, the modes were
released only when the peak floor bound S, so a trade won above the floor
kept them and placed less than the floor (an Off battery at N=1 went to 0 W
under a 2000 W floor), and a capped grid charge released an Off battery.
While released, held batteries weigh 0 and carry only the shortfall.
- control: a side of the loop bound that reaches the rail is left to the
rail, so SAFETY-03's i_clamped still fires with every battery on.
- opt11_identity: add maintenance scenarios and a 6000-case arbiter digest
(peak floor, trades, maintenance, refusals); a5dddad digests pinned.
- tests: test_arbiter part 11, test_control (g), test_modes (0/1 refused,
loop bound counts live units only). DOCS and DECISIONS say the new rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
Review of PR #149 (OPT-11 P1): findings, and fixes pushed as ffc9a4d
Blocker, now fixed: a mode could beat the peak guard. The release rule only released the modes when the peak floor bound S. When a trade (P_ARBITRAGE 30) won stage 2 above the floor, deciding_priority stayed at 30, the modes held, and the units placed less than the floor:
N=2: trade +3000 W, peak floor 2000 W, A rated 1500 W, B Off: placed 1500 W (unmet 1500). The peak floor was missed.
N=1: same trade, battery Off: the target was 0 W under a 2000 W floor.
The other way round, a grid charge (trade −3000 W) capped by the peak limit at −1000 W released every mode, so the Off battery was charged from the grid. A floor that A alone could carry also used Off B.
The sweep in test_arbiter part 9 missed this because it grades target_w against the floor and not the placed sum, and it has no trade.
Fix (arbiter _unit_modes at N>1, _site_mode at N=1): a SET above P_MODE still decides alone. Otherwise the target is clamped into what the modes can place (loop_bound) and then back into the site limits above P_MODE. If that point is placeable with the modes, they hold. If not, the target moves only as far as the limit needs and the modes give way. While they give way, held batteries weigh 0, so an Off battery carries only the shortfall. Results: A 1500 / B 500; N=1 Off gives 2000 W; the capped charge goes −1000 W on A and 0 W on B; with every battery Off the floor is split evenly. A 20000-case fuzz is now in test_arbiter part 11. Before the fix it found 1198 cycles under the floor that the same cycle without modes met, plus 778 needless Off uses. After the fix it finds 0 of each, and REFUSE_SIGN is never caused by a mode.
Point 3, SAFETY-03 masked, now fixed. The mode clamp ran before the rail bound. With every battery on, the bound equals the rail, so i_clamped could never fire on any install with modes on. The dev's sweep (f) used ±9000 W, wider than the rail, so it never saw this. Now a side of the bound that reaches the rail is left to the rail. A bound equal to the rail changes no Decision over 8000 random cycles (1420 of them wind into the rail), including with integrator_max_w above max_w (test_control (g)). A bound tighter than the rail (a battery Off, refused or empty) still reads mode-bound: the rail is never reached there by design. No wind-up and no freeze: the dev's (a) to (c) still pass.
Point 1, identity: equal, but the harness did not reach maintenance or the peak guard. I re-ran it on a5dddad and on d69fd8f: n1/n1_off fc3bca17…, n2/n2_off a60003b1…, equal. The runs never reach maintenance, and the peak guard needs a P1 meter the harness does not have. Mutating the new maintenance-slot code left every digest unchanged. I added n1_maint, n2_maint and a 6000-case arbiter digest (resolve: peak floor, trades, maintenance pins and shaping, refusal limits and probes). They are equal on a5dddad and on ffc9a4d (5a21d43e…, 48a1ad96…, 43a87421…) and pinned in test_modes part 1. Non-vacuity: with modes off, each of four mutants moves a digest: refusal claims not filed (n2), maintenance slot order (n2_maint), unit interval +1 W (n2, resolve), bound applied with modes off (n2).
Point 4, risk (b), the unslewed drop: keep it, no slew. The clamp moves the command toward 0 and never across it, because the bound holds 0. So |command| only shrinks, with no sign change, no circulation and no push through the operating point. The SAFETY-46 overshoot (~0.37 × rating) comes from a new claim driving through the loop's point at a handover, which this cannot do. The allocator drops the Off battery's share on the same tick anyway, so a slewed site command would only be unmet watts that park the freeze, which is the failure the bound removes. The cost is a step in import (or in export when charging) equal to what the closed battery gave. That follows an operator action and is bounded by that battery's rating.
Point 5, risk (c), the SAFETY-73 claim move: unchanged. The refusal claims are unit claims, and the loop claim is a site claim. resolve partitions by actuator, and why_key reads only loop/safety/failsafe, so the order within each unit is unchanged. test_refusal_units passes (30 s). The mutant "refusal claim never filed" is caught there. With modes off, the n2 digest (which includes refusal probes) is equal.
Point 6, maintenance and Off: OK. Off stops a running cycle and logs ABORT (battery switched off). A battery in any phase gets no mode claim. Off mid-drain, then On 30 s later: the battery follows its mode again, and its cycle waits its turn (last_completed is untouched, so it reruns). It does not get stuck.
Point 7, mutations. From the dev's 26 I ran M1, M4, M6, M10, M14, M16, M18 and M26: all caught. I also ran 10 of my own, and all are caught now. Two survived at first: pack_use accepting 0/1 (a stored 0 reads back as On) and a loop bound counting stale units. test_modes now covers both.
Point 8, UI, store and records: OK./action checks unit against the roster, pack_use needs a real bool and pack_mode needs one of MODES. Nothing else reaches the store. On Supervisor 2026.08.0 (its own AppOptions): the manifest, the shipped options, true, and an old blob merged with defaults are all accepted. The UI shows a boolean switch. test_ui_text, test_messages and test_translations pass. DOCS and DECISIONS are updated to the new rule ("only for what the other batteries cannot cover").
Full suite on ffc9a4d, locally: 46/47 pass. test_freeze needs the sibling sim repo, which CI mounts.
Owner question (not resolved in code): does Off mean 0 W even when the peak limit needs that battery? My recommendation will be in the report to the manager.
## Review of PR #149 (OPT-11 P1): findings, and fixes pushed as ffc9a4d
**Blocker, now fixed: a mode could beat the peak guard.** The release rule only released the modes when the peak floor *bound* S. When a trade (P_ARBITRAGE 30) won stage 2 above the floor, `deciding_priority` stayed at 30, the modes held, and the units placed less than the floor:
- N=2: trade +3000 W, peak floor 2000 W, A rated 1500 W, B Off: placed 1500 W (unmet 1500). The peak floor was missed.
- N=1: same trade, battery Off: the target was **0 W** under a 2000 W floor.
- The other way round, a grid charge (trade −3000 W) capped by the peak limit at −1000 W released every mode, so the Off battery was charged from the grid. A floor that A alone could carry also used Off B.
- The sweep in test_arbiter part 9 missed this because it grades `target_w` against the floor and not the placed sum, and it has no trade.
Fix (arbiter `_unit_modes` at N>1, `_site_mode` at N=1): a SET above P_MODE still decides alone. Otherwise the target is clamped into what the modes can place (`loop_bound`) and then back into the site limits above P_MODE. If that point is placeable with the modes, they hold. If not, the target moves only as far as the limit needs and the modes give way. While they give way, held batteries weigh 0, so an Off battery carries only the shortfall. Results: A 1500 / B 500; N=1 Off gives 2000 W; the capped charge goes −1000 W on A and 0 W on B; with every battery Off the floor is split evenly. A 20000-case fuzz is now in test_arbiter part 11. Before the fix it found 1198 cycles under the floor that the same cycle without modes met, plus 778 needless Off uses. After the fix it finds 0 of each, and REFUSE_SIGN is never caused by a mode.
**Point 3, SAFETY-03 masked, now fixed.** The mode clamp ran before the rail bound. With every battery on, the bound *equals* the rail, so `i_clamped` could never fire on any install with modes on. The dev's sweep (f) used ±9000 W, wider than the rail, so it never saw this. Now a side of the bound that reaches the rail is left to the rail. A bound equal to the rail changes no Decision over 8000 random cycles (1420 of them wind into the rail), including with `integrator_max_w` above `max_w` (test_control (g)). A bound tighter than the rail (a battery Off, refused or empty) still reads `mode-bound`: the rail is never reached there by design. No wind-up and no freeze: the dev's (a) to (c) still pass.
**Point 1, identity: equal, but the harness did not reach maintenance or the peak guard.** I re-ran it on a5dddad and on d69fd8f: n1/n1_off fc3bca17…, n2/n2_off a60003b1…, equal. The runs never reach maintenance, and the peak guard needs a P1 meter the harness does not have. Mutating the new maintenance-slot code left every digest unchanged. I added `n1_maint`, `n2_maint` and a 6000-case arbiter digest (`resolve`: peak floor, trades, maintenance pins and shaping, refusal limits and probes). They are equal on a5dddad and on ffc9a4d (5a21d43e…, 48a1ad96…, 43a87421…) and pinned in test_modes part 1. Non-vacuity: with modes off, each of four mutants moves a digest: refusal claims not filed (n2), maintenance slot order (n2_maint), unit interval +1 W (n2, resolve), bound applied with modes off (n2).
**Point 4, risk (b), the unslewed drop: keep it, no slew.** The clamp moves the command toward 0 and never across it, because the bound holds 0. So |command| only shrinks, with no sign change, no circulation and no push through the operating point. The SAFETY-46 overshoot (~0.37 × rating) comes from a new claim driving *through* the loop's point at a handover, which this cannot do. The allocator drops the Off battery's share on the same tick anyway, so a slewed site command would only be unmet watts that park the freeze, which is the failure the bound removes. The cost is a step in import (or in export when charging) equal to what the closed battery gave. That follows an operator action and is bounded by that battery's rating.
**Point 5, risk (c), the SAFETY-73 claim move: unchanged.** The refusal claims are unit claims, and the loop claim is a site claim. `resolve` partitions by actuator, and `why_key` reads only `loop`/`safety`/`failsafe`, so the order within each unit is unchanged. test_refusal_units passes (30 s). The mutant "refusal claim never filed" is caught there. With modes off, the n2 digest (which includes refusal probes) is equal.
**Point 6, maintenance and Off: OK.** Off stops a running cycle and logs `ABORT (battery switched off)`. A battery in any phase gets no mode claim. Off mid-drain, then On 30 s later: the battery follows its mode again, and its cycle waits its turn (`last_completed` is untouched, so it reruns). It does not get stuck.
**Point 7, mutations.** From the dev's 26 I ran M1, M4, M6, M10, M14, M16, M18 and M26: all caught. I also ran 10 of my own, and all are caught now. Two survived at first: `pack_use` accepting 0/1 (a stored 0 reads back as On) and a loop bound counting stale units. test_modes now covers both.
**Point 8, UI, store and records: OK.** `/action` checks `unit` against the roster, `pack_use` needs a real bool and `pack_mode` needs one of `MODES`. Nothing else reaches the store. On Supervisor 2026.08.0 (its own `AppOptions`): the manifest, the shipped options, `true`, and an old blob merged with defaults are all accepted. The UI shows a boolean switch. test_ui_text, test_messages and test_translations pass. DOCS and DECISIONS are updated to the new rule ("only for what the other batteries cannot cover").
Full suite on ffc9a4d, locally: 46/47 pass. test_freeze needs the sibling sim repo, which CI mounts.
**Owner question (not resolved in code):** does Off mean 0 W even when the peak limit needs that battery? My recommendation will be in the report to the manager.
Owner question (risk a): does "Off" mean 0 W even when the peak limit needs the battery?
This is not resolved in code. It is analysed here for the owner.
Option 1: Off gives way to the peak floor (the design, as fixed in ffc9a4d).
The Off battery gives only what the other batteries cannot cover of the peak floor. It gives nothing when they can, and it is never charged because of the peak limit.
The peak floor is never missed because of a mode.
The battery can still be drawn down to the peak SoC floor and its own cut-out level. The page says so: "unless the peak limit needs it".
Risk: an owner who switched a battery off because something is wrong with it may be surprised. A battery that is really faulty is still closed by the hard limits (refusal, empty pack, stale link), not by its mode.
Option 2: hard Off (always 0 W).
At N>1 it must be a hard unit limit. The peak guard then files a floor the site cannot meet, and the peak is exceeded by up to that battery's rating for the rest of the quarter. That is a billed capacity-tariff peak: a cost, not an equipment hazard.
At N=1 it cannot be a site limit. lo = floor > hi = 0 is SAFETY-25 §0a's contradiction, and the whole site stops at 0 W. It would need the peak guard's achievable_w reduced so that the guard reports itself saturated.
It is more code and more tests (peak guard sizing per battery). It is safe only if the peak guard knows about it.
Recommendation: ship Option 1 as the default. It is safe and documented in DOCS and DECISIONS. If the owner wants a hard Off, it should be a separate, explicit choice ("Off, also for the peak limit"). It would be built with peak-guard sizing that leaves that battery out (a follow-up card), not by changing what Off means under the current guard.
## Owner question (risk a): does "Off" mean 0 W even when the peak limit needs the battery?
This is not resolved in code. It is analysed here for the owner.
**Option 1: Off gives way to the peak floor (the design, as fixed in ffc9a4d).**
- The Off battery gives only what the other batteries cannot cover of the peak floor. It gives nothing when they can, and it is never charged because of the peak limit.
- The peak floor is never missed because of a mode.
- The battery can still be drawn down to the peak SoC floor and its own cut-out level. The page says so: "unless the peak limit needs it".
- Risk: an owner who switched a battery off because something is wrong with it may be surprised. A battery that is really faulty is still closed by the hard limits (refusal, empty pack, stale link), not by its mode.
**Option 2: hard Off (always 0 W).**
- At N>1 it must be a hard unit limit. The peak guard then files a floor the site cannot meet, and the peak is exceeded by up to that battery's rating for the rest of the quarter. That is a billed capacity-tariff peak: a cost, not an equipment hazard.
- At N=1 it cannot be a site limit. `lo` = floor > `hi` = 0 is SAFETY-25 §0a's contradiction, and the whole site stops at 0 W. It would need the peak guard's `achievable_w` reduced so that the guard reports itself saturated.
- It is more code and more tests (peak guard sizing per battery). It is safe only if the peak guard knows about it.
**Recommendation:** ship Option 1 as the default. It is safe and documented in DOCS and DECISIONS. If the owner wants a hard Off, it should be a separate, explicit choice ("Off, also for the peak limit"). It would be built with peak-guard sizing that leaves that battery out (a follow-up card), not by changing what Off means under the current guard.
admin
merged commit ed739d4cdb into release/1.02026-09-28 19:53:23 +02: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.
OPT-11 phase P1 (design
goodwe-session/OPT-11-design.md§6 P1; the owner said "go" on 2026-09-28). Base: release/1.0a5dddad.What it does
mode(unit scope,P_MODE= 35).UnitBoundsgainsmode_lo,mode_hiandmode_priority.pre_resolve()keeps them apart from the hard limits.Resolution.deciding_priorityis the higher of the winning set and the limit that bound S.allocator.unit_interval()applies the rule: hard ∩ mode when deciding ≤ 35, hard only above that. At N=1,resolve()takes the mode out of stage 2 and clamps the site target by the same rule, so a mode never enters alo > hitest at any roster.Allocation.mode_faults,Resolution.mode_fault). main.py logs it once per battery as ERRORMODE_RANGE.compute()also ignores a bound without 0.compute(bound=)=allocator.loop_bound(): the sum over live units of hard ∩ mode, where a pinned unit adds 0..pin so a SAFETY-73 probe stays reachable. The integrator (before the rail bound, so SAFETY-03 is not tripped) and the command stay inside it, and rounding stays inside it too. SAFETY-73's refusal claims are now filed before the loop so the bound sees them. They are still the last unit claims filed, so each unit's claim order is unchanged.packmode.charge_weights). Giving power keeps the OPT-09 weights. The expensive-run order comes in P3.unit/<name>/pack_useandpack_mode(default: on, Solar first).pack_sellis reserved for P3 selling. Two new/actionvalues,pack_useandpack_mode, takeunitandvalue, write to the action log, and are refused with a plain reason when modes are off or the input is bad. Off is[0, 0]. Off stops a running maintenance cycle with a log line and is left out of the maintenance slot. A battery in any maintenance phase gets no mode claim.app/ui_text), shown only whenmode_keyis present in/status. UX-13 P3 replaces them. Optionbattery_modes_enabled(default false) is in config.yaml, translations and uilayout, with a DOCS section and a DECISIONS entry.test_peakguard part 10f: the claim-source set is updated on purpose to add
mode, with the argument in the file. The 10g counts moved from 13 to 14 direct appends and from 8 to 9 unit appends.Evidence (all run in
goodwe-addon-ci:latest,--network none)tools/opt11_identity.pyruns the real Controller for 540 cycles per scenario: house steps, a solar surplus, a battery reaching its cut-out level, and at N=2 a SAFETY-73 refusal with probes. I ran the same file ona5dddadand on this branch. The digests are equal for all four scenarios:n1/n1_offfc3bca17…,n2/n2_offa60003b1….test_modes.pypart 1 holds the branch to those digests and also spies that nomodeclaim and no bound appear.--spill(admin/goodwe rig, read-only), A Solar first and B Leftover solar, 9 kW PV against a 1 kW house:--alloutput: see comment below.a5dddadwas 46/46.pack_useandpack_mode; no JS errors. Phone width and dark mode were not checked.Mutations (26, each on a purged copy, graded over test_alloc/arbiter/control/modes/peakguard): all caught
>=)Left out, and why
inverters[].mode(the starting mode as an option) is not built. The store default is Solar first. It is not needed for P1 and would add a roster field.For the reviewer
control.py, the OPT-11 blocks.🤖 Generated with Claude Code
https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
tools/alloc_sim.py --allond69fd8f(rig admin/goodwe mounted read-only):Local suite on the branch: 47/47 test files pass.
Review of PR #149 (OPT-11 P1): findings, and fixes pushed as
ffc9a4dBlocker, now fixed: a mode could beat the peak guard. The release rule only released the modes when the peak floor bound S. When a trade (P_ARBITRAGE 30) won stage 2 above the floor,
deciding_prioritystayed at 30, the modes held, and the units placed less than the floor:target_wagainst the floor and not the placed sum, and it has no trade.Fix (arbiter
_unit_modesat N>1,_site_modeat N=1): a SET above P_MODE still decides alone. Otherwise the target is clamped into what the modes can place (loop_bound) and then back into the site limits above P_MODE. If that point is placeable with the modes, they hold. If not, the target moves only as far as the limit needs and the modes give way. While they give way, held batteries weigh 0, so an Off battery carries only the shortfall. Results: A 1500 / B 500; N=1 Off gives 2000 W; the capped charge goes −1000 W on A and 0 W on B; with every battery Off the floor is split evenly. A 20000-case fuzz is now in test_arbiter part 11. Before the fix it found 1198 cycles under the floor that the same cycle without modes met, plus 778 needless Off uses. After the fix it finds 0 of each, and REFUSE_SIGN is never caused by a mode.Point 3, SAFETY-03 masked, now fixed. The mode clamp ran before the rail bound. With every battery on, the bound equals the rail, so
i_clampedcould never fire on any install with modes on. The dev's sweep (f) used ±9000 W, wider than the rail, so it never saw this. Now a side of the bound that reaches the rail is left to the rail. A bound equal to the rail changes no Decision over 8000 random cycles (1420 of them wind into the rail), including withintegrator_max_wabovemax_w(test_control (g)). A bound tighter than the rail (a battery Off, refused or empty) still readsmode-bound: the rail is never reached there by design. No wind-up and no freeze: the dev's (a) to (c) still pass.Point 1, identity: equal, but the harness did not reach maintenance or the peak guard. I re-ran it on
a5dddadand ond69fd8f: n1/n1_off fc3bca17…, n2/n2_off a60003b1…, equal. The runs never reach maintenance, and the peak guard needs a P1 meter the harness does not have. Mutating the new maintenance-slot code left every digest unchanged. I addedn1_maint,n2_maintand a 6000-case arbiter digest (resolve: peak floor, trades, maintenance pins and shaping, refusal limits and probes). They are equal ona5dddadand onffc9a4d(5a21d43e…, 48a1ad96…, 43a87421…) and pinned in test_modes part 1. Non-vacuity: with modes off, each of four mutants moves a digest: refusal claims not filed (n2), maintenance slot order (n2_maint), unit interval +1 W (n2, resolve), bound applied with modes off (n2).Point 4, risk (b), the unslewed drop: keep it, no slew. The clamp moves the command toward 0 and never across it, because the bound holds 0. So |command| only shrinks, with no sign change, no circulation and no push through the operating point. The SAFETY-46 overshoot (~0.37 × rating) comes from a new claim driving through the loop's point at a handover, which this cannot do. The allocator drops the Off battery's share on the same tick anyway, so a slewed site command would only be unmet watts that park the freeze, which is the failure the bound removes. The cost is a step in import (or in export when charging) equal to what the closed battery gave. That follows an operator action and is bounded by that battery's rating.
Point 5, risk (c), the SAFETY-73 claim move: unchanged. The refusal claims are unit claims, and the loop claim is a site claim.
resolvepartitions by actuator, andwhy_keyreads onlyloop/safety/failsafe, so the order within each unit is unchanged. test_refusal_units passes (30 s). The mutant "refusal claim never filed" is caught there. With modes off, the n2 digest (which includes refusal probes) is equal.Point 6, maintenance and Off: OK. Off stops a running cycle and logs
ABORT (battery switched off). A battery in any phase gets no mode claim. Off mid-drain, then On 30 s later: the battery follows its mode again, and its cycle waits its turn (last_completedis untouched, so it reruns). It does not get stuck.Point 7, mutations. From the dev's 26 I ran M1, M4, M6, M10, M14, M16, M18 and M26: all caught. I also ran 10 of my own, and all are caught now. Two survived at first:
pack_useaccepting 0/1 (a stored 0 reads back as On) and a loop bound counting stale units. test_modes now covers both.Point 8, UI, store and records: OK.
/actionchecksunitagainst the roster,pack_useneeds a real bool andpack_modeneeds one ofMODES. Nothing else reaches the store. On Supervisor 2026.08.0 (its ownAppOptions): the manifest, the shipped options,true, and an old blob merged with defaults are all accepted. The UI shows a boolean switch. test_ui_text, test_messages and test_translations pass. DOCS and DECISIONS are updated to the new rule ("only for what the other batteries cannot cover").Full suite on
ffc9a4d, locally: 46/47 pass. test_freeze needs the sibling sim repo, which CI mounts.Owner question (not resolved in code): does Off mean 0 W even when the peak limit needs that battery? My recommendation will be in the report to the manager.
Owner question (risk a): does "Off" mean 0 W even when the peak limit needs the battery?
This is not resolved in code. It is analysed here for the owner.
Option 1: Off gives way to the peak floor (the design, as fixed in
ffc9a4d).Option 2: hard Off (always 0 W).
lo= floor >hi= 0 is SAFETY-25 §0a's contradiction, and the whole site stops at 0 W. It would need the peak guard'sachievable_wreduced so that the guard reports itself saturated.Recommendation: ship Option 1 as the default. It is safe and documented in DOCS and DECISIONS. If the owner wants a hard Off, it should be a separate, explicit choice ("Off, also for the peak limit"). It would be built with peak-guard sizing that leaves that battery out (a follow-up card), not by changing what Off means under the current guard.