Files
goodwe-addon/goodwe_controller/DECISIONS/OPT-11-battery-modes.md
T
adminandClaude Opus 5.5 fae6681e19
checks / unit (pull_request) Successful in 18m49s
infra-pin / fresh (pull_request) Successful in 1s
OPT-15: price-run fixes (quarter edge, charge-end overshoot, refusal probe)
F1: with battery modes on, the peak guard projects the quarter `now` is in
even before its first P1 sample (PeakTracker.project(roll=True)), and only
a measured stop latches the period. The edge tick no longer reads "no
seconds left", so a charge is not stopped and latched at a quarter edge.

F2: after a run edge the loop is held at E until the meter shows the step
(EDGE_HOLD_S, longer while the battery still moves). The replay gained a
lag model (dead time, reading age, P1 debounce, 1 s cycles) that shows
TEST-23's overshoot without the hold.

F3: a charge refuser's SAFETY-73 probe inside a cheap run is a charger
pinned at PROBE_W, the others rest, so the probe actually runs.

Modes off stays byte-identical to 5a4cdc2 (new late-P1 identity scenarios).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:22:32 +02:00

7.3 KiB

OPT-11 - battery modes, per battery

A battery's mode (on/off, Solar first, Leftover solar) is a soft unit limit at P_MODE = 35 that always holds 0 W. A SET above 35 (a safety stop) overrides it; a limit above 35 (the peak guard) makes it give way only as far as that limit needs, and a battery its mode holds back then carries only what the others cannot (arbiter._unit_modes(), _site_mode()). Design and owner answers: goodwe-session/OPT-11-design.md (2026-09-27); P1 is its section 6 P1, and the pull request lists what was measured.

  • Off by default (battery_modes_enabled); off, commands are byte-identical to a5dddad (test_modes.py part 1).
  • Off is a mode range, so the peak guard can still use an Off battery when the others cannot cover the floor (design sections 2-3). Whether Off should be absolute is an open owner question.
  • Review (PR #149): releasing only when the peak floor bound S let a trade won above the floor keep the modes and place less than the floor, and let a capped grid charge release an Off battery. Both are graded in test_arbiter.py part 11. The loop bound leaves a side that reaches the rail to the rail, so SAFETY-03 still flags a loop winding into it.
  • P2 (HA entities): a select and a switch per battery, only while modes are on; commands go through handle_action(), the page's path. A retained command is dropped, not applied: the store already holds the last choice, and a replay after a restart could otherwise undo a newer one. The config topics announced are kept in the store (mqtt_mode_configs), so turning modes off removes exactly those, and an install that never turned them on publishes what ed739d4 did (test_mqtt.py part 10). A removed topic stays in that list and its empty config is re-sent on each start, so a start with no broker cannot lose it; re-sending is harmless. The HA options are the page's English names, pinned by a test, and are not renamed with the page: automations send them.
  • In P1 only Off closes a direction; Leftover solar differs by charging last.
  • P3 (price runs, priceruns.py, modetrade.py): each battery's range is narrowed by its role this tick (hold, give, charge, rest, sell); main.py files the intersection with the switch, so it still holds 0 W. With modes on the site trade is filed by the modes, sized from the loop bound (so no battery a mode or a refusal holds is asked for watts, P1's unmet share) and the guard's headroom; arbitrage.decide() runs only with modes off, which stays byte-identical to 7a00f12 (test_modes_p3.py part 1).
  • SAFETY-46 item 2 is decided as the owner answered (2026-09-25): a trade is a run of consecutive firm slots. It applies with battery modes on; the modes-off trade keeps its one-period shape, because that path must not move.
  • Choices that were measured, not assumed (tools/opt11p3_replay.py):
    • a sale needs the guard to have measured the quarter, not to be clear: a 5 kW discharge alone reads "engaged" on export and flipped the sale every 5 s;
    • a grid charge makes the others rest, so it starts only at 500 W and, once the guard or the headroom stops it, stays off for that 15-minute period (without: 13 flips in 4 minutes under a 3.5 kW house);
    • stored energy is valued at the last cheap run for 12 h, and a charge needs its own run to clear the spread (without: a night charge was spent on flat morning prices, then charging went on at them);
    • a run edge reseeds the loop at the house's level (a 3 kW import spike removed) and, since OPT-15, holds it there until the meter shows the step.
  • Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3 Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period trade 18.14. Review of #153 (same model, with a per-action split):
    • the cheapest hours were at midday on 15 of 16 days, so Leftover solar (acting as Cheap hours until P4) bought 12.2 kWh there that the sun would have given: 7 kWh more exported, 12 kWh less solar stored. Holding for the expensive hours saved 4 kWh of import, not enough. With no grid charge on Leftover solar: 19.44;
    • the old trade charged one hour, so the afternoon sun still fit;
    • a sale earned the wholesale price (2.27 EUR for 11.4 kWh) and the house then bought the same energy back (2.29 EUR): a wash before wear. It pays only when the sale hour's wholesale price beats the later import price, levies included (about 0.13 EUR/kWh above that hour's wholesale here);
    • valuing the charge left at the end, wear and real Flemish levies make selling worse and do not change the order.
  • Owner decision 2026-09-29 (option A): selling is off until it can be judged on the price the grid pays (packmode.SELLING_ENABLED, code kept), and Leftover solar takes solar only until P4's forecast (packmode.GRID_CHARGE_MODES); Cheap hours only still charges from the grid. Replayed (16 days, as above): Solar first + Leftover solar 19.44 EUR net against P2's 19.33; Solar first + Cheap hours only 19.57.
  • Not done in P3: the forecast (P4), and a headroom too short for the run moving the rest to the next cheap periods.
  • OPT-15 (TEST-23 findings on 5a4cdc2, test_price_run_fixes.py):
    • F1, a charge stopped at a quarter edge and latched for the quarter. The P1 averager opens a quarter only when a sample arrives, and the HA source builds one 0.35 s after the reading, so the cycle on the boundary still saw the old block: "no seconds left", headroom None, "no trade (headroom)", latched for the new quarter. With modes on the guard now projects the quarter now is in (PeakTracker.project(roll=True), crediting the held sample as the averager will), and only a measured stop latches. Modes off still reads the old block (identity).
    • F2, -1120 W export at a charge end against -257 W modelled. The replay acted every 5 s on readings of that instant, and by then the lagged battery had nearly arrived, so it never acted on a reading that still showed the trade. The add-on cycles on each meter update (1 s on the rig) on readings 1-2 s old: the two cycles after the reseed saw the trade's +3300 W and slewed E up 555 W each. The loop now holds E until the meter reaches the target (or EDGE_HOLD_S, 18 s; to 36 s while the battery still moves). The replay gained Lag (dead time, reading age, the P1 debounce, 1 s cycles). Charge end, 800 W house: -10 to -51 W over eight lag variants; without the hold -1370 W (meter 2 s).
    • F3, a charge refusal in a cheap run was never probed: the SAFETY-73 pin stands only while the site asks that way, and nothing asked to charge. Probing was chosen over changing the card: the probe is 200 W for 34 s each 15 min under the same peak gates, and it wins back the run. In its probe window the refusing battery is a charger, pinned at 200 W by its own claim, and the others rest. At one inverter SAFETY-52 already ends a charge refusal as soon as the house asks for power.
    • Not fixed, same on 5a4cdc2: with lagged readings a headroom-sized quarter banks up to 1.4 W over the aim (48.6 W under the cap), and a battery reading older than the meter can make a charge start look like a house over the aim, a real guard stop.