Two survivors of the review's mutations get a check each in
test_price_run_fixes.py (X1: a held export credited negative in the rolled
quarter; X4: the hold extended by a reading already past E). DECISIONS: the
slow-battery quarter (3456 W, same on e3e4ba6) and the replay of a
maintenance phase starting during the hold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
187 lines
12 KiB
Markdown
187 lines
12 KiB
Markdown
# 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, merged up to e3e4ba6,
|
|
`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).
|
|
Since SAFETY-91, `next_quarter()` does the same for the guard at two
|
|
inverters (no stop there without the roll, same banked quarters); the
|
|
roll is what covers one inverter, where the old projection stops the
|
|
charge at every edge. With modes on at one inverter the roll also removes
|
|
the 1 s 0 W command at each quarter edge that SAFETY-81's sim showed, so
|
|
its modes-on digests are re-pinned (modes off equal to e3e4ba6). A
|
|
rolled projection is not graded by SAFETY-81's alarm (the old edge tick
|
|
read "no seconds left"), so a standing overload is still notified once
|
|
per quarter.
|
|
- 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). On e3e4ba6 the same
|
|
numbers hold. The hold gives the loop the target as its grid, so it
|
|
yields to SAFETY-95's drain tail, and any other claim that moves the
|
|
command ends it.
|
|
- 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. A monthly drain (SAFETY-78) still comes
|
|
first, and with a day plan a probing Leftover solar battery goes only to
|
|
its planned level. At one inverter SAFETY-52 already ends a charge
|
|
refusal as soon as the house asks for power.
|
|
- Not fixed, same on e3e4ba6: 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.
|
|
- Review: an unmeasured stop that never latched let a headroom missing
|
|
every other cycle flip the charge 1799 times in 30 min, so the second one
|
|
in a quarter latches (at most 4 changes a quarter). A house that rises
|
|
1, 2 or 3 kW as the charge ends, from 30 s into the quarter to 15 s
|
|
before its end (two inverters, lagged readings): the hold adds at most
|
|
+33 W to that quarter against the loop without it, and the most any
|
|
quarter banks is 3431 W with the charge sized to the aim (3450 W; the cap
|
|
is 3500 W), 3456 W with a slow 8 s battery (the lag's own, the same on
|
|
e3e4ba6). The import during the hold is the rise, below the charge's
|
|
own, and a rise past that is the peak floor's, which ends the hold.
|
|
A maintenance drain, hold or charge that starts during the hold ends it
|
|
on that tick (replayed at edge + 3 s).
|
|
|
|
## P4: the solar forecast and the day plan (2026-09-29)
|
|
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
|
|
today; `pv_forecast_tomorrow_entity` optional), read by `app/solarfc.py`.
|
|
Supported shapes: Forecast.Solar (state only), Solcast (`detailedForecast`)
|
|
and Open-Meteo Solar Forecast (`wh_period`). The unit is read, not guessed;
|
|
older than 3 h is stale (one missed hourly poll must not flag it).
|
|
- Trust: `solar_trust_pct`, default 70 (owner answer 5).
|
|
- House use: nothing the add-on recorded fitted. `tariff.db` holds grid
|
|
import per quarter, which the batteries shape. So `dayplan.HouseProfile`
|
|
keeps grid + battery per local hour for 7 days in the store
|
|
(`house_profile`), median per hour. It is net of the solar the house used,
|
|
so the solar sum never takes it below the night median. Solar readings are
|
|
not used (TEL-11). The first plan needs one day of it.
|
|
- Verdict (`dayplan.plan()`): trust x sum max(0, forecast - house) x 0.95
|
|
until the expensive run the cheap run is for. Solar first takes it first.
|
|
- Leftover solar may then charge from the grid up to min(ceiling, floor +
|
|
the house's use in the expensive runs / 0.95), less its sun share: only
|
|
the shortfall.
|
|
- Cheap hours only ignores the plan and fills to the ceiling (design
|
|
section 1; review of #156, see the replay below).
|
|
- Solar first never charges from the grid (owner, 2026-09-29).
|
|
- The level is kept unless it moves by min(1 kWh, 10 %). With no plan
|
|
(no, stale or failed forecast, no house profile, no capacity) every mode
|
|
is P3 option A exactly: `test_modes_p4.py` part 1 pins 12 digests from
|
|
5a4cdc2 (modes on, with and without an unavailable forecast).
|
|
- The plan is made with the runs (every 15 min, or at once when the forecast
|
|
comes or goes). A lost forecast drops the levels at the next cycle.
|
|
- Not done: the design's scaling by measured solar from 10:00 (it needs the
|
|
solar readings TEL-11 keeps out), the HA `goodwe_<inv>_plan` sensor, and
|
|
FCST-01.
|
|
- Replay (`tools/opt11p4_replay.py`, 16 recorded days, Solar first + Leftover
|
|
solar, net EUR, lower is better; site solar rebuilt from the recording,
|
|
forecast errors from Elia's day-ahead Belgian solar forecast):
|
|
- year-earlier hourly prices: P2 19.33, P3 option A 19.44, old one-period
|
|
trade 18.14, P4 19.15 (perfect forecast 19.14, 1.5 x too high 19.16,
|
|
missing 19.44);
|
|
- the days' real 2026 quarter-hourly prices (held out): P2 32.26, option A
|
|
32.01, old trade 31.40, P4 29.19 (perfect 29.21, too high 29.28, missing
|
|
32.01).
|
|
- Solar first + Cheap hours only, skipping its sun share (the first
|
|
build): 19.57 (same as P3) and 29.72 against P3's 29.41. The shorter,
|
|
moved runs left B lower, so later midday top-ups rested A while the sun
|
|
shone. Cheap hours now ignores the plan, which is P3 exactly.
|
|
- A forecast of no sun at all gives 19.14 and 29.33. So nearly all of the
|
|
gain is Leftover solar buying the evening's shortfall in the cheap run;
|
|
the forecast itself was worth 0.14 EUR in 16 September days.
|
|
- Bias: the site's solar is rebuilt from the same net use the replay
|
|
plays, and "recorded" keeps its hourly shape and adds only the national
|
|
daily error. Both favour P4; "national" (no house leakage) gives 29.25.
|
|
No threshold was tuned to these days. The 2026-09-24 PV recording could not
|
|
be replayed: it is only in the live Home Assistant.
|