OPT-11 P3: price runs per battery #153

Merged
admin merged 4 commits from opt11-p3-price-runs into release/1.0 2026-09-29 11:37:47 +02:00
Owner

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.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV

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
admin added 1 commit 2026-09-29 02:38:20 +02:00
OPT-11 P3: price runs per battery
checks / unit (pull_request) Successful in 13m45s
infra-pin / fresh (pull_request) Successful in 1s
902524ae5e
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>
admin added 1 commit 2026-09-29 03:51:19 +02:00
OPT-11 P3 review: sale shows no money, honest sell text, tests for four surviving mutants
checks / unit (pull_request) Successful in 14m5s
infra-pin / fresh (pull_request) Successful in 1s
b014fd2e16
- 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>
Author
Owner

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 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.
Author
Owner

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.
admin added 1 commit 2026-09-29 10:43:01 +02:00
OPT-11 P3, owner option A: selling off, Leftover solar takes solar only
checks / unit (pull_request) Successful in 15m12s
infra-pin / fresh (pull_request) Successful in 1s
6dab4ec16b
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>
admin added 1 commit 2026-09-29 11:21:35 +02:00
OPT-11 P3 option A review: wizard no longer says modes come later
checks / unit (pull_request) Successful in 15m14s
infra-pin / fresh (pull_request) Successful in 1s
7104515229
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>
Author
Owner

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.

## 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.0 2026-09-29 11:37:47 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#153