OPT-36 (hotfix 0.20.1). Leftover solar now counts what Cheap hours only and Solar first batteries will give in the expensive hours before it buys from the grid at night.
Live 0.20.0, 2026-11-02 01:01: A (Leftover solar, 60 %) went to 95 % (5.38 kWh) while B (Cheap hours only, 94 %) already covered the expensive hours. With these figures A now buys 0.
dayplan.py: _cheap_give_kwh() (level when the hours start: the ceiling, or what the cheap run reaches in the time left under the peak aim; minus floor, x capacity x 0.95) and _solar_first_give_kwh() (level now less the house's night use to the dear hours, shared by energy held) are taken off the shortfall before the share. Off, refusing, in maintenance, no capacity or no level count 0.
main.py: passes peak_aim_w() to the plan.
homeview.py: the give block reads the level after the planned grid charge; plan_cover() returns the plan's own figure; new Why line "Other batteries cover..." (EN + NL).
Tests: test_opt36.py (new), test_opt23_28.py (the "Cheap hours is no part of A's share" check rewritten on purpose), test_batteries_screen.py (new key).
No version bump. CHANGELOG line under "Not in a release yet"; DECISIONS OPT-23-28 note and DOCS updated.
Not merged. Dev report: reports/OPT-36-dev-report.md.
OPT-36 (hotfix 0.20.1). Leftover solar now counts what Cheap hours only and Solar first batteries will give in the expensive hours before it buys from the grid at night.
Live 0.20.0, 2026-11-02 01:01: A (Leftover solar, 60 %) went to 95 % (5.38 kWh) while B (Cheap hours only, 94 %) already covered the expensive hours. With these figures A now buys 0.
- `dayplan.py`: `_cheap_give_kwh()` (level when the hours start: the ceiling, or what the cheap run reaches in the time left under the peak aim; minus floor, x capacity x 0.95) and `_solar_first_give_kwh()` (level now less the house's night use to the dear hours, shared by energy held) are taken off the shortfall before the share. Off, refusing, in maintenance, no capacity or no level count 0.
- `main.py`: passes `peak_aim_w()` to the plan.
- `homeview.py`: the give block reads the level after the planned grid charge; `plan_cover()` returns the plan's own figure; new Why line "Other batteries cover..." (EN + NL).
- Tests: `test_opt36.py` (new), `test_opt23_28.py` (the "Cheap hours is no part of A's share" check rewritten on purpose), `test_batteries_screen.py` (new key).
- No version bump. CHANGELOG line under "Not in a release yet"; DECISIONS OPT-23-28 note and DOCS updated.
Not merged. Dev report: reports/OPT-36-dev-report.md.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Live 0.20.0: A (Leftover solar, 60 %) charged to 95 % while B (Cheap hours,
94 %) already covered the expensive hours. dayplan.plan() now takes off the
shortfall what Cheap hours batteries will hold when the hours start (the
ceiling or what their cheap run reaches under the peak aim) and what Solar
first batteries will still hold after the night's use. Home reads a give
block after the planned charge, plan_cover() is the plan's own figure, and
the Why line says other batteries cover.
Co-Authored-By: Claude <noreply@anthropic.com>
With Cheap hours B counted, A buys only what B cannot reach. The OPT-34
night scenarios use a 1.6 x larger house profile, and the ux54 tab scenarios
a 4.8 kW evening (ux54_scenarios.make(evening=)), so A still charges; the
hard-coded levels become relations.
Co-Authored-By: Claude <noreply@anthropic.com>
The silent and fresh nights diverged only because the test's fresh sensors
kept reading the evening's 0.4 kWh 'left today' after midnight, while the
kept figure is the 4th's 6.0 kWh (solarfc.Hold.stand_in, as_today): two
different shortfalls (16.5 and 12.7 kWh) that A's ceiling hid until B was
counted. Fresh sensors now read 6.0 after midnight, so fresh and kept are
the same figure; the 0.3 tolerance is back, the other exact values are
recomputed. test_ux54 literals are recomputed too.
Co-Authored-By: Claude <noreply@anthropic.com>
The run is sized at the batteries' ratings, not the peak limit, so a run too
short to fill a Cheap hours battery is planned again later in the night.
Counting that run alone made B's give grow through the night and A's level
fall after A had bought (round 2: 55 -> 69 -> 41 -> 37 %, A to 56 %).
dayplan._cheap_give_kwh() now also counts the later firm slots before the
expensive hours that a run would still be worth charging in (price + the
spread control asks at most the give run's dearest slot); the Controller
passes the slots and the spread.
test_opt34: back to the original house shapes, fixture and expected values.
B is Off in the nights that test A's level (a Cheap hours B now covers
these hours); B's checks run in new `_b` nights; the 03:00 check runs in a
night from midnight; tail starts A at 60 %; late_fresh reads the kept 6.0.
test_opt36: slot cases, a whole night on one figure (level never moves),
F-1 with B counted (raised level reached and held). test_ux54: the run end
is read from the plan and asserted (02:30); the F1/F2 sweep uses a peak
limit that does not bind and reads a level shown as the target as at it.
Co-Authored-By: Claude <noreply@anthropic.com>
OPT-36 review (Opus, independent): head 1ac7fe9. Verdict: CHANGES REQUESTED, not merged.
Correct for the owner's site (day/night tariff). On dynamic prices it can buy less than 0.20.0 and the house then imports at the dear price. CI run 1520 is green on 1ac7fe9, and my local full suite passes too (94 files, 0 failed).
Findings
Medium: under-buy on dynamic tariffs from the slot rule (app/dayplan.py:261-269). B's charge is counted in later slots that pass the worth test (price + spread <= dearest dear slot). After the run ends, control re-plans B's remaining charge into the cheapest window anywhere in the horizon. If that window is after the dear hours, B never charges in the slots it was counted in. The run is also sized at A+B's combined ratings for A's now-smaller need, so it is shorter than before and B cannot fill in it.
Whole-night test through the real Controller with a 3-price static tariff (22-01 0.15, 01-03 0.10, 03-07 0.15, 07-22 0.34), A 30 %, B 20 %, 10 kWh / 2.5 kW each, no binding peak limit. The plan counts B to 95 %; B really ends at 67.5 %. The dear hours are 2.61 kWh short and that comes from the grid at 0.34. On 0.20.0 nothing is short (A 95 %, B 91 %).
A safe-side variant: count a later slot only if its price is <= the cheapest firm slot at or after until.start. This brings the gap down to 0.22 kWh and leaves mid_flat unchanged (34.94 % all night, A 34.96 %). It is not pushed.
Medium: the real-price replays cost more than 0.20.0 where Solar first counts.ownsf_hourly_real and p4_ls_dark_real end at the same SoC as on 0.20.0 but cost +0.40 EUR each over 3 days (0.20.0: 4.00 / 3.84 EUR; this head: 4.40 / 4.24 EUR). Likely cause, not proven: _solar_first_give_kwh() (dayplan.py:288-306) counts the Solar first battery's level at until.start against every give run in the next 24 h (dayplan.py:350-361). On dynamic prices that battery also feeds the house in the mid-price hours between give runs, and those hours are not in short. The four own_* cases (Cheap hours counted) cost +0.01 to +0.50 EUR after valuing what is left stored, but they end with less stored, so that figure depends on how the stored energy is valued.
Low:test_ux54.py:917 uses peak_w=20000, but peak_cap_w only accepts 500-15000. 15000 would not bind either. The reason given for it (the tab's curve charges at the ratings, the plan counts B under the peak) is a real difference between the Forecast tab and control when the peak limit binds. It predates OPT-36 (UX-54), but OPT-36 makes it show.
Low: the solar.uncovered text (ui_text/en.json:364) still says the rest "comes from another battery or the grid". Cheap hours and Solar first are now counted before that, so the rest comes from the grid, as DOCS.md now says.
Low: the CHANGELOG line (CHANGELOG.md:7) says "The Batteries screen says which battery covers", but others_cover only says "Other batteries". It is also 3 sentences, where the brief asked for 1-2. The DECISIONS "Not done" (OPT-23-28 note:12) does not list the dynamic-tariff limit.
Checked and fine
Live figures: 0.20.0 puts A at 95 % (5.376 kWh, 0.395 kWh uncovered); this head has A buy 0 with nothing uncovered. B's give line now reads 15.4 kWh.
Zero cases, no double count, and cheap/sfirst capped at short (dayplan.py:364-373).
Night drift is fixed. Peak-bound nights (room 0.6 kW, B 30/40/50 %) never step A's level down. Charging matches 0.20.0 tick for tick until the run plan ends; total stored is the same as 0.20.0 where both ran to 07:00.
B refusing to charge at 23:00 or 01:00 is detected, and A goes to 95 %. The result equals 0.20.0, or is slightly better. B switched Off at 05:30: A tops up to 94.98 % in time.
Digests: all 17 required cases are identical, re-run independently. The changed cases are the stated ones and have the same command counts as the dev's.
Tests: the test_opt23_28 rewrite is on purpose and strict. In test_opt34, B Off keeps the original values and tolerances, and F-1 passes unchanged. test_opt36 mid/mid_flat/f1_dark_b prove a stable level and the latch with B counted. The ux54 run end is asserted. The 0.1 % snap is limited to equal rounding at LEVEL_DP. Mutant "drop the Cheap hours subtraction" is red (re-checked).
Screens: the give line after the grid block, plan_cover() taken from the plan, and the others_cover text in EN and NL are plain and short.
Day/night (the owner's site): no under-buy against 0.20.0 found. Merging as-is would need an explicit decision that the dynamic-tariff regression (findings 1-2) is acceptable for 0.20.1.
**OPT-36 review (Opus, independent): head 1ac7fe9. Verdict: CHANGES REQUESTED, not merged.**
Correct for the owner's site (day/night tariff). On dynamic prices it can buy less than 0.20.0 and the house then imports at the dear price. CI run 1520 is green on 1ac7fe9, and my local full suite passes too (94 files, 0 failed).
### Findings
1. **Medium: under-buy on dynamic tariffs from the slot rule** (`app/dayplan.py:261-269`). B's charge is counted in later slots that pass the worth test (price + spread <= dearest dear slot). After the run ends, control re-plans B's remaining charge into the cheapest window anywhere in the horizon. If that window is after the dear hours, B never charges in the slots it was counted in. The run is also sized at A+B's combined ratings for A's now-smaller need, so it is shorter than before and B cannot fill in it.
- Whole-night test through the real Controller with a 3-price static tariff (22-01 0.15, 01-03 0.10, 03-07 0.15, 07-22 0.34), A 30 %, B 20 %, 10 kWh / 2.5 kW each, no binding peak limit. The plan counts B to 95 %; B really ends at 67.5 %. The dear hours are 2.61 kWh short and that comes from the grid at 0.34. On 0.20.0 nothing is short (A 95 %, B 91 %).
- A safe-side variant: count a later slot only if its price is <= the cheapest firm slot at or after `until.start`. This brings the gap down to 0.22 kWh and leaves `mid_flat` unchanged (34.94 % all night, A 34.96 %). It is not pushed.
2. **Medium: the real-price replays cost more than 0.20.0 where Solar first counts.** `ownsf_hourly_real` and `p4_ls_dark_real` end at the same SoC as on 0.20.0 but cost +0.40 EUR each over 3 days (0.20.0: 4.00 / 3.84 EUR; this head: 4.40 / 4.24 EUR). Likely cause, not proven: `_solar_first_give_kwh()` (`dayplan.py:288-306`) counts the Solar first battery's level at `until.start` against every give run in the next 24 h (`dayplan.py:350-361`). On dynamic prices that battery also feeds the house in the mid-price hours between give runs, and those hours are not in `short`. The four `own_*` cases (Cheap hours counted) cost +0.01 to +0.50 EUR after valuing what is left stored, but they end with less stored, so that figure depends on how the stored energy is valued.
3. **Low:** `test_ux54.py:917` uses `peak_w=20000`, but `peak_cap_w` only accepts 500-15000. 15000 would not bind either. The reason given for it (the tab's curve charges at the ratings, the plan counts B under the peak) is a real difference between the Forecast tab and control when the peak limit binds. It predates OPT-36 (UX-54), but OPT-36 makes it show.
4. **Low:** the `solar.uncovered` text (`ui_text/en.json:364`) still says the rest "comes from another battery or the grid". Cheap hours and Solar first are now counted before that, so the rest comes from the grid, as DOCS.md now says.
5. **Low:** the CHANGELOG line (CHANGELOG.md:7) says "The Batteries screen says which battery covers", but `others_cover` only says "Other batteries". It is also 3 sentences, where the brief asked for 1-2. The DECISIONS "Not done" (OPT-23-28 note:12) does not list the dynamic-tariff limit.
### Checked and fine
- Live figures: 0.20.0 puts A at 95 % (5.376 kWh, 0.395 kWh uncovered); this head has A buy 0 with nothing uncovered. B's give line now reads 15.4 kWh.
- Zero cases, no double count, and `cheap`/`sfirst` capped at `short` (dayplan.py:364-373).
- Night drift is fixed. Peak-bound nights (room 0.6 kW, B 30/40/50 %) never step A's level down. Charging matches 0.20.0 tick for tick until the run plan ends; total stored is the same as 0.20.0 where both ran to 07:00.
- B refusing to charge at 23:00 or 01:00 is detected, and A goes to 95 %. The result equals 0.20.0, or is slightly better. B switched Off at 05:30: A tops up to 94.98 % in time.
- Digests: all 17 required cases are identical, re-run independently. The changed cases are the stated ones and have the same command counts as the dev's.
- Tests: the test_opt23_28 rewrite is on purpose and strict. In test_opt34, B Off keeps the original values and tolerances, and F-1 passes unchanged. test_opt36 `mid`/`mid_flat`/`f1_dark_b` prove a stable level and the latch with B counted. The ux54 run end is asserted. The 0.1 % snap is limited to equal rounding at `LEVEL_DP`. Mutant "drop the Cheap hours subtraction" is red (re-checked).
- Screens: the give line after the grid block, `plan_cover()` taken from the plan, and the `others_cover` text in EN and NL are plain and short.
Day/night (the owner's site): no under-buy against 0.20.0 found. Merging as-is would need an explicit decision that the dynamic-tariff regression (findings 1-2) is acceptable for 0.20.1.
Review F1: a slot after B's run counts only if no slot from the expensive
hours on is cheaper (control plans B's charge left in the cheapest window
ahead), and x 0.95 of B's charge is stored. Three-price night: 1ac7fe9 left
the expensive hours 2.61 kWh short; now 0.
Review F2: a Solar first battery's drain until the expensive hours reads each
hour at no less than the night base (the profile is net of the sun). Read net,
a dark morning drained the battery the plan still counted; A's level fell,
the cheap run moved and was lost before it started (+0.40 EUR per 3 days).
The two real-price replays now cost what 0.20.0 does.
F3: the ux54 sweep's peak limit is 15 kW (the option's top). F4: the rest
comes from the grid (EN/NL). F5: CHANGELOG and DECISIONS wording.
Co-Authored-By: Claude <noreply@anthropic.com>
F1, three-price night: the gap goes from 2.61 kWh (1ac7fe9) to 0.07 kWh. A ends at 77.5 % and B at 67.5 %; together they hold 9.98 kWh against a 10.04 kWh shortfall.
mid_flat: A's level stays at 34.94 % all night and A ends at 34.96 %, as before.
Binding-peak nights (B at 30/40/50 %, 0.6 kW room): same as round 3. A's level never steps down.
Live figures: A buys 0, nothing uncovered, B gives up to 15.4 kWh.
F2, real-price replays:ownsf_hourly_real (4.00 EUR) and p4_ls_dark_real (3.84 EUR) now give the same digest sha as 0.20.0. modes_off and n1_ls_hourly_real are identical too. own_dark_real (Cheap hours counted, informational) costs 4.55 EUR against 5.07, and ends with less stored.
Mutants, graded on test_opt36, all red:
21 (cheaper-later rule dropped): red on three.
22 (no 0.95 on B's charge): 10 checks red.
23 (Solar first drain read net of the sun): both cost-pinned replays red.
F3-F5: done (15 kW sweep, "comes from the grid" in EN and NL, CHANGELOG in 2 sentences, DECISIONS "Not done" names the dynamic-tariff limit).
Remaining limits, not blockers:
On a dynamic tariff the Cheap hours count can still name a slot that goes unused. This is documented.
A plan rebuilt without levels can lose a run that has not started. This is in 0.20.0 too and OPT-36 does not trigger it; it is a separate card, Low-Medium.
**OPT-36 re-review (round 4, head eb9ccc2): APPROVED, merging into hotfix-0.20.1.**
CI run 1522 `checks / unit (pull_request)` is green on refs/pull/229/head = eb9ccc2 (94 files, 0 failed).
I re-ran these myself on eb9ccc2:
- **F1, three-price night:** the gap goes from 2.61 kWh (1ac7fe9) to 0.07 kWh. A ends at 77.5 % and B at 67.5 %; together they hold 9.98 kWh against a 10.04 kWh shortfall.
- **mid_flat:** A's level stays at 34.94 % all night and A ends at 34.96 %, as before.
- **Binding-peak nights** (B at 30/40/50 %, 0.6 kW room): same as round 3. A's level never steps down.
- **Live figures:** A buys 0, nothing uncovered, B gives up to 15.4 kWh.
- **F2, real-price replays:** `ownsf_hourly_real` (4.00 EUR) and `p4_ls_dark_real` (3.84 EUR) now give the same digest sha as 0.20.0. `modes_off` and `n1_ls_hourly_real` are identical too. `own_dark_real` (Cheap hours counted, informational) costs 4.55 EUR against 5.07, and ends with less stored.
- **Mutants**, graded on test_opt36, all red:
- 21 (cheaper-later rule dropped): red on `three`.
- 22 (no 0.95 on B's charge): 10 checks red.
- 23 (Solar first drain read net of the sun): both cost-pinned replays red.
- **F3-F5:** done (15 kW sweep, "comes from the grid" in EN and NL, CHANGELOG in 2 sentences, DECISIONS "Not done" names the dynamic-tariff limit).
Remaining limits, not blockers:
- On a dynamic tariff the Cheap hours count can still name a slot that goes unused. This is documented.
- A plan rebuilt without levels can lose a run that has not started. This is in 0.20.0 too and OPT-36 does not trigger it; it is a separate card, Low-Medium.
admin
merged commit 22d09f8fe7 into hotfix-0.20.12026-11-02 14:49:46 +01: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-36 (hotfix 0.20.1). Leftover solar now counts what Cheap hours only and Solar first batteries will give in the expensive hours before it buys from the grid at night.
Live 0.20.0, 2026-11-02 01:01: A (Leftover solar, 60 %) went to 95 % (5.38 kWh) while B (Cheap hours only, 94 %) already covered the expensive hours. With these figures A now buys 0.
dayplan.py:_cheap_give_kwh()(level when the hours start: the ceiling, or what the cheap run reaches in the time left under the peak aim; minus floor, x capacity x 0.95) and_solar_first_give_kwh()(level now less the house's night use to the dear hours, shared by energy held) are taken off the shortfall before the share. Off, refusing, in maintenance, no capacity or no level count 0.main.py: passespeak_aim_w()to the plan.homeview.py: the give block reads the level after the planned grid charge;plan_cover()returns the plan's own figure; new Why line "Other batteries cover..." (EN + NL).test_opt36.py(new),test_opt23_28.py(the "Cheap hours is no part of A's share" check rewritten on purpose),test_batteries_screen.py(new key).Not merged. Dev report: reports/OPT-36-dev-report.md.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
OPT-36 review (Opus, independent): head
1ac7fe9. Verdict: CHANGES REQUESTED, not merged.Correct for the owner's site (day/night tariff). On dynamic prices it can buy less than 0.20.0 and the house then imports at the dear price. CI run 1520 is green on
1ac7fe9, and my local full suite passes too (94 files, 0 failed).Findings
app/dayplan.py:261-269). B's charge is counted in later slots that pass the worth test (price + spread <= dearest dear slot). After the run ends, control re-plans B's remaining charge into the cheapest window anywhere in the horizon. If that window is after the dear hours, B never charges in the slots it was counted in. The run is also sized at A+B's combined ratings for A's now-smaller need, so it is shorter than before and B cannot fill in it.until.start. This brings the gap down to 0.22 kWh and leavesmid_flatunchanged (34.94 % all night, A 34.96 %). It is not pushed.ownsf_hourly_realandp4_ls_dark_realend at the same SoC as on 0.20.0 but cost +0.40 EUR each over 3 days (0.20.0: 4.00 / 3.84 EUR; this head: 4.40 / 4.24 EUR). Likely cause, not proven:_solar_first_give_kwh()(dayplan.py:288-306) counts the Solar first battery's level atuntil.startagainst every give run in the next 24 h (dayplan.py:350-361). On dynamic prices that battery also feeds the house in the mid-price hours between give runs, and those hours are not inshort. The fourown_*cases (Cheap hours counted) cost +0.01 to +0.50 EUR after valuing what is left stored, but they end with less stored, so that figure depends on how the stored energy is valued.test_ux54.py:917usespeak_w=20000, butpeak_cap_wonly accepts 500-15000. 15000 would not bind either. The reason given for it (the tab's curve charges at the ratings, the plan counts B under the peak) is a real difference between the Forecast tab and control when the peak limit binds. It predates OPT-36 (UX-54), but OPT-36 makes it show.solar.uncoveredtext (ui_text/en.json:364) still says the rest "comes from another battery or the grid". Cheap hours and Solar first are now counted before that, so the rest comes from the grid, as DOCS.md now says.others_coveronly says "Other batteries". It is also 3 sentences, where the brief asked for 1-2. The DECISIONS "Not done" (OPT-23-28 note:12) does not list the dynamic-tariff limit.Checked and fine
cheap/sfirstcapped atshort(dayplan.py:364-373).mid/mid_flat/f1_dark_bprove a stable level and the latch with B counted. The ux54 run end is asserted. The 0.1 % snap is limited to equal rounding atLEVEL_DP. Mutant "drop the Cheap hours subtraction" is red (re-checked).plan_cover()taken from the plan, and theothers_covertext in EN and NL are plain and short.Day/night (the owner's site): no under-buy against 0.20.0 found. Merging as-is would need an explicit decision that the dynamic-tariff regression (findings 1-2) is acceptable for 0.20.1.
OPT-36 re-review (round 4, head
eb9ccc2): APPROVED, merging into hotfix-0.20.1.CI run 1522
checks / unit (pull_request)is green on refs/pull/229/head =eb9ccc2(94 files, 0 failed).I re-ran these myself on
eb9ccc2:1ac7fe9) to 0.07 kWh. A ends at 77.5 % and B at 67.5 %; together they hold 9.98 kWh against a 10.04 kWh shortfall.ownsf_hourly_real(4.00 EUR) andp4_ls_dark_real(3.84 EUR) now give the same digest sha as 0.20.0.modes_offandn1_ls_hourly_realare identical too.own_dark_real(Cheap hours counted, informational) costs 4.55 EUR against 5.07, and ends with less stored.three.Remaining limits, not blockers: