OPT-36: Leftover solar counts Cheap hours and Solar first batteries before buying #229

Merged
admin merged 8 commits from OPT-36 into hotfix-0.20.1 2026-11-02 14:49:46 +01:00
Owner

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.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC

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
admin added 1 commit 2026-11-02 02:03:30 +01:00
OPT-36: Leftover solar counts what Cheap hours and Solar first batteries will give before buying
checks / unit (pull_request) Failing after 59m31s
infra-pin / fresh (pull_request) Successful in 2s
05746c0564
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>
admin added 1 commit 2026-11-02 02:43:07 +01:00
OPT-36: test a give block before tonight's charge, Solar first refusing and in maintenance
checks / unit (pull_request) Failing after 26m32s
infra-pin / fresh (pull_request) Successful in 4s
490f67924b
Co-Authored-By: Claude <noreply@anthropic.com>
admin added 1 commit 2026-11-02 02:50:38 +01:00
OPT-36: test_opt36 reads possibly-missing values through nth()
checks / unit (pull_request) Failing after 35m3s
infra-pin / fresh (pull_request) Successful in 2s
60674e45ba
Co-Authored-By: Claude <noreply@anthropic.com>
admin added 1 commit 2026-11-02 03:30:43 +01:00
OPT-36: test_opt34 and test_ux54 scenarios keep a shortfall B does not cover
checks / unit (pull_request) Successful in 27m17s
infra-pin / fresh (pull_request) Successful in 2s
6a527de851
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>
admin added 1 commit 2026-11-02 05:34:35 +01:00
OPT-36 round 2: test_opt34 keeps its tolerances; fresh sensors read the day they are on
checks / unit (pull_request) Successful in 20m36s
infra-pin / fresh (pull_request) Successful in 2s
3a18475a5a
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>
admin added 1 commit 2026-11-02 10:15:42 +01:00
OPT-36 round 3: B's charge counts the cheap time left; OPT-34 nights on their own shapes
checks / unit (pull_request) Failing after 36m29s
infra-pin / fresh (pull_request) Successful in 2s
e4b9dee699
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>
admin added 1 commit 2026-11-02 10:57:24 +01:00
OPT-36 round 3: test_opt36 reads the mid_flat level through nth()
checks / unit (pull_request) Successful in 26m38s
infra-pin / fresh (pull_request) Successful in 2s
1ac7fe98d7
Co-Authored-By: Claude <noreply@anthropic.com>
Author
Owner

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.

**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.
admin added 1 commit 2026-11-02 12:56:27 +01:00
OPT-36 round 4: count only charge control will make; Solar first drain at the night base
checks / unit (pull_request) Successful in 44m14s
infra-pin / fresh (pull_request) Successful in 2s
eb9ccc2730
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>
Author
Owner

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.
**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.1 2026-11-02 14:49:46 +01:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#229