Supported shapes: Forecast.Solar (core; state only), Solcast PV Forecast (detailedForecast/detailedHourly), Open-Meteo Solar Forecast (wh_period). Any kWh/Wh/MWh sensor works as state only (spread over a 06–20 h bell).
The unit is read, never guessed. Stale after 3 h. Unavailable, bad or stale means no forecast (never 0).
Validation: rule 13 refuses a trust outside 30–100 or a non-sensor. id. Settings notes flag a sensor not in kWh, a forecast set with modes off, and tomorrow's without today's. Option texts are EN/NL; validation text is EN, as the rest of validate.py.
House profile (dayplan.HouseProfile). Nothing already recorded fitted: tariff.db keeps only grid import per quarter, which the batteries shape.
So the add-on now keeps grid + battery per local hour, 7 days, in the store (house_profile), and uses the median per hour.
It is net of self-used solar, so the solar sum never takes it below the night median.
Solar readings are not used (TEL-11). The first plan needs one day.
Verdict per battery (dayplan.plan()): sun = trust × Σ max(0, forecast − house) × 0.95 until the expensive run the cheap run is for. Solar first takes it first.
Leftover solar charges from the grid only up to min(ceiling, floor + house use in the expensive runs / 0.95), less its sun share: the shortfall, not 95 %.
Cheap hours only: ceiling less its sun share.
Solar first never grid-charges.
The cheap run is sized by the shortfall, so it is the cheapest short window. Wired through modetrade.charge_to(); targets=None is P3 exactly.
Re-check and hysteresis.
The plan is rebuilt with the runs (every 15 min, and at once when the forecast comes or goes).
A level moves only by min(1 kWh, 10 %).
A moved level reopens the run for that battery; a lost forecast drops the levels on the next cycle.
Missing or stale forecast, or no profile yet: P3 option A exactly (Leftover solar solar only, Cheap hours to the ceiling), with a status line.
UI. Batteries screen: Why "Enough sun is forecast, so it does not charge from the grid." / "Too little sun is forecast, so it charges some from the grid." / "No solar forecast, so it charges from solar only." (while holding).
Today's grid block is the shortfall.
A forecast line: "Solar forecast today: 9.2 kWh, we count on 6.4 kWh. Updated 10:15."
Settings Simple gets the forecast card with the same line. All in EN/NL, STYLE-checked.
HA goodwe_<inv>_plan sensor: left for later (needs a new MQTT text entity and test_mqtt payload diff; not cheap).
Invariants kept. Sizing, peak guard, refusal, maintenance, Off and selling paths are unchanged; P4 only narrows which batteries may charge and how far.
Replay: does it pay? (tools/opt11p4_replay.py, same engine as P3's; net EUR over 16 recorded days 2026-09-02..17, lower is better; "after" values the charge left at the end at the median all-in price)
P4 beats P2 and option A on both price sets (−0.18 / −0.29 EUR on the P3 set; −3.07 / −2.82 on real 2026 prices).
It does not beat the old one-period trade on the P3 set (19.15 against 18.14); it does on real 2026 prices.
Cheap hours with the forecast is worse than P3's Cheap hours on real prices (29.72 against 29.41). Leftover solar is the mode the forecast pays for.
Forecasts (the site's solar was not recorded on these days):
site solar is rebuilt from the recording as max(0, night median − net use);
recorded: that × the real daily error of Elia's day-ahead Belgian solar forecast (energy-charts; daily error 1–32 %, median about 6 %);
national: the Belgian day-ahead forecast scaled to the site. It includes local weather: 09-06 +72 %, 09-02 −23 %;
data in scenarios/energy-charts-be-solar-price-2026-09-01_2026-09-17.json (CC BY 4.0).
No threshold was tuned: trust 70, hysteresis 1 kWh/10 %, stale 3 h and one day of profile are the design's values, fixed before any replay. The real-2026 price set was not used by P3.
The PV week from 2026-09-24 could not be replayed. It is only in the live HA recorder (.158, off limits) and the encrypted backups. The tool takes any net-house recording, so an export can be replayed later.
Evidence (goodwe-addon-checks:1, --network none)
Identity (tools/opt11p4_identity.py, real Controller, P1 meter, cheap and dear slots, modes on):
12 scenarios (N=1 LS/CH; N=2 SF+LS, CH+LS, LS+LS, SF+CH), each without a forecast entity and with one reading unavailable plus a ready house profile. All 12 digests equal 5a4cdc2's (pinned in test_modes_p4.py part 1).
It bites: Leftover solar grid-charging without a plan moves n2_sf_ls; an unavailable forecast read as sunny moves n2_sf_ch_fc_down.
The P1/P3/SAFETY-79 modes-off digests still pass.
Invariants (test_modes_p4.py part 6, real controller, night + day). Checked in sunny, dark, Cheap hours partly, forecast lost mid-run and forecast too high, plus refusal and Off:
no opposite-direction split;
Solar first's share never < 0 during a grid charge;
every banked quarter with a grid charge ≤ cap − margin (max 3300 W, aim 3450);
a charge refusal ends B's charging; an Off battery never charges;
no plan without a house profile; selling stays off.
16-day replay (charge quarters, plant mean): SF+LS max 3451 W, SF+CH 3463 W (P3 SF+CH on the same prices: 3455 W), all under the 3500 W cap.
Pure fuzz: chargers() with no plan equals P3 option A's rule on 20 000 rosters; with a plan, 0 of 20 000 break the level/ceiling/SF/refusal rules; decide() 0 of 5000.
Full suite locally: 51/51 pass (test_freeze with the sim checkout).
Mutations (purged copy each, python3 -B, graded by test_modes_p4.py): 26, all red
M1 SF gets a level · M2 level not capped by the ceiling · M3 LS charges with no plan · M4 chargers ignore the level · M5 run sized to the ceiling, not the shortfall · M6 stale forecast accepted · M7 unit guessed as kWh · M8 unavailable read as 0 kWh · M9 levels kept when the forecast goes · M10 no hysteresis · M11 SF room not taken first · M12 trust ignored · M13 LS not capped by the house's use · M14 Off/maintenance batteries planned · M15 tomorrow's unknown sun counted as 0 · M16 house profile always ready · M17 night floor removed · M18 forecast read with modes off · M19 trust range not checked · M20 non-sensor entity accepted · M21 Why lines ignore the plan · M22 forecast line dropped · M23 levels not passed to decide() · M24 run mark not reopened by a moved level · M25 Today grid block ignores the plan · M26 forecast arrival waits for the next 15-min slot.
M9, M11, M12, M17, M18, M24, M25 and M26 survived at first; tests were added, and all are now red.
Not done / risks for the reviewer
Not done:
the design's intra-day scaling by measured solar (needs the PV readings TEL-11 keeps out of control);
the HA plan sensor;
FCST-01;
replaying the 2026-09-24 PV week;
OPT-15 (untouched).
Money risk, not safety: a forecast too high means less grid charge, so the evening is covered less (replay: +0.01 to +0.09 EUR over 16 days). Too low means buying what the sun then gives.
New store keyhouse_profile (about 7 × 24 floats), written once per hour, only with modes on and a forecast entity set.
A battery without capacity_kwh gets no plan (P3 option A). DOCS says so.
The Leftover-solar cap uses the profile's use in the expensive runs. On a site whose give runs are short, Leftover solar buys little. That is intended ("not up to 95 %"), but it is a design choice beyond the brief's wording.
No CHANGELOG entry: no OPT-11 phase has one; the release cut writes it.
OPT-11 phase P4 (design `goodwe-session/OPT-11-design.md` §1 "Excess solar", §2, §6 P4; owner answers; owner decision 2026-09-29 option A). Base: release/1.0 5a4cdc2.
## What it does
1. **Forecast input** (`app/solarfc.py`, pure). `pv_forecast_entity` (kWh left today), `pv_forecast_tomorrow_entity` (optional), `solar_trust_pct` (30–100, default 70).
- Supported shapes: **Forecast.Solar** (core; state only), **Solcast PV Forecast** (`detailedForecast`/`detailedHourly`), **Open-Meteo Solar Forecast** (`wh_period`). Any kWh/Wh/MWh sensor works as state only (spread over a 06–20 h bell).
- The unit is read, never guessed. Stale after 3 h. Unavailable, bad or stale means no forecast (never 0).
- Validation: rule 13 refuses a trust outside 30–100 or a non-`sensor.` id. Settings notes flag a sensor not in kWh, a forecast set with modes off, and tomorrow's without today's. Option texts are EN/NL; validation text is EN, as the rest of validate.py.
2. **House profile** (`dayplan.HouseProfile`). Nothing already recorded fitted: `tariff.db` keeps only grid import per quarter, which the batteries shape.
- So the add-on now keeps grid + battery per local hour, 7 days, in the store (`house_profile`), and uses the median per hour.
- It is net of self-used solar, so the solar sum never takes it below the night median.
- Solar readings are not used (TEL-11). The first plan needs one day.
3. **Verdict per battery** (`dayplan.plan()`): sun = trust × Σ max(0, forecast − house) × 0.95 until the expensive run the cheap run is for. Solar first takes it first.
- **Leftover solar** charges from the grid only up to min(ceiling, floor + house use in the expensive runs / 0.95), less its sun share: the shortfall, not 95 %.
- **Cheap hours only**: ceiling less its sun share.
- **Solar first** never grid-charges.
- The cheap run is sized by the shortfall, so it is the cheapest short window. Wired through `modetrade.charge_to()`; `targets=None` is P3 exactly.
4. **Re-check and hysteresis.**
- The plan is rebuilt with the runs (every 15 min, and at once when the forecast comes or goes).
- A level moves only by min(1 kWh, 10 %).
- A moved level reopens the run for that battery; a lost forecast drops the levels on the next cycle.
5. **Missing or stale forecast, or no profile yet:** P3 option A exactly (Leftover solar solar only, Cheap hours to the ceiling), with a status line.
6. **UI.** Batteries screen: Why "Enough sun is forecast, so it does not charge from the grid." / "Too little sun is forecast, so it charges some from the grid." / "No solar forecast, so it charges from solar only." (while holding).
- Today's grid block is the shortfall.
- A forecast line: "Solar forecast today: 9.2 kWh, we count on 6.4 kWh. Updated 10:15."
- Settings Simple gets the forecast card with the same line. All in EN/NL, STYLE-checked.
- HA `goodwe_<inv>_plan` sensor: **left for later** (needs a new MQTT text entity and test_mqtt payload diff; not cheap).
7. **Invariants kept.** Sizing, peak guard, refusal, maintenance, Off and selling paths are unchanged; P4 only narrows which batteries may charge and how far.
## Replay: does it pay? (`tools/opt11p4_replay.py`, same engine as P3's; net EUR over 16 recorded days 2026-09-02..17, lower is better; "after" values the charge left at the end at the median all-in price)
| variant | year-earlier hourly prices (P3 set) | after | real 2026 quarter-hourly prices (held out) | after |
|---|---|---|---|---|
| P2 baseline SF+LS (7a00f12) | **19.33** | 20.77 | 32.26 | 34.61 |
| old one-period trade (7a00f12) | **18.14** | 19.58 | 31.40 | 33.75 |
| P3 option A SF+LS (5a4cdc2) | **19.44** | 20.81 | 32.01 | 34.36 |
| **P4 SF+LS, recorded forecast** | **19.15** | 20.52 | **29.19** | 31.54 |
| P4, national forecast (harder) | 19.15 | 20.52 | 29.25 | 31.60 |
| P4, perfect forecast | 19.14 | 20.51 | 29.21 | 31.56 |
| P4, forecast 1.5 × too high | 19.16 | 20.53 | 29.28 | 31.63 |
| P4, forecast missing | 19.44 (= option A) | 20.81 | 32.01 (= option A) | 34.36 |
| P3 SF+CH (5a4cdc2) | 19.57 | | 29.41 | 31.76 |
| P4 SF+CH, recorded forecast | 19.57 | 20.94 | 29.72 | 32.07 |
- **P4 beats P2 and option A on both price sets** (−0.18 / −0.29 EUR on the P3 set; −3.07 / −2.82 on real 2026 prices).
- **It does not beat the old one-period trade on the P3 set** (19.15 against 18.14); it does on real 2026 prices.
- **Cheap hours with the forecast is worse than P3's Cheap hours on real prices** (29.72 against 29.41). Leftover solar is the mode the forecast pays for.
- **Forecasts** (the site's solar was not recorded on these days):
- site solar is rebuilt from the recording as max(0, night median − net use);
- **recorded**: that × the real daily error of Elia's day-ahead Belgian solar forecast (energy-charts; daily error 1–32 %, median about 6 %);
- **national**: the Belgian day-ahead forecast scaled to the site. It includes local weather: 09-06 +72 %, 09-02 −23 %;
- data in `scenarios/energy-charts-be-solar-price-2026-09-01_2026-09-17.json` (CC BY 4.0).
- **No threshold was tuned**: trust 70, hysteresis 1 kWh/10 %, stale 3 h and one day of profile are the design's values, fixed before any replay. The real-2026 price set was not used by P3.
- **The PV week from 2026-09-24 could not be replayed.** It is only in the live HA recorder (.158, off limits) and the encrypted backups. The tool takes any net-house recording, so an export can be replayed later.
## Evidence (goodwe-addon-checks:1, `--network none`)
- **Identity** (`tools/opt11p4_identity.py`, real Controller, P1 meter, cheap and dear slots, **modes on**):
- 12 scenarios (N=1 LS/CH; N=2 SF+LS, CH+LS, LS+LS, SF+CH), each without a forecast entity and with one reading `unavailable` plus a ready house profile. **All 12 digests equal 5a4cdc2's** (pinned in `test_modes_p4.py` part 1).
- It bites: Leftover solar grid-charging without a plan moves n2_sf_ls; an unavailable forecast read as sunny moves n2_sf_ch_fc_down.
- The P1/P3/SAFETY-79 modes-off digests still pass.
- **Invariants** (`test_modes_p4.py` part 6, real controller, night + day). Checked in sunny, dark, Cheap hours partly, forecast lost mid-run and forecast too high, plus refusal and Off:
- no opposite-direction split;
- Solar first's share never < 0 during a grid charge;
- every banked quarter with a grid charge ≤ cap − margin (max 3300 W, aim 3450);
- a charge refusal ends B's charging; an Off battery never charges;
- no plan without a house profile; selling stays off.
- 16-day replay (charge quarters, plant mean): SF+LS max 3451 W, SF+CH 3463 W (P3 SF+CH on the same prices: 3455 W), all under the 3500 W cap.
- **Pure fuzz:** chargers() with no plan equals P3 option A's rule on 20 000 rosters; with a plan, 0 of 20 000 break the level/ceiling/SF/refusal rules; decide() 0 of 5000.
- **Full suite locally: 51/51 pass** (test_freeze with the sim checkout).
## Mutations (purged copy each, `python3 -B`, graded by test_modes_p4.py): 26, all red
M1 SF gets a level · M2 level not capped by the ceiling · M3 LS charges with no plan · M4 chargers ignore the level · M5 run sized to the ceiling, not the shortfall · M6 stale forecast accepted · M7 unit guessed as kWh · M8 unavailable read as 0 kWh · M9 levels kept when the forecast goes · M10 no hysteresis · M11 SF room not taken first · M12 trust ignored · M13 LS not capped by the house's use · M14 Off/maintenance batteries planned · M15 tomorrow's unknown sun counted as 0 · M16 house profile always ready · M17 night floor removed · M18 forecast read with modes off · M19 trust range not checked · M20 non-sensor entity accepted · M21 Why lines ignore the plan · M22 forecast line dropped · M23 levels not passed to decide() · M24 run mark not reopened by a moved level · M25 Today grid block ignores the plan · M26 forecast arrival waits for the next 15-min slot.
- M9, M11, M12, M17, M18, M24, M25 and M26 survived at first; tests were added, and all are now red.
## Not done / risks for the reviewer
- **Not done:**
- the design's intra-day scaling by measured solar (needs the PV readings TEL-11 keeps out of control);
- the HA plan sensor;
- FCST-01;
- replaying the 2026-09-24 PV week;
- OPT-15 (untouched).
- **Money risk, not safety:** a forecast too high means less grid charge, so the evening is covered less (replay: +0.01 to +0.09 EUR over 16 days). Too low means buying what the sun then gives.
- **New store key** `house_profile` (about 7 × 24 floats), written once per hour, only with modes on and a forecast entity set.
- **A battery without `capacity_kwh`** gets no plan (P3 option A). DOCS says so.
- **The Leftover-solar cap uses the profile's use in the expensive runs.** On a site whose give runs are short, Leftover solar buys little. That is intended ("not up to 95 %"), but it is a design choice beyond the brief's wording.
- **No CHANGELOG entry:** no OPT-11 phase has one; the release cut writes it.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
A Home Assistant solar forecast (Forecast.Solar, Solcast or Open-Meteo
shapes) and the add-on's own hourly house use decide, before each cheap
run, whether the sun will fill each battery before the expensive hours.
Leftover solar then buys from the grid only the shortfall; Cheap hours only
skips the part the sun covers; Solar first never grid-charges. No, stale or
failed forecast: P3 option A exactly (12 digests pinned from 5a4cdc2).
Replay (16 recorded days, forecast errors from Elia's day-ahead BE solar):
year-earlier prices P4 19.15 vs P2 19.33 / option A 19.44 / old trade 18.14;
real 2026 prices P4 29.19 vs 32.26 / 32.01 / 31.40 EUR net.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Cheap hours only fills to the ceiling with or without a forecast, as the
design (section 1) says. Skipping the sun's share cost 0.31 EUR on real
2026 prices (29.72 against P3's 29.41) and saved nothing on the P3 set.
- A forecast reading from before local midnight is stale: until the
integration refreshes, "left today" is still yesterday's 0 and
"tomorrow" is really today.
- Forecast entity ids must be sensor.[a-z0-9_]+ (they go into the
/api/states/<id> path).
- House profile: a stored day that is not a date, or is after today, is
dropped instead of kept forever.
- Text: the forecast line says "for the rest of today"; the learning line
no longer promises "tomorrow" (a plan needs one full day).
- Tests for four surviving review mutants (hour sample floor, the dear run
after the cheap run, share by room, daytime use) and the fixes above.
- Replay tool: `--fc zero` (a forecast of no sun).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- DECISIONS: the Cheap hours change and why; the no-sun replay (the gain is
Leftover solar buying the evening's shortfall; the forecast added 0.14
EUR); the reconstruction bias stated plainly.
- DOCS: Cheap hours ignores the forecast; a reading from before midnight
is stale.
- The forecast line: "Solar still to come today: ..." (it is what is
left, and it stays within the 12-word limit).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: modetrade.decide() takes both the day plan (targets) and
SAFETY-78's draining flag; the Why line keeps SAFETY-78's other_cycle
ahead of the plan's word (test added).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #156, part 1: does P4 pay? (for the owner)
I re-ran the replay myself (goodwe-addon-checks:1, --network none) on the dev's head and again on the final head b171ada (after merging SAFETY-81 and SAFETY-78). Net EUR over 16 days, lower is better:
Variant
P3 price set
Real 2026 prices
P3 option A, Solar first + Leftover solar
19.44
32.01
P4, Solar first + Leftover solar (recorded forecast)
19.15
29.19
P4, national forecast (no house leakage)
19.15
29.25
P4 with a forecast of no sun at all (new --fc zero)
19.14
29.33
P3 Cheap hours
19.57
29.41
P4 Cheap hours, as built
19.57
29.72
P4 Cheap hours, after my fix (ignores the plan)
19.57
29.41
The dev's numbers reproduce exactly.
Where the money comes from.
Almost none of it comes from the forecast. A forecast of no sun at all does as well (19.14 and 29.33).
The gain (0.29 and 2.8 EUR) comes from Leftover solar buying only the evening's shortfall in the cheap run. P3 option A never let it buy anything.
The forecast itself added 0.14 EUR on real prices over 16 days in September. It may matter more in sunnier months, but these days do not show that.
Bias in the method, stated plainly. Both parts favour P4:
The site's solar is rebuilt as the night median minus the recorded net use. The replay plays that same net use back as the house. So the "recorded" forecast carries the day's real house load: that is leakage.
"Recorded" keeps the true hourly shape and adds only the national daily error, which is smaller than one roof's error.
The "national" variant has no house leakage. It still uses one scale factor fitted on all 16 days, so it is still slightly optimistic. It gives 29.25.
Because a forecast of no sun is just as good, the bias does not change the verdict.
The house profile sees only the past, so the plan does not see future house load through the profile.
Cheap hours was worse (29.72 against 29.41). Not a code bug, but a design deviation. Fixed.
The design (section 1) says Cheap hours ignores the forecast. The build gave it a level of "ceiling minus its sun share".
Day by day: its shorter, moved runs left battery B lower, and later midday top-ups followed. While B charges from the grid, Solar first A rests and cannot take the sun, so that sun is exported. 09-08 alone cost 0.98 EUR.
In fbd8847, Cheap hours only fills to the ceiling with or without a forecast. Replay: 29.41 and 19.57, the same as P3.
In plain words, for the owner:
With a solar forecast, the Leftover solar battery now buys only what the house needs in the expensive evening hours, in the cheapest hours. Over 16 September days, that saved about 2.80 EUR on this year's real prices and 0.29 EUR on last year's prices, compared with today's "solar only". The forecast itself did little in September, and most of the saving would come even without it. Cheap hours only stays as it is today.
Is this worse than option A for Leftover solar? No. It is better on both price sets, with the same worst quarter under the peak limit (3451 W against a 3450 W aim and a 3500 W limit).
## Review of #156, part 1: does P4 pay? (for the owner)
I re-ran the replay myself (`goodwe-addon-checks:1`, `--network none`) on the dev's head and again on the final head b171ada (after merging SAFETY-81 and SAFETY-78). Net EUR over 16 days, lower is better:
| Variant | P3 price set | Real 2026 prices |
|---|---|---|
| P3 option A, Solar first + Leftover solar | 19.44 | 32.01 |
| **P4, Solar first + Leftover solar** (recorded forecast) | **19.15** | **29.19** |
| P4, national forecast (no house leakage) | 19.15 | 29.25 |
| P4 with a forecast of **no sun at all** (new `--fc zero`) | 19.14 | 29.33 |
| P3 Cheap hours | 19.57 | 29.41 |
| P4 Cheap hours, as built | 19.57 | 29.72 |
| P4 Cheap hours, after my fix (ignores the plan) | 19.57 | 29.41 |
The dev's numbers reproduce exactly.
**Where the money comes from.**
- Almost none of it comes from the forecast. A forecast of no sun at all does as well (19.14 and 29.33).
- The gain (0.29 and 2.8 EUR) comes from Leftover solar buying **only the evening's shortfall** in the cheap run. P3 option A never let it buy anything.
- The forecast itself added 0.14 EUR on real prices over 16 days in September. It may matter more in sunnier months, but these days do not show that.
**Bias in the method, stated plainly. Both parts favour P4:**
- The site's solar is rebuilt as the night median minus the recorded net use. The replay plays that same net use back as the house. So the "recorded" forecast carries the day's real house load: that is leakage.
- "Recorded" keeps the true hourly shape and adds only the **national** daily error, which is smaller than one roof's error.
- The "national" variant has no house leakage. It still uses one scale factor fitted on all 16 days, so it is still slightly optimistic. It gives 29.25.
- Because a forecast of no sun is just as good, the bias does not change the verdict.
- The house profile sees only the past, so the plan does not see future house load through the profile.
**Cheap hours was worse (29.72 against 29.41). Not a code bug, but a design deviation. Fixed.**
- The design (section 1) says Cheap hours ignores the forecast. The build gave it a level of "ceiling minus its sun share".
- Day by day: its shorter, moved runs left battery B lower, and later midday top-ups followed. While B charges from the grid, Solar first A rests and cannot take the sun, so that sun is exported. 09-08 alone cost 0.98 EUR.
- In fbd8847, Cheap hours only fills to the ceiling with or without a forecast. Replay: 29.41 and 19.57, the same as P3.
**In plain words, for the owner:**
With a solar forecast, the Leftover solar battery now buys only what the house needs in the expensive evening hours, in the cheapest hours. Over 16 September days, that saved about 2.80 EUR on this year's real prices and 0.29 EUR on last year's prices, compared with today's "solar only". The forecast itself did little in September, and most of the saving would come even without it. Cheap hours only stays as it is today.
**Is this worse than option A for Leftover solar?** No. It is better on both price sets, with the same worst quarter under the peak limit (3451 W against a 3450 W aim and a 3500 W limit).
Pure fuzz I wrote myself, 90 000 cases of dayplan.plan/hold and modetrade.chargers/energy/decide: 0 violations. It covers:
every range holds 0;
Solar first never gets a level and never charges;
no charger is at or above its level or above the ceiling;
Off, refusing, offline or maintenance batteries never charge;
no sale role or action;
with no plan, chargers() is exactly P3's rule;
more sun or more trust never raises a level.
Real controller, 5 days on real 2026 prices, with the forecast flipping at random (unavailable, 3x too sunny, 0 kWh, normal) every 5 to 60 minutes. SF+LS and SF+CH:
0 opposite-direction commands;
Solar first never charged during a grid charge;
0 sales, 0 ranges without 0;
Leftover solar never grid-charged without a forecast.
That covers a forecast lost or changed mid-run.
Refusal, maintenance and Off go through the P3 paths, which this PR does not change. allocator/arbiter are untouched here (SAFETY-78's changes arrive by the merge). The Off peak-shortfall rule is P3's and was tested in the P3 TEST-23 report (check 6).
The 3463 W quarter: 2026-09-09 13:15, Cheap hours. The charge was sized by the headroom when the house dipped to 25 W. The house then came back (up to 2.2 kW), and the lagging battery ramp lost 13 W against the aim. That is P3's sizing path: P3 Cheap hours shows 3455 W on 09-13, and my chaos run found 3463 W on P3's path again.
It is still 37 W under the 3500 W limit, inside SAFETY-79's 50 W margin.
With Cheap hours now back on P3 behaviour, the P4 replay's worst is 3455 W.
Follow-up for OPT-15 (the same planner area): the mean can pass the aim by up to about 13 W.
2. house_profile: bounded.
Size: at most 8 days x 24 floats. It is written once per closed hour, and only with modes on and a forecast set.
An hour needs 5 minutes of readings. No reading at all (a battery offline makes site_batt_w None) means no sample, not a wrong one. DST merges or skips an hour, and the other days' medians fill in.
Worst case: a wild day, for example a first day with a party or a car charging. It can only move a level between 0 (option A) and the ceiling (P3 Cheap hours). Charge power is still capped by the headroom, so the risk is money only.
Fixed: a stored day that is not a date, or is after today (a wrong clock once), was kept forever. It is now dropped (tests added).
3. Forecast parsing.
Checked with the real shapes:
Forecast.Solar: state only, kWh.
Solcast: detailedForecast, period_start ISO with an offset, kW per 30 min. It is only on ..._forecast_today/_tomorrow; the remaining_today sensor in DOCS is read as state only, which works.
Open-Meteo: wh_period, Wh.
W, no unit, unavailable, nan, inf, negative and junk attributes are all "no forecast", never 0.
Bug fixed: just after midnight, before the integration refreshes, "left today" is still yesterday's 0 and "tomorrow" is really today. A plan at 00:10 read that as a dark day. A reading from before local midnight is now stale (test added).
Injection: the id goes into /api/states/<id>. Rule 13 now needs sensor.[a-z0-9_]+, so a path such as sensor.x/../config is refused (test).
Not verified offline: whether Open-Meteo's wh_period keys are period start or end. At worst it shifts the split by an hour.
Low: the state-only bell counts UTC hours from midnight, so it is 1 h off on the 2 DST days.
4. Validation: OK.
English-only validate.py text is the accepted UX-17 scope.
The options are absent on upgrade. The Supervisor fills solar_trust_pct: 70, the entities are empty (skipped), and _number defaults to 70, so there is no startup refusal on upgrade.
5. Identity and mutations.
Identity on 5a4cdc2 and on my head: 12/12 digests equal. Part 1 of the tests still pins them after both merges.
K1 hysteresis max for min, K2 SF room not subtracted, K3 Solcast split not scaled, K7 midnight rule removed: red at once.
K4 hour sample floor 1, K5 plan for a dear run before the cheap run, K6 share by count not room, K8 daytime profile ignored: survived. I added a test for each in part 4, and all four are now red.
6. UI and records.
EN/NL. The forecast line said "today" but shows what is left. It is now "Solar still to come today: 9.2 kWh, we count on 6.4 kWh." (within the 12-word rule).
"The plan starts tomorrow" was untrue when started after about 04:00. It is now "…after one full day."
The Why lines match the plan. Cheap hours now shows no plan word. SAFETY-78's "other cycle" line wins over the plan word (test).
DOCS and DECISIONS are updated with the Cheap hours change, the no-sun result and the reconstruction bias. The STYLE, doc and text tests pass.
Merges: release/1.0 moved twice, first to SAFETY-81 #155 and then to SAFETY-78 #154. I merged both normally. The conflicts in decide(), battery_why and the screen tests keep both sides. draining still blocks every grid charge.
Full suite locally: 53/53 on b171ada. Replays re-run on b171ada: P3 option A 19.44/32.01, P4 SF+LS 19.15/29.19.
## Review of #156, part 2: safety, code and records (fixes pushed as fbd8847, 0b96e31, b171ada)
**1. Safety invariants: OK.**
- Pure fuzz I wrote myself, 90 000 cases of `dayplan.plan`/`hold` and `modetrade.chargers`/`energy`/`decide`: **0 violations**. It covers:
- every range holds 0;
- Solar first never gets a level and never charges;
- no charger is at or above its level or above the ceiling;
- Off, refusing, offline or maintenance batteries never charge;
- no sale role or action;
- with no plan, `chargers()` is exactly P3's rule;
- more sun or more trust never raises a level.
- Real controller, 5 days on real 2026 prices, with the forecast flipping at random (unavailable, 3x too sunny, 0 kWh, normal) every 5 to 60 minutes. SF+LS and SF+CH:
- 0 opposite-direction commands;
- Solar first never charged during a grid charge;
- 0 sales, 0 ranges without 0;
- Leftover solar never grid-charged without a forecast.
- That covers a forecast lost or changed mid-run.
- Refusal, maintenance and Off go through the P3 paths, which this PR does not change. `allocator`/`arbiter` are untouched here (SAFETY-78's changes arrive by the merge). The Off peak-shortfall rule is P3's and was tested in the P3 TEST-23 report (check 6).
- **The 3463 W quarter:** 2026-09-09 13:15, Cheap hours. The charge was sized by the headroom when the house dipped to 25 W. The house then came back (up to 2.2 kW), and the lagging battery ramp lost 13 W against the aim. That is P3's sizing path: P3 Cheap hours shows 3455 W on 09-13, and my chaos run found 3463 W on P3's path again.
- It is still 37 W under the 3500 W limit, inside SAFETY-79's 50 W margin.
- With Cheap hours now back on P3 behaviour, the P4 replay's worst is 3455 W.
- Follow-up for OPT-15 (the same planner area): the mean can pass the aim by up to about 13 W.
**2. `house_profile`: bounded.**
- Size: at most 8 days x 24 floats. It is written once per closed hour, and only with modes on and a forecast set.
- An hour needs 5 minutes of readings. No reading at all (a battery offline makes `site_batt_w` None) means no sample, not a wrong one. DST merges or skips an hour, and the other days' medians fill in.
- Worst case: a wild day, for example a first day with a party or a car charging. It can only move a level between 0 (option A) and the ceiling (P3 Cheap hours). Charge power is still capped by the headroom, so the risk is money only.
- Fixed: a stored day that is not a date, or is after today (a wrong clock once), was kept forever. It is now dropped (tests added).
**3. Forecast parsing.**
- Checked with the real shapes:
- Forecast.Solar: state only, kWh.
- Solcast: `detailedForecast`, `period_start` ISO with an offset, kW per 30 min. It is only on `..._forecast_today`/`_tomorrow`; the `remaining_today` sensor in DOCS is read as state only, which works.
- Open-Meteo: `wh_period`, Wh.
- W, no unit, unavailable, nan, inf, negative and junk attributes are all "no forecast", never 0.
- **Bug fixed:** just after midnight, before the integration refreshes, "left today" is still yesterday's 0 and "tomorrow" is really today. A plan at 00:10 read that as a dark day. A reading from before local midnight is now stale (test added).
- **Injection:** the id goes into `/api/states/<id>`. Rule 13 now needs `sensor.[a-z0-9_]+`, so a path such as `sensor.x/../config` is refused (test).
- Not verified offline: whether Open-Meteo's `wh_period` keys are period start or end. At worst it shifts the split by an hour.
- Low: the state-only bell counts UTC hours from midnight, so it is 1 h off on the 2 DST days.
**4. Validation: OK.**
- English-only validate.py text is the accepted UX-17 scope.
- The options are absent on upgrade. The Supervisor fills `solar_trust_pct: 70`, the entities are empty (skipped), and `_number` defaults to 70, so there is no startup refusal on upgrade.
**5. Identity and mutations.**
- Identity on 5a4cdc2 and on my head: **12/12 digests equal**. Part 1 of the tests still pins them after both merges.
- Dev's mutants, sampled M1, M3, M6, M9, M13 and M24: **6/6 red**.
- My own 8:
- K1 hysteresis max for min, K2 SF room not subtracted, K3 Solcast split not scaled, K7 midnight rule removed: red at once.
- K4 hour sample floor 1, K5 plan for a dear run before the cheap run, K6 share by count not room, K8 daytime profile ignored: **survived**. I added a test for each in part 4, and all four are now red.
**6. UI and records.**
- EN/NL. The forecast line said "today" but shows what is **left**. It is now "Solar still to come today: 9.2 kWh, we count on 6.4 kWh." (within the 12-word rule).
- "The plan starts tomorrow" was untrue when started after about 04:00. It is now "…after one full day."
- The Why lines match the plan. Cheap hours now shows no plan word. SAFETY-78's "other cycle" line wins over the plan word (test).
- DOCS and DECISIONS are updated with the Cheap hours change, the no-sun result and the reconstruction bias. The STYLE, doc and text tests pass.
**Merges:** release/1.0 moved twice, first to SAFETY-81 #155 and then to SAFETY-78 #154. I merged both normally. The conflicts in `decide()`, `battery_why` and the screen tests keep both sides. `draining` still blocks every grid charge.
Full suite locally: **53/53** on b171ada. Replays re-run on b171ada: P3 option A 19.44/32.01, P4 SF+LS 19.15/29.19.
admin
merged commit 1145a931ac into release/1.02026-09-29 23:01:49 +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 P4 (design
goodwe-session/OPT-11-design.md§1 "Excess solar", §2, §6 P4; owner answers; owner decision 2026-09-29 option A). Base: release/1.05a4cdc2.What it does
app/solarfc.py, pure).pv_forecast_entity(kWh left today),pv_forecast_tomorrow_entity(optional),solar_trust_pct(30–100, default 70).detailedForecast/detailedHourly), Open-Meteo Solar Forecast (wh_period). Any kWh/Wh/MWh sensor works as state only (spread over a 06–20 h bell).sensor.id. Settings notes flag a sensor not in kWh, a forecast set with modes off, and tomorrow's without today's. Option texts are EN/NL; validation text is EN, as the rest of validate.py.dayplan.HouseProfile). Nothing already recorded fitted:tariff.dbkeeps only grid import per quarter, which the batteries shape.house_profile), and uses the median per hour.dayplan.plan()): sun = trust × Σ max(0, forecast − house) × 0.95 until the expensive run the cheap run is for. Solar first takes it first.modetrade.charge_to();targets=Noneis P3 exactly.goodwe_<inv>_plansensor: left for later (needs a new MQTT text entity and test_mqtt payload diff; not cheap).Replay: does it pay? (
tools/opt11p4_replay.py, same engine as P3's; net EUR over 16 recorded days 2026-09-02..17, lower is better; "after" values the charge left at the end at the median all-in price)7a00f12)7a00f12)5a4cdc2)5a4cdc2)scenarios/energy-charts-be-solar-price-2026-09-01_2026-09-17.json(CC BY 4.0).Evidence (goodwe-addon-checks:1,
--network none)tools/opt11p4_identity.py, real Controller, P1 meter, cheap and dear slots, modes on):unavailableplus a ready house profile. All 12 digests equal 5a4cdc2's (pinned intest_modes_p4.pypart 1).test_modes_p4.pypart 6, real controller, night + day). Checked in sunny, dark, Cheap hours partly, forecast lost mid-run and forecast too high, plus refusal and Off:Mutations (purged copy each,
python3 -B, graded by test_modes_p4.py): 26, all redM1 SF gets a level · M2 level not capped by the ceiling · M3 LS charges with no plan · M4 chargers ignore the level · M5 run sized to the ceiling, not the shortfall · M6 stale forecast accepted · M7 unit guessed as kWh · M8 unavailable read as 0 kWh · M9 levels kept when the forecast goes · M10 no hysteresis · M11 SF room not taken first · M12 trust ignored · M13 LS not capped by the house's use · M14 Off/maintenance batteries planned · M15 tomorrow's unknown sun counted as 0 · M16 house profile always ready · M17 night floor removed · M18 forecast read with modes off · M19 trust range not checked · M20 non-sensor entity accepted · M21 Why lines ignore the plan · M22 forecast line dropped · M23 levels not passed to decide() · M24 run mark not reopened by a moved level · M25 Today grid block ignores the plan · M26 forecast arrival waits for the next 15-min slot.
Not done / risks for the reviewer
house_profile(about 7 × 24 floats), written once per hour, only with modes on and a forecast entity set.capacity_kwhgets no plan (P3 option A). DOCS says so.🤖 Generated with Claude Code
https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
Review of #156, part 1: does P4 pay? (for the owner)
I re-ran the replay myself (
goodwe-addon-checks:1,--network none) on the dev's head and again on the final headb171ada(after merging SAFETY-81 and SAFETY-78). Net EUR over 16 days, lower is better:--fc zero)The dev's numbers reproduce exactly.
Where the money comes from.
Bias in the method, stated plainly. Both parts favour P4:
Cheap hours was worse (29.72 against 29.41). Not a code bug, but a design deviation. Fixed.
fbd8847, Cheap hours only fills to the ceiling with or without a forecast. Replay: 29.41 and 19.57, the same as P3.In plain words, for the owner:
With a solar forecast, the Leftover solar battery now buys only what the house needs in the expensive evening hours, in the cheapest hours. Over 16 September days, that saved about 2.80 EUR on this year's real prices and 0.29 EUR on last year's prices, compared with today's "solar only". The forecast itself did little in September, and most of the saving would come even without it. Cheap hours only stays as it is today.
Is this worse than option A for Leftover solar? No. It is better on both price sets, with the same worst quarter under the peak limit (3451 W against a 3450 W aim and a 3500 W limit).
Review of #156, part 2: safety, code and records (fixes pushed as
fbd8847,0b96e31,b171ada)1. Safety invariants: OK.
dayplan.plan/holdandmodetrade.chargers/energy/decide: 0 violations. It covers:chargers()is exactly P3's rule;allocator/arbiterare untouched here (SAFETY-78's changes arrive by the merge). The Off peak-shortfall rule is P3's and was tested in the P3 TEST-23 report (check 6).2.
house_profile: bounded.site_batt_wNone) means no sample, not a wrong one. DST merges or skips an hour, and the other days' medians fill in.3. Forecast parsing.
detailedForecast,period_startISO with an offset, kW per 30 min. It is only on..._forecast_today/_tomorrow; theremaining_todaysensor in DOCS is read as state only, which works.wh_period, Wh./api/states/<id>. Rule 13 now needssensor.[a-z0-9_]+, so a path such assensor.x/../configis refused (test).wh_periodkeys are period start or end. At worst it shifts the split by an hour.4. Validation: OK.
solar_trust_pct: 70, the entities are empty (skipped), and_numberdefaults to 70, so there is no startup refusal on upgrade.5. Identity and mutations.
5a4cdc2and on my head: 12/12 digests equal. Part 1 of the tests still pins them after both merges.6. UI and records.
Merges: release/1.0 moved twice, first to SAFETY-81 #155 and then to SAFETY-78 #154. I merged both normally. The conflicts in
decide(),battery_whyand the screen tests keep both sides.drainingstill blocks every grid charge.Full suite locally: 53/53 on
b171ada. Replays re-run onb171ada: P3 option A 19.44/32.01, P4 SF+LS 19.15/29.19.