Price runs (app/priceruns.py, pure). The cheap run is the cheapest consecutive firm window long enough for the chargers' energy at their rating. Give runs are the consecutive slots at least the required spread (cycle_cost_threshold_eur, widened as TariffPlan.arbitrage() does) above the cheap price. The sell run is the dearest window inside a give run, with no slot below 0. Provisional prices never count. The plan is rebuilt at most every 15 min; a run under way is kept to its end.
Per-battery roles (app/modetrade.py, pure): off / house / hold / give / charge / rest / sell. Solar first never grid-charges or sells. Leftover solar acts as Cheap hours only (no forecast until P4). Selling is per battery (pack_sell), Leftover solar and Cheap hours only, stops at arbitrage_soc_floor. A battery refusing a direction (SAFETY-52/73) takes no part in a run that way. main.py files each range intersected with the switch (always holds 0 W).
Expensive-run release. Holding batteries give power only in give runs. A sale asks sellers' rating + the measured house, so the house is covered first and the rest is exported; sellers carry it first (weights).
SAFETY-45 inside the modes. With modes on, the site trade (arbitrage claim) is filed after every unit claim and sized to the loop bound (hard ∩ mode) and the guard's headroom: no unmet share on an Off/held/limited battery (P1's gap). A charge starts only at ≥ 500 W and, once the guard/headroom stops it, stays off for that 15-min period. A sale only needs the guard to have measured the quarter (a 5 kW discharge alone reads "engaged" on export).
Run edges. When a mode trade ends the loop is reseeded at the house's own level (seeded short by this tick's stale error). Model: 2.5 kW charge ending under an 800 W house: no reseed −258 W export then +2976 W import, 55 s to settle; reseed −257 W / +615 W, 20 s.
UI. Cheap hours only in the page list, Settings Simple and the HA select (appended; P2's labels, ids and topics unchanged, test_mqtt 10b). Selling switch on the card (pack_sell /action). Real Today blocks and Why lines per role (EN/NL, STYLE-checked). Home's plan shows the mode runs. The installer Arbitrage row reports the modes' trade. With modes on the site arbitrage_enabled trade is not run (DOCS says so).
F2: the site Why row says "raised for the peak limit" when a limit pushed power up (why_raised()).
SAFETY-46 item 2 decided and recorded (runs of consecutive slots, with modes on; modes-off path unchanged).
Evidence (goodwe-addon-checks:1, --network none)
Identity, modes off: new tools/opt11p3_identity.py (real Controller, P1 meter, prices, arbitrage_enabled, 1080 cycles; trade CHARGEs and DISCHARGEs). Same digests on 7a00f12 and here: n1/n1_off 71245b22…, n2/n2_off 56cf2801… (pinned in test_modes_p3 part 1). Non-vacuous: a reseed leaking to modes off, and an unmeasured headroom, each move a digest. OPT-11 P1 and SAFETY-79 identity digests still pass.
Invariants (test_modes_p3.py): 20 000-tick fuzz of roles/sizing; real controller over a day: no opposite-direction split on any cycle; Solar first share never < 0 during a grid charge; every banked quarter with a grid charge ≤ cap − margin (max 3450.01 W, aim 3450); no sale below the floor or the spread, never at a negative price; hold never gives; a refusal ends the run's trade; no prices → follow the house; Solar first only → never trades; charge limited to what B's inverter can take → 0 W unmet.
Mutations: 28, all red (list below). Two survived at first (M13, M14); tests added, then red.
Full suite locally: all 50 files pass.
Replay vs P2 baseline (tools/opt11p3_replay.py; house = recorded 2026-09-02..17 grid+battery; prices EPEX BE 364 days earlier, hourly, all-in per the file's stated assumption; two modelled 10 kWh / 2.5 kW batteries; export paid at wholesale)
Captured quarter-hourly days 2026-09-09/10 (real prices, same days' house): P2 1.04, P3 SF+LS 2.75, P3 SF+CH+sell −0.08, P3 CH+CH 1.77, old trade 0.52 EUR net. P3 does not beat the P2 baseline on the 16 days. Selling is judged on import prices (as arbitrage.py does) but earns the injection price, so selling loses money in this model. The owner should see this.
Mutations (purged copies, python3 -B)
M1 SF may grid-charge · M2 charge-refusing battery charges · M3 sell below floor · M4 sell without switch · M5 give-refusing battery sells · M6 no 500 W start · M7 period latch ignored in decide · M8 charge without the guard · M9 sell at negative price · M10 resting batteries may charge · M11 charge not capped by headroom · M12 not capped by loop bound (modetrade) · M13 give overlaps cheap run · M14 sell over negative slot · M15 provisional prices used · M16 started run not kept · M17 charge without its own spread · M18 sale gated on guard clear · M19 period latch never set · M20 no reseed · M21 no reference cheap price · M22 refusals not passed to roles · M23 trade sized without loop bound (main) · M24 Why "held back" when raised · M25 role Why lines dropped · M26 pack_sell not routed · M27 Cheap hours not offered · M28 modes-off gate broken. All red.
Not done / risks for the reviewer
Not done: forecast (P4); moving a headroom-short charge to the next cheap periods; an HA switch for selling; regenerating the UX-13 screenshots (they still show the greyed-out controls); the setup wizard's "modes come later" line (UX-13, stale since P1).
Economics above: P3 is roughly neutral against P2 and worse than the old one-period trade in this model; selling at the injection price loses money.
Design choices not in the design doc, each measured: 500 W start + per-period latch; sale not gated on the guard being clear; stored energy valued at the last cheap run for 12 h; a charge needs its own run to clear the spread.
Handover export overshoot is ~0.1 × rating after reseed (model only; TEST-23 should measure).
While one battery grid-charges the others rest (owner answer 3), so the house imports.
With modes on, arbitrage_enabled is not read for trading; an LS/CH battery grid-charges without it.
OPT-11 phase P3 (design `goodwe-session/OPT-11-design.md` §1, §2 Prices, §3, §5, §6 P3, owner answers). Base: release/1.0 7a00f12.
## What it does
1. **Price runs** (`app/priceruns.py`, pure). The cheap run is the cheapest consecutive firm window long enough for the chargers' energy at their rating. Give runs are the consecutive slots at least the required spread (`cycle_cost_threshold_eur`, widened as `TariffPlan.arbitrage()` does) above the cheap price. The sell run is the dearest window inside a give run, with no slot below 0. Provisional prices never count. The plan is rebuilt at most every 15 min; a run under way is kept to its end.
2. **Per-battery roles** (`app/modetrade.py`, pure): off / house / hold / give / charge / rest / sell. Solar first never grid-charges or sells. Leftover solar acts as Cheap hours only (no forecast until P4). Selling is per battery (`pack_sell`), Leftover solar and Cheap hours only, stops at `arbitrage_soc_floor`. A battery refusing a direction (SAFETY-52/73) takes no part in a run that way. main.py files each range intersected with the switch (always holds 0 W).
3. **Expensive-run release.** Holding batteries give power only in give runs. A sale asks sellers' rating + the measured house, so the house is covered first and the rest is exported; sellers carry it first (weights).
4. **SAFETY-45 inside the modes.** With modes on, the site trade (`arbitrage` claim) is filed after every unit claim and sized to the loop bound (hard ∩ mode) and the guard's headroom: no unmet share on an Off/held/limited battery (P1's gap). A charge starts only at ≥ 500 W and, once the guard/headroom stops it, stays off for that 15-min period. A sale only needs the guard to have measured the quarter (a 5 kW discharge alone reads "engaged" on export).
5. **Run edges.** When a mode trade ends the loop is reseeded at the house's own level (seeded short by this tick's stale error). Model: 2.5 kW charge ending under an 800 W house: no reseed −258 W export then +2976 W import, 55 s to settle; reseed −257 W / +615 W, 20 s.
6. **UI.** Cheap hours only in the page list, Settings Simple and the HA select (appended; P2's labels, ids and topics unchanged, test_mqtt 10b). Selling switch on the card (`pack_sell` /action). Real Today blocks and Why lines per role (EN/NL, STYLE-checked). Home's plan shows the mode runs. The installer Arbitrage row reports the modes' trade. With modes on the site `arbitrage_enabled` trade is not run (DOCS says so).
7. **F2:** the site Why row says "raised for the peak limit" when a limit pushed power up (`why_raised()`).
8. **SAFETY-46 item 2** decided and recorded (runs of consecutive slots, with modes on; modes-off path unchanged).
## Evidence (goodwe-addon-checks:1, `--network none`)
- **Identity, modes off:** new `tools/opt11p3_identity.py` (real Controller, P1 meter, prices, `arbitrage_enabled`, 1080 cycles; trade CHARGEs and DISCHARGEs). Same digests on 7a00f12 and here: n1/n1_off `71245b22…`, n2/n2_off `56cf2801…` (pinned in test_modes_p3 part 1). Non-vacuous: a reseed leaking to modes off, and an unmeasured headroom, each move a digest. OPT-11 P1 and SAFETY-79 identity digests still pass.
- **Invariants** (`test_modes_p3.py`): 20 000-tick fuzz of roles/sizing; real controller over a day: no opposite-direction split on any cycle; Solar first share never < 0 during a grid charge; every banked quarter with a grid charge ≤ cap − margin (max 3450.01 W, aim 3450); no sale below the floor or the spread, never at a negative price; hold never gives; a refusal ends the run's trade; no prices → follow the house; Solar first only → never trades; charge limited to what B's inverter can take → 0 W unmet.
- **Mutations:** 28, all red (list below). Two survived at first (M13, M14); tests added, then red.
- Full suite locally: all 50 files pass.
## Replay vs P2 baseline (`tools/opt11p3_replay.py`; house = recorded 2026-09-02..17 grid+battery; prices EPEX BE 364 days earlier, hourly, all-in per the file's stated assumption; two modelled 10 kWh / 2.5 kW batteries; export paid at wholesale)
| variant (tree) | import kWh | export kWh | net EUR | cycles A/B |
|---|---|---|---|---|
| modes off (7a00f12) | 106.3 | 13.5 | 19.25 | 7.1/7.1 |
| old trade, modes off (7a00f12) | 116.3 | 22.3 | 18.19 | 7.7/7.7 |
| **P2 baseline** SF + LS (7a00f12) | 107.1 | 14.4 | **19.33** | 10.8/3.4 |
| P2 + old trade (7a00f12) | 109.8 | 15.9 | 18.14 | 11.3/4.0 |
| P3 SF + LS | 115.3 | 22.0 | 19.57 | 10.4/4.0 |
| P3 SF + CH + sell | 126.4 | 33.1 | 19.60 | 10.4/4.0 |
| P3 CH + CH(sell) | 149.2 | 46.8 | 21.49 | 6.6/6.8 |
Captured quarter-hourly days 2026-09-09/10 (real prices, same days' house): P2 1.04, P3 SF+LS 2.75, P3 SF+CH+sell −0.08, P3 CH+CH 1.77, old trade 0.52 EUR net.
**P3 does not beat the P2 baseline on the 16 days.** Selling is judged on import prices (as `arbitrage.py` does) but earns the injection price, so selling loses money in this model. The owner should see this.
## Mutations (purged copies, `python3 -B`)
M1 SF may grid-charge · M2 charge-refusing battery charges · M3 sell below floor · M4 sell without switch · M5 give-refusing battery sells · M6 no 500 W start · M7 period latch ignored in decide · M8 charge without the guard · M9 sell at negative price · M10 resting batteries may charge · M11 charge not capped by headroom · M12 not capped by loop bound (modetrade) · M13 give overlaps cheap run · M14 sell over negative slot · M15 provisional prices used · M16 started run not kept · M17 charge without its own spread · M18 sale gated on guard clear · M19 period latch never set · M20 no reseed · M21 no reference cheap price · M22 refusals not passed to roles · M23 trade sized without loop bound (main) · M24 Why "held back" when raised · M25 role Why lines dropped · M26 pack_sell not routed · M27 Cheap hours not offered · M28 modes-off gate broken. All red.
## Not done / risks for the reviewer
- Not done: forecast (P4); moving a headroom-short charge to the next cheap periods; an HA switch for selling; regenerating the UX-13 screenshots (they still show the greyed-out controls); the setup wizard's "modes come later" line (UX-13, stale since P1).
- Economics above: P3 is roughly neutral against P2 and worse than the old one-period trade in this model; selling at the injection price loses money.
- Design choices not in the design doc, each measured: 500 W start + per-period latch; sale not gated on the guard being clear; stored energy valued at the last cheap run for 12 h; a charge needs its own run to clear the spread.
- Handover export overshoot is ~0.1 × rating after reseed (model only; TEST-23 should measure).
- While one battery grid-charges the others rest (owner answer 3), so the house imports.
- With modes on, `arbitrage_enabled` is not read for trading; an LS/CH battery grid-charges without it.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
Cheap and expensive hours as runs of consecutive firm price slots
(priceruns.py); each battery's role per tick from its mode (modetrade.py):
grid charging for Leftover solar / Cheap hours only, never Solar first;
holding for the expensive runs; selling per battery (pack_sell) above the
spread and the floor. The site trade is sized inside the modes (loop bound)
and the peak guard's headroom; a run edge reseeds the loop. Cheap hours only
and selling are enabled on the Batteries screen and in the HA select
(appended). The site Why row no longer says "held back" when the peak limit
pushes power up (F2). Modes off: byte-identical to 7a00f12 on the trade path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A sale block on the plan no longer says "Worth up to" at the import
price: a sale earns the lower injection price.
- The selling switch help (EN/NL) and DOCS say the grid pays less than
power costs; DECISIONS records why P3 loses on the replay.
- test_modes_p3 part 11: modes on ignore the site trade switch (R13), a
battery in maintenance takes no part (R10), a headroom stop latches for
the period (R8). All three mutants survived before and are red now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #153, part 1: the economics (for the owner)
I re-ran the dev's replay on both trees (same code, --network none). The numbers are exactly the dev's: P2 19.33, P3 SF+LS 19.57, P3 with a sale 19.60, both Cheap hours 21.49, old one-period trade 18.14 EUR net over 16 days. I added a split per action (charge, sale, other) and three experiments.
Why P3 loses (16 days, SF+LS against P2):
The cheapest hours were at midday on 15 of the 16 days. With no forecast, Leftover solar acts as Cheap hours, so B bought 12.2 kWh at midday for 1.38 EUR. The sun would have filled B for free. Result: 7 kWh more exported (21.4 against 14.4) and 12 kWh less charged from the sun.
Holding B for the expensive hours saved 4 kWh of import (about 0.64 EUR). That is not enough to make up for the midday buying. Net +0.24 EUR worse.
This is not a bug. It is what the no-forecast default does, and P4 is meant to fix it. Experiment with no grid charge on Leftover solar (hold and give only): 19.44, about the same as P2.
Old trade (18.14): it charges both batteries for one hour, often a negative-price hour. The afternoon sun still fits after that. A run fills B for about 4 h at midday, and during the run Solar first A may not take solar (it "rests").
12 h valuation: without it, SF+LS gives 19.40 and CH+sell 19.19. On the captured days it helps. The effect is small either way.
Selling: the sales earned 2.27 EUR for 11.4 kWh at the wholesale price. The house then bought that energy back later for 2.29 EUR. So selling is a wash before battery wear, and a loss after it.
A kWh you store is worth the price you would pay for it later, levies included. The grid only pays the wholesale price for it.
So a sale pays only when the sale hour's wholesale price is higher than the later import price for that energy. In this model that is about 0.13 EUR/kWh above that hour's wholesale price. With real Flemish levies it is about 0.15 to 0.20.
To sell grid-bought energy you need a sale price of about 0.14 EUR/kWh wholesale if you bought at 0, and 0.20 if you bought at 0.05.
The highest price in the 16 hourly days was 0.245. On the quarter-hourly 2026-09-09 spike (about 0.37 to 0.5) selling did pay: −0.08 against P2's 1.04 EUR, and still about 0.5 EUR better once the charge left at the end is valued.
Is the model sound? Mostly, and fixing it does not change the order.
Hourly prices from a year earlier understate the spreads (the file says so). That slightly favours P3 in reality.
The charge left in the batteries at the end is not valued. That matters for the 2-day runs (P3 SF+LS ends 5.5 kWh fuller than P2, about 1.1 EUR) and for both Cheap hours (+10 kWh, about 2 EUR, so about 19.5, not 21.49). On 16 days it hardly matters for the others: all end near 10 %.
No battery wear is counted. At 0.03 EUR/kWh that adds about 0.5 EUR to the sale variant.
The all-in import price is (wholesale + 0.12) × 1.06. Flemish levies are higher. That makes selling worse and does not change charge-against-use spreads.
Export is paid at the full wholesale price. Real injection pays slightly less, which again makes selling worse.
Capacity tariff: no variant raised the month peak. The house sets 4467 W itself in all of them. Every quarter with a P3 grid charge banked at most 3450.7 W.
In plain words: in September, P3 as built saves nothing over P2 and buys midday power the sun would have given. Selling breaks even at best. What would make P3 pay:
The forecast (P4), so Leftover solar charges from the grid only for the gap the sun leaves.
A top-up in the cheapest hour or two rather than filling to 95 %, the way the old trade does.
A sale judged on the injection price, and only for energy the house will not need before the next cheap hours.
## Review of #153, part 1: the economics (for the owner)
I re-ran the dev's replay on both trees (same code, `--network none`). The numbers are exactly the dev's: P2 19.33, P3 SF+LS 19.57, P3 with a sale 19.60, both Cheap hours 21.49, old one-period trade 18.14 EUR net over 16 days. I added a split per action (charge, sale, other) and three experiments.
**Why P3 loses (16 days, SF+LS against P2):**
- The cheapest hours were at midday on 15 of the 16 days. With no forecast, Leftover solar acts as Cheap hours, so B bought **12.2 kWh at midday for 1.38 EUR**. The sun would have filled B for free. Result: **7 kWh more exported** (21.4 against 14.4) and 12 kWh less charged from the sun.
- Holding B for the expensive hours saved **4 kWh of import (about 0.64 EUR)**. That is not enough to make up for the midday buying. Net **+0.24 EUR worse**.
- **This is not a bug.** It is what the no-forecast default does, and P4 is meant to fix it. Experiment with no grid charge on Leftover solar (hold and give only): **19.44**, about the same as P2.
- **Old trade (18.14):** it charges both batteries for one hour, often a negative-price hour. The afternoon sun still fits after that. A run fills B for about 4 h at midday, and during the run Solar first A may not take solar (it "rests").
- **12 h valuation:** without it, SF+LS gives 19.40 and CH+sell 19.19. On the captured days it helps. The effect is small either way.
**Selling:** the sales earned **2.27 EUR** for 11.4 kWh at the wholesale price. The house then bought that energy back later for **2.29 EUR**. So selling is a wash before battery wear, and a loss after it.
- A kWh you store is worth the price you would pay for it later, levies included. The grid only pays the wholesale price for it.
- So a sale pays only when **the sale hour's wholesale price is higher than the later import price for that energy**. In this model that is about 0.13 EUR/kWh above that hour's wholesale price. With real Flemish levies it is about 0.15 to 0.20.
- To sell grid-bought energy you need a sale price of about **0.14 EUR/kWh wholesale if you bought at 0**, and 0.20 if you bought at 0.05.
- The highest price in the 16 hourly days was 0.245. On the quarter-hourly 2026-09-09 spike (about 0.37 to 0.5) selling did pay: −0.08 against P2's 1.04 EUR, and still about 0.5 EUR better once the charge left at the end is valued.
**Is the model sound? Mostly, and fixing it does not change the order.**
- Hourly prices from a year earlier understate the spreads (the file says so). That slightly favours P3 in reality.
- The charge left in the batteries at the end is **not valued**. That matters for the 2-day runs (P3 SF+LS ends 5.5 kWh fuller than P2, about 1.1 EUR) and for both Cheap hours (+10 kWh, about 2 EUR, so about 19.5, not 21.49). On 16 days it hardly matters for the others: all end near 10 %.
- **No battery wear** is counted. At 0.03 EUR/kWh that adds about 0.5 EUR to the sale variant.
- The all-in import price is (wholesale + 0.12) × 1.06. Flemish levies are higher. That makes selling worse and does not change charge-against-use spreads.
- Export is paid at the full wholesale price. Real injection pays slightly less, which again makes selling worse.
- **Capacity tariff:** no variant raised the month peak. The house sets 4467 W itself in all of them. Every quarter with a P3 grid charge banked at most 3450.7 W.
**In plain words:** in September, P3 as built saves nothing over P2 and buys midday power the sun would have given. Selling breaks even at best. What would make P3 pay:
1. The forecast (P4), so Leftover solar charges from the grid only for the gap the sun leaves.
2. A top-up in the cheapest hour or two rather than filling to 95 %, the way the old trade does.
3. A sale judged on the injection price, and only for energy the house will not need before the next cheap hours.
Review of #153, part 2: safety and code (fixes pushed as b014fd2)
1. Invariants, re-run: OK.
Real controller, 16 days, both Cheap hours and CH+sell (5206 charge and 4320 sale cycles):
no opposite-direction split;
no battery outside the charge role charged during a grid charge;
no sale below the floor or at a negative price;
no Off battery used.
My own fuzz of modetrade.decide(), size() and priceruns.plan(): 300 000 cases (1417 charges, 925 sales), 0 violations. It checks:
every range holds 0;
Solar first never charges, sells or holds;
a charger or seller that is refusing, at its level, Off, not live or in maintenance is never used;
the gates (guard, 500 W start, latch, headroom);
the size stays within the headroom and the bound;
no provisional slot in any run, the sale inside a give run, no give run over the cheap run.
It catches R12 below (65 violations in 30 000 cases).
3450.01 W (and 3450.69 W on my 16-day run) against an aim of 3450 is not a rounding bug. The headroom is exact by construction (peakguard.py). The fraction of a watt is the lagged battery tracking the command, which is the overshoot SAFETY-79's 50 W margin exists for. It stays 49 W under the cap. Acceptable.
2. arbitrage_enabled with modes on.
Not reading it matches the designs: the design's §1 table puts grid charging inside Leftover solar and Cheap hours, and UX-13 says the switch becomes "hidden, derived: true if any battery sells".
Surprise risk:
Turning modes on alone never grid-charges, because the default mode is Solar first.
But an install already on P2 with a battery on Leftover solar starts grid charging after the update without doing anything. In P2, Leftover solar meant solar only. The owner's own B is such a battery. Live has no prices today, so nothing happens yet.
The uimodel.arbitrage_from_batteries() hook still returns None, so the stored switch can say off while a battery sells. That is cosmetic, but it should be done.
Recommendation: until P4, Leftover solar should not charge from the grid (hold and give only). Cheap hours, which you must pick on purpose, does. This also matches the money result (19.44 against 19.57).
3. The four behaviours that are not in the design:
500 W start and period latch: keep. They only ever remove charging, so they cannot raise import or a peak. While latched, B holds and A covers the house. An engaged peak guard still releases the modes.
Sale needs the guard measured, not clear: keep. A sale only adds discharge, so it can only lower import, and the capacity tariff is on import. So it cannot breach the peak. The sale's end is reseeded at the house's level.
12 h valuation: justified but marginal (±0.2 to 0.4 EUR either way, see part 1).
Run-edge reseed: keep (item 4).
4. Run-edge overshoot: bounded. After the reseed the import side stays below the house's own load: +615 W under an 800 W house, against +2976 W without the reseed. What is left is about 0.1 × rating as export, which costs nothing on the peak. SAFETY-46 measured about 0.37 × rating at a handover without a reseed. TEST-23 should still measure it.
5. Identity: equal and not vacuous. On 7a00f12 and on the head: n1/n1_off 71245b22…, n2/n2_off 56cf2801…, with CHARGE and DISCHARGE in every scenario. R14 below (headroom not computed with modes off) moves the digest and is caught.
6. Mutations.
Dev's, sampled: M18, M20 and M28 are red. R1 and R3 below match M6 and M3.
Mine (16):
Red at once, 12 of them:
R1 no 500 W start
R2 size ignores headroom
R3 sell 5 % under the floor
R4 sell at a negative price
R5 refusing battery charges
R6 refusing battery sells
R9 provisional price used
R12 Solar first may charge while resting
R14 modes-off headroom not computed
R15 started run not kept
R17 sell over a negative slot
R18 Off not honoured
Red after one extra check, R19: a charge ignores the period latch.
4 survived:
R13 (the old site trade also runs with modes on): untested;
R10 (maintenance ignored when choosing chargers): untested;
R8 (latch not set after a headroom stop): untested;
R7 (charge ignores the guard state): equivalent, because an engaged guard gives 0 W headroom by construction.
I added test_modes_p3 part 11 for R13, R10 and R8. All three are red now.
7. UI, HA and records.
The HA select only appends "Cheap hours only". No label, id or topic changed (test_mqtt 10b).
Fixed: the plan's sale block said "Worth up to X EUR" at the import price. That implies a sale earns what power costs. It now shows no money line (tested).
Fixed: the selling help now says "…but you are paid less than you pay for power." (EN and NL). DOCS says selling rarely pays, and DECISIONS records why P3 loses. Both are STYLE-checked.
Full suite locally: 49/49 pass (test_freeze needs the sim checkout). Not merging: the owner decides after part 1.
## Review of #153, part 2: safety and code (fixes pushed as b014fd2)
**1. Invariants, re-run: OK.**
- Real controller, 16 days, both Cheap hours and CH+sell (5206 charge and 4320 sale cycles):
- no opposite-direction split;
- no battery outside the charge role charged during a grid charge;
- no sale below the floor or at a negative price;
- no Off battery used.
- My own fuzz of `modetrade.decide()`, `size()` and `priceruns.plan()`: 300 000 cases (1417 charges, 925 sales), 0 violations. It checks:
- every range holds 0;
- Solar first never charges, sells or holds;
- a charger or seller that is refusing, at its level, Off, not live or in maintenance is never used;
- the gates (guard, 500 W start, latch, headroom);
- the size stays within the headroom and the bound;
- no provisional slot in any run, the sale inside a give run, no give run over the cheap run.
It catches R12 below (65 violations in 30 000 cases).
- **3450.01 W (and 3450.69 W on my 16-day run) against an aim of 3450 is not a rounding bug.** The headroom is exact by construction (peakguard.py). The fraction of a watt is the lagged battery tracking the command, which is the overshoot SAFETY-79's 50 W margin exists for. It stays 49 W under the cap. Acceptable.
**2. `arbitrage_enabled` with modes on.**
- Not reading it matches the designs: the design's §1 table puts grid charging inside Leftover solar and Cheap hours, and UX-13 says the switch becomes "hidden, derived: true if any battery sells".
- **Surprise risk:**
- Turning modes on alone never grid-charges, because the default mode is Solar first.
- But **an install already on P2 with a battery on Leftover solar starts grid charging after the update without doing anything**. In P2, Leftover solar meant solar only. The owner's own B is such a battery. Live has no prices today, so nothing happens yet.
- The `uimodel.arbitrage_from_batteries()` hook still returns None, so the stored switch can say off while a battery sells. That is cosmetic, but it should be done.
- **Recommendation:** until P4, Leftover solar should not charge from the grid (hold and give only). Cheap hours, which you must pick on purpose, does. This also matches the money result (19.44 against 19.57).
**3. The four behaviours that are not in the design:**
- **500 W start and period latch: keep.** They only ever remove charging, so they cannot raise import or a peak. While latched, B holds and A covers the house. An engaged peak guard still releases the modes.
- **Sale needs the guard measured, not clear: keep.** A sale only adds discharge, so it can only lower import, and the capacity tariff is on import. So it cannot breach the peak. The sale's end is reseeded at the house's level.
- **12 h valuation: justified but marginal** (±0.2 to 0.4 EUR either way, see part 1).
- **Run-edge reseed: keep** (item 4).
**4. Run-edge overshoot: bounded.** After the reseed the import side stays below the house's own load: +615 W under an 800 W house, against +2976 W without the reseed. What is left is about 0.1 × rating as **export**, which costs nothing on the peak. SAFETY-46 measured about 0.37 × rating at a handover without a reseed. TEST-23 should still measure it.
**5. Identity: equal and not vacuous.** On 7a00f12 and on the head: n1/n1_off 71245b22…, n2/n2_off 56cf2801…, with CHARGE and DISCHARGE in every scenario. R14 below (headroom not computed with modes off) moves the digest and is caught.
**6. Mutations.**
- **Dev's, sampled:** M18, M20 and M28 are red. R1 and R3 below match M6 and M3.
- **Mine (16):**
- Red at once, 12 of them:
- R1 no 500 W start
- R2 size ignores headroom
- R3 sell 5 % under the floor
- R4 sell at a negative price
- R5 refusing battery charges
- R6 refusing battery sells
- R9 provisional price used
- R12 Solar first may charge while resting
- R14 modes-off headroom not computed
- R15 started run not kept
- R17 sell over a negative slot
- R18 Off not honoured
- Red after one extra check, R19: a charge ignores the period latch.
- **4 survived:**
- **R13** (the old site trade also runs with modes on): **untested**;
- **R10** (maintenance ignored when choosing chargers): **untested**;
- **R8** (latch not set after a headroom stop): **untested**;
- **R7** (charge ignores the guard state): **equivalent**, because an engaged guard gives 0 W headroom by construction.
I added test_modes_p3 part 11 for R13, R10 and R8. All three are red now.
**7. UI, HA and records.**
- The HA select only appends "Cheap hours only". No label, id or topic changed (test_mqtt 10b).
- **Fixed:** the plan's sale block said "Worth up to X EUR" at the import price. That implies a sale earns what power costs. It now shows no money line (tested).
- **Fixed:** the selling help now says "…but you are paid less than you pay for power." (EN and NL). DOCS says selling rarely pays, and DECISIONS records why P3 loses. Both are STYLE-checked.
Full suite locally: 49/49 pass (test_freeze needs the sim checkout). **Not merging**: the owner decides after part 1.
Owner decision 2026-09-29. Selling is off until it can be judged on the price
the grid pays: packmode.SELLING_ENABLED = False makes read() answer
sell=False whatever the store holds, pack_sell is refused ("Selling is off
for now."), and the page has no selling switch; the sell code stays. Until
the P4 forecast, only Cheap hours only charges from the grid
(packmode.GRID_CHARGE_MODES); Leftover solar holds and gives in the
expensive runs. Help, Today and plan lines, DOCS and DECISIONS follow.
Replay, 16 days: Solar first + Leftover solar 19.44 EUR net (P2 19.33).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 5 said "Choosing a mode for each battery comes in a later update",
stale since P1. It now points to battery modes on the Batteries screen
(EN/NL); test_setup checks the new line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
read() answers sell=False and can_sell answers False while SELLING_ENABLED is False.
_mode_packs() passes pm.sell and pm.can_sell, so modetrade.sellers() is always empty and no SELL role or sale claim can form.
/action pack_sell is refused before anything is stored. MQTT's parse_command() only produces pack_use and pack_mode. The site trade is not run with modes on.
The sale block on the plan needs pm.sell and pm.can_sell.
Replayed over 16 days with CH + a stored sell switch "on": 4442 charge cycles, 0 sale cycles.
The tests turn the flag on only around the kept sell code, restore it straight after, and part 12 checks it is False. Each test file runs in its own process, so nothing leaks between files.
2. Leftover solar never grid-charges: OK.
chargers() takes only GRID_CHARGE_MODES = Cheap hours only. With no prices, Leftover solar follows the house with the loop, which never asks for a grid charge.
Maintenance is unchanged and still outranks the mode, as designed.
16-day replay, SF + LS: 0 charge cycles, 0 charge roles, 0 opposite-direction splits. Worst quarter is 4467 W, set by the house alone, the same as P2.
Leftover solar still took in 1.54 kWh while the grid was importing. That is loop lag, and P2 (7a00f12) gives exactly the same 1.54 kWh.
3. Text: fixed one stale line.
Wizard step 5 still said "Choosing a mode for each battery comes in a later update." It now says: "After setup, turn on battery modes to choose a mode for each battery on the Batteries screen." (EN and NL; test_setup updated.)
Nothing else promises selling or grid charging for Leftover solar (EN/NL, DOCS). STYLE tests pass.
4. Replay: SF + LS 19.44 EUR confirmed (P2 19.33). With a Cheap hours battery: 19.57.
5. Mutations: 8 of 8 caught.
I could not find the A1–A7 list on the PR or in the commit, so I wrote the equivalent guards myself:
S1: the flag switched on
S2: can_sell ignores the flag
S3: read() ignores the flag
S4: the pack_sell refusal removed
S5: Leftover solar added to GRID_CHARGE_MODES
S6: the grid block shown for Leftover solar
My own two:
O1: _mode_packs lets every price battery sell, bypassing the store
O2: chargers() back on PRICE_MODES
6. Records: OK. DECISIONS records the 2026-09-29 decision (option A) in one short item, with the replay numbers. They are true.
Full suite locally 49/49 (test_freeze needs the sim checkout). I will merge on green CI with head_commit_id pinned.
## Review of 6dab4ec (owner option A), plus 7104515
**1. Selling cannot run by any route: OK.**
- `read()` answers `sell=False` and `can_sell` answers False while `SELLING_ENABLED` is False.
- `_mode_packs()` passes `pm.sell and pm.can_sell`, so `modetrade.sellers()` is always empty and no SELL role or sale claim can form.
- `/action pack_sell` is refused before anything is stored. MQTT's `parse_command()` only produces `pack_use` and `pack_mode`. The site trade is not run with modes on.
- The sale block on the plan needs `pm.sell and pm.can_sell`.
- Replayed over 16 days with CH + a stored sell switch "on": 4442 charge cycles, **0 sale cycles**.
- The tests turn the flag on only around the kept sell code, restore it straight after, and part 12 checks it is False. Each test file runs in its own process, so nothing leaks between files.
**2. Leftover solar never grid-charges: OK.**
- `chargers()` takes only `GRID_CHARGE_MODES` = Cheap hours only. With no prices, Leftover solar follows the house with the loop, which never asks for a grid charge.
- Maintenance is unchanged and still outranks the mode, as designed.
- 16-day replay, SF + LS: 0 charge cycles, 0 charge roles, 0 opposite-direction splits. Worst quarter is 4467 W, set by the house alone, the same as P2.
- Leftover solar still took in 1.54 kWh while the grid was importing. That is loop lag, and P2 (7a00f12) gives exactly the same 1.54 kWh.
**3. Text: fixed one stale line.**
- Wizard step 5 still said "Choosing a mode for each battery comes in a later update." It now says: "After setup, turn on battery modes to choose a mode for each battery on the Batteries screen." (EN and NL; test_setup updated.)
- Nothing else promises selling or grid charging for Leftover solar (EN/NL, DOCS). STYLE tests pass.
**4. Replay:** SF + LS **19.44 EUR** confirmed (P2 19.33). With a Cheap hours battery: 19.57.
**5. Mutations: 8 of 8 caught.**
- I could not find the A1–A7 list on the PR or in the commit, so I wrote the equivalent guards myself:
- S1: the flag switched on
- S2: `can_sell` ignores the flag
- S3: `read()` ignores the flag
- S4: the `pack_sell` refusal removed
- S5: Leftover solar added to `GRID_CHARGE_MODES`
- S6: the grid block shown for Leftover solar
- My own two:
- O1: `_mode_packs` lets every price battery sell, bypassing the store
- O2: `chargers()` back on `PRICE_MODES`
**6. Records: OK.** DECISIONS records the 2026-09-29 decision (option A) in one short item, with the replay numbers. They are true.
Full suite locally 49/49 (test_freeze needs the sim checkout). I will merge on green CI with `head_commit_id` pinned.
admin
merged commit 5a4cdc273e into release/1.02026-09-29 11:37:47 +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 P3 (design
goodwe-session/OPT-11-design.md§1, §2 Prices, §3, §5, §6 P3, owner answers). Base: release/1.07a00f12.What it does
app/priceruns.py, pure). The cheap run is the cheapest consecutive firm window long enough for the chargers' energy at their rating. Give runs are the consecutive slots at least the required spread (cycle_cost_threshold_eur, widened asTariffPlan.arbitrage()does) above the cheap price. The sell run is the dearest window inside a give run, with no slot below 0. Provisional prices never count. The plan is rebuilt at most every 15 min; a run under way is kept to its end.app/modetrade.py, pure): off / house / hold / give / charge / rest / sell. Solar first never grid-charges or sells. Leftover solar acts as Cheap hours only (no forecast until P4). Selling is per battery (pack_sell), Leftover solar and Cheap hours only, stops atarbitrage_soc_floor. A battery refusing a direction (SAFETY-52/73) takes no part in a run that way. main.py files each range intersected with the switch (always holds 0 W).arbitrageclaim) is filed after every unit claim and sized to the loop bound (hard ∩ mode) and the guard's headroom: no unmet share on an Off/held/limited battery (P1's gap). A charge starts only at ≥ 500 W and, once the guard/headroom stops it, stays off for that 15-min period. A sale only needs the guard to have measured the quarter (a 5 kW discharge alone reads "engaged" on export).pack_sell/action). Real Today blocks and Why lines per role (EN/NL, STYLE-checked). Home's plan shows the mode runs. The installer Arbitrage row reports the modes' trade. With modes on the sitearbitrage_enabledtrade is not run (DOCS says so).why_raised()).Evidence (goodwe-addon-checks:1,
--network none)tools/opt11p3_identity.py(real Controller, P1 meter, prices,arbitrage_enabled, 1080 cycles; trade CHARGEs and DISCHARGEs). Same digests on7a00f12and here: n1/n1_off71245b22…, n2/n2_off56cf2801…(pinned in test_modes_p3 part 1). Non-vacuous: a reseed leaking to modes off, and an unmeasured headroom, each move a digest. OPT-11 P1 and SAFETY-79 identity digests still pass.test_modes_p3.py): 20 000-tick fuzz of roles/sizing; real controller over a day: no opposite-direction split on any cycle; Solar first share never < 0 during a grid charge; every banked quarter with a grid charge ≤ cap − margin (max 3450.01 W, aim 3450); no sale below the floor or the spread, never at a negative price; hold never gives; a refusal ends the run's trade; no prices → follow the house; Solar first only → never trades; charge limited to what B's inverter can take → 0 W unmet.Replay vs P2 baseline (
tools/opt11p3_replay.py; house = recorded 2026-09-02..17 grid+battery; prices EPEX BE 364 days earlier, hourly, all-in per the file's stated assumption; two modelled 10 kWh / 2.5 kW batteries; export paid at wholesale)7a00f12)7a00f12)7a00f12)7a00f12)Captured quarter-hourly days 2026-09-09/10 (real prices, same days' house): P2 1.04, P3 SF+LS 2.75, P3 SF+CH+sell −0.08, P3 CH+CH 1.77, old trade 0.52 EUR net.
P3 does not beat the P2 baseline on the 16 days. Selling is judged on import prices (as
arbitrage.pydoes) but earns the injection price, so selling loses money in this model. The owner should see this.Mutations (purged copies,
python3 -B)M1 SF may grid-charge · M2 charge-refusing battery charges · M3 sell below floor · M4 sell without switch · M5 give-refusing battery sells · M6 no 500 W start · M7 period latch ignored in decide · M8 charge without the guard · M9 sell at negative price · M10 resting batteries may charge · M11 charge not capped by headroom · M12 not capped by loop bound (modetrade) · M13 give overlaps cheap run · M14 sell over negative slot · M15 provisional prices used · M16 started run not kept · M17 charge without its own spread · M18 sale gated on guard clear · M19 period latch never set · M20 no reseed · M21 no reference cheap price · M22 refusals not passed to roles · M23 trade sized without loop bound (main) · M24 Why "held back" when raised · M25 role Why lines dropped · M26 pack_sell not routed · M27 Cheap hours not offered · M28 modes-off gate broken. All red.
Not done / risks for the reviewer
arbitrage_enabledis not read for trading; an LS/CH battery grid-charges without it.🤖 Generated with Claude Code
https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
Review of #153, part 1: the economics (for the owner)
I re-ran the dev's replay on both trees (same code,
--network none). The numbers are exactly the dev's: P2 19.33, P3 SF+LS 19.57, P3 with a sale 19.60, both Cheap hours 21.49, old one-period trade 18.14 EUR net over 16 days. I added a split per action (charge, sale, other) and three experiments.Why P3 loses (16 days, SF+LS against P2):
Selling: the sales earned 2.27 EUR for 11.4 kWh at the wholesale price. The house then bought that energy back later for 2.29 EUR. So selling is a wash before battery wear, and a loss after it.
Is the model sound? Mostly, and fixing it does not change the order.
In plain words: in September, P3 as built saves nothing over P2 and buys midday power the sun would have given. Selling breaks even at best. What would make P3 pay:
Review of #153, part 2: safety and code (fixes pushed as
b014fd2)1. Invariants, re-run: OK.
Real controller, 16 days, both Cheap hours and CH+sell (5206 charge and 4320 sale cycles):
My own fuzz of
modetrade.decide(),size()andpriceruns.plan(): 300 000 cases (1417 charges, 925 sales), 0 violations. It checks:It catches R12 below (65 violations in 30 000 cases).
3450.01 W (and 3450.69 W on my 16-day run) against an aim of 3450 is not a rounding bug. The headroom is exact by construction (peakguard.py). The fraction of a watt is the lagged battery tracking the command, which is the overshoot SAFETY-79's 50 W margin exists for. It stays 49 W under the cap. Acceptable.
2.
arbitrage_enabledwith modes on.uimodel.arbitrage_from_batteries()hook still returns None, so the stored switch can say off while a battery sells. That is cosmetic, but it should be done.3. The four behaviours that are not in the design:
4. Run-edge overshoot: bounded. After the reseed the import side stays below the house's own load: +615 W under an 800 W house, against +2976 W without the reseed. What is left is about 0.1 × rating as export, which costs nothing on the peak. SAFETY-46 measured about 0.37 × rating at a handover without a reseed. TEST-23 should still measure it.
5. Identity: equal and not vacuous. On
7a00f12and on the head: n1/n1_off 71245b22…, n2/n2_off 56cf2801…, with CHARGE and DISCHARGE in every scenario. R14 below (headroom not computed with modes off) moves the digest and is caught.6. Mutations.
Dev's, sampled: M18, M20 and M28 are red. R1 and R3 below match M6 and M3.
Mine (16):
I added test_modes_p3 part 11 for R13, R10 and R8. All three are red now.
7. UI, HA and records.
Full suite locally: 49/49 pass (test_freeze needs the sim checkout). Not merging: the owner decides after part 1.
Owner decision 2026-09-29. Selling is off until it can be judged on the price the grid pays: packmode.SELLING_ENABLED = False makes read() answer sell=False whatever the store holds, pack_sell is refused ("Selling is off for now."), and the page has no selling switch; the sell code stays. Until the P4 forecast, only Cheap hours only charges from the grid (packmode.GRID_CHARGE_MODES); Leftover solar holds and gives in the expensive runs. Help, Today and plan lines, DOCS and DECISIONS follow. Replay, 16 days: Solar first + Leftover solar 19.44 EUR net (P2 19.33). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Review of
6dab4ec(owner option A), plus71045151. Selling cannot run by any route: OK.
read()answerssell=Falseandcan_sellanswers False whileSELLING_ENABLEDis False._mode_packs()passespm.sell and pm.can_sell, somodetrade.sellers()is always empty and no SELL role or sale claim can form./action pack_sellis refused before anything is stored. MQTT'sparse_command()only producespack_useandpack_mode. The site trade is not run with modes on.pm.sell and pm.can_sell.2. Leftover solar never grid-charges: OK.
chargers()takes onlyGRID_CHARGE_MODES= Cheap hours only. With no prices, Leftover solar follows the house with the loop, which never asks for a grid charge.7a00f12) gives exactly the same 1.54 kWh.3. Text: fixed one stale line.
4. Replay: SF + LS 19.44 EUR confirmed (P2 19.33). With a Cheap hours battery: 19.57.
5. Mutations: 8 of 8 caught.
can_sellignores the flagread()ignores the flagpack_sellrefusal removedGRID_CHARGE_MODES_mode_packslets every price battery sell, bypassing the storechargers()back onPRICE_MODES6. Records: OK. DECISIONS records the 2026-09-29 decision (option A) in one short item, with the replay numbers. They are true.
Full suite locally 49/49 (test_freeze needs the sim checkout). I will merge on green CI with
head_commit_idpinned.