The probe's trade was sized at the probing battery's rating. Its SAFETY-73
pin held 200 W of that, and the allocator put the rest on a battery with no
mode range (one in a maintenance phase): TEST-23 OPT-15 g7, -2300 W on A in
its maintenance charge for 34 s, grid 3100 W.
modetrade.decide() now asks PROBE_W for the probing battery plus what the
other chargers would have had without the probe; size() adds the probe on top
of their share, or files PROBE_W exactly, or nothing. _mode_trade() carries
the pin only on the ticks it is filed, so the window's first and last cycle
do not move another battery's share either. Modes off and modes on with no
probe are unchanged (bf9019e digests). Tests: test_opt20.py.
Co-Authored-By: Claude <noreply@anthropic.com>
204 lines
14 KiB
Markdown
204 lines
14 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).
|
|
- OPT-20 (TEST-23 OPT-15 g7, `test_opt20.py`): a probe asks only PROBE_W.
|
|
The trade was sized at the probing battery's rating (2500 W), B's pin held
|
|
200 W of it, and the allocator put the other 2300 W on A, in its
|
|
maintenance charge phase (no mode range there, so nothing kept it out):
|
|
grid 3100 W for 34 s. Now the ask is PROBE_W for the probing battery plus
|
|
what the other chargers would have had without the probe (none: 200 W
|
|
alone), and it carries the pin only on the ticks the pin is filed (the
|
|
window's first and last cycle moved another battery's share by 200 W).
|
|
- Chosen: the probe sits on top of a headroom-sized charge, so beside a
|
|
normal charger its command is the same and the grid rises 200 W (about
|
|
7 W on the quarter) rather than that charger giving 200 W up. A probe
|
|
that the headroom or the bound cannot hold at 200 W is not filed.
|
|
- Unchanged, same on bf9019e: with a charge ask the other batteries cannot
|
|
give (one sign), so an idle-looking A that gives the house stops for the
|
|
window and the grid rises by what it gave. The trade line now says 200 W
|
|
for a probe alone; its words are UX-38's. The base P4 F4 path (a normal
|
|
trade on a battery in maintenance charge) is not touched.
|
|
|
|
## 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.
|