UX-54: a Forecast tab with the sun, the house and each battery's level, per hour
A new tab beside Home and Batteries. For today and tomorrow, per hour: the solar forecast, the solar measured so far, the house's expected use and each battery's expected level, with the plan's charge and give blocks (and the plan's words) under it. Owner go 2026-10-26: "Day curve", "New tab". Card: UX-54.
How it follows the add-on
Blocks are Home's. Today's are /status' plan runs (grid, give, monthly cycle); tomorrow's come from the same builders (homeview.runs_for()). A block is on the tab exactly where Home has one, UX-53's gate included.
Sun: what solarfc reads. A daily total (Forecast.Solar; the owner's three "today remaining" and three "tomorrow" sensors) is spread over the 06:00-20:00 bell and the tab says so. An hourly split (Solcast, Open-Meteo) is used as given.
House:HouseProfile.estimator(), never below the night median, as the plan reads it.
Level (did not exist): the plan holds targets and run blocks only. daycurve.curve() is one pure function: each battery gets the role modetrade.decide() gives it, spare solar goes to Solar first batteries first, a shortfall is met by those that may give, at dayplan.EFF, inside the plan's floor and ceiling. It counts on solar_trust_pct and says so.
Not known yet is a note, never an empty chart: modes off, no forecast set, forecast missing or old, learning, no tomorrow forecast, a monthly cycle, no level reading, stopped, shadow.
Light: built on request (GET forecast, only while the tab shows, every 30 s). No Home Assistant call. The one addition in the control path is an in-memory add after the solar read the cycle already takes, in a try.
Found, not changed (no control change allowed)
dayplan.plan() calls Forecast.between(s, e) per hour; for a state-only forecast between() reads s as the moment "left today" was taken, so each later hour is spread over what is left of the bell. On the owner's shape 14.4 kWh left gives 57 kWh over the hours to midnight. The plan's sun is over-counted for state-only forecasts (it does not show on an all-Solar-first site, which gets no planned level). The tab measures both ends from now (dayview.sun_kwh()), which keeps the total. A card should fix the plan.
Files
app/daycurve.py (new, pure), app/dayview.py (new), app/homeview.py (runs_for, day_note, in_lang), app/main.py (forecast_payload, SolarToday), app/web.py (GET /forecast), app/static/{index.html,app.js,app.css}, app/ui_text/{en,nl}.json (30 strings), DECISIONS/UX-54-forecast-tab.md, DOCS.md (a short section), tools/ux54_scenarios.py, test_ux54.py; test_home.py and test_selfconfig.py count one more tab and one more GET.
Evidence
Details in reports/UX-54-dev-report.md (kept by the manager).
test_ux54.py: 69 checks (shape per integration, blocks equal Home's on 13 scenarios incl. modes on Solar first / Leftover solar / Cheap hours / modes off, the gate, curve invariants incl. a 300-case fuzz, every note, SolarToday, words EN/NL, route, no HA call and nothing moved). test_messages.py, test_ui_text.py, test_docs.py, test_home.py, test_selfconfig.py pass; the full suite in goodwe-addon-checks:1 (--network none, sim mounted, GOODWE_REQUIRE_HWSIM=1): 83 of 84 files on the first local pass (the one failure was an unguarded next() in my own new test, fixed before the commit); checks / unit (pull_request) green on this head (run 1400).
17 mutations, all caught (table in the report).
Commands identical to release/1.0: 4 replay variants (modes on: Solar first + Leftover solar, Cheap hours x2; modes off with arbitrage) x 6 runs each (base, base + solar entity, head, head + solar entity, head + the tab built every 60 ticks), 34 560 ticks, one SHA-256 per variant, all equal; the four identity tools (opt11p3, opt11p4, safety79, safety84) byte-identical on base and head.
Browser proof (Playwright): 10 scenarios x EN/NL x desktop/360 px, 346 checks, no page errors; screenshots in scratch/UX-54/dev/shots/.
Not done: no CHANGELOG, no version change. Rig test on TEST-23 is the next step of the flow.
## UX-54: a Forecast tab with the sun, the house and each battery's level, per hour
A new tab beside Home and Batteries. For today and tomorrow, per hour: the solar forecast, the solar measured so far, the house's expected use and each battery's expected level, with the plan's charge and give blocks (and the plan's words) under it. Owner go 2026-10-26: "Day curve", "New tab". Card: UX-54.
### How it follows the add-on
- **Blocks are Home's.** Today's are `/status`' plan `runs` (grid, give, monthly cycle); tomorrow's come from the same builders (`homeview.runs_for()`). A block is on the tab exactly where Home has one, UX-53's gate included.
- **Sun:** what `solarfc` reads. A daily total (Forecast.Solar; the owner's three "today remaining" and three "tomorrow" sensors) is spread over the 06:00-20:00 bell and the tab says so. An hourly split (Solcast, Open-Meteo) is used as given.
- **House:** `HouseProfile.estimator()`, never below the night median, as the plan reads it.
- **Level (did not exist):** the plan holds targets and run blocks only. `daycurve.curve()` is one pure function: each battery gets the role `modetrade.decide()` gives it, spare solar goes to Solar first batteries first, a shortfall is met by those that may give, at `dayplan.EFF`, inside the plan's floor and ceiling. It counts on `solar_trust_pct` and says so.
- **Not known yet is a note**, never an empty chart: modes off, no forecast set, forecast missing or old, learning, no tomorrow forecast, a monthly cycle, no level reading, stopped, shadow.
- **Light:** built on request (`GET forecast`, only while the tab shows, every 30 s). No Home Assistant call. The one addition in the control path is an in-memory add after the solar read the cycle already takes, in a `try`.
### Found, not changed (no control change allowed)
`dayplan.plan()` calls `Forecast.between(s, e)` per hour; for a state-only forecast `between()` reads `s` as the moment "left today" was taken, so each later hour is spread over what is left of the bell. On the owner's shape 14.4 kWh left gives 57 kWh over the hours to midnight. The plan's sun is over-counted for state-only forecasts (it does not show on an all-Solar-first site, which gets no planned level). The tab measures both ends from now (`dayview.sun_kwh()`), which keeps the total. A card should fix the plan.
### Files
`app/daycurve.py` (new, pure), `app/dayview.py` (new), `app/homeview.py` (`runs_for`, `day_note`, `in_lang`), `app/main.py` (`forecast_payload`, `SolarToday`), `app/web.py` (`GET /forecast`), `app/static/{index.html,app.js,app.css}`, `app/ui_text/{en,nl}.json` (30 strings), `DECISIONS/UX-54-forecast-tab.md`, `DOCS.md` (a short section), `tools/ux54_scenarios.py`, `test_ux54.py`; `test_home.py` and `test_selfconfig.py` count one more tab and one more GET.
### Evidence
Details in `reports/UX-54-dev-report.md` (kept by the manager).
- `test_ux54.py`: 69 checks (shape per integration, blocks equal Home's on 13 scenarios incl. modes on Solar first / Leftover solar / Cheap hours / modes off, the gate, curve invariants incl. a 300-case fuzz, every note, SolarToday, words EN/NL, route, no HA call and nothing moved). `test_messages.py`, `test_ui_text.py`, `test_docs.py`, `test_home.py`, `test_selfconfig.py` pass; the full suite in `goodwe-addon-checks:1` (`--network none`, sim mounted, `GOODWE_REQUIRE_HWSIM=1`): 83 of 84 files on the first local pass (the one failure was an unguarded `next()` in my own new test, fixed before the commit); `checks / unit (pull_request)` green on this head (run 1400).
- 17 mutations, all caught (table in the report).
- Commands identical to release/1.0: 4 replay variants (modes on: Solar first + Leftover solar, Cheap hours x2; modes off with arbitrage) x 6 runs each (base, base + solar entity, head, head + solar entity, head + the tab built every 60 ticks), 34 560 ticks, one SHA-256 per variant, all equal; the four identity tools (`opt11p3`, `opt11p4`, `safety79`, `safety84`) byte-identical on base and head.
- Browser proof (Playwright): 10 scenarios x EN/NL x desktop/360 px, 346 checks, no page errors; screenshots in `scratch/UX-54/dev/shots/`.
Not done: no CHANGELOG, no version change. Rig test on TEST-23 is the next step of the flow.
🤖 Generated with [Claude Code](https://claude.ai/code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
A new tab beside Home and Batteries for today and tomorrow: the solar
forecast, the solar measured so far, the house's expected use and each
battery's expected level, with the plan's own charge and give blocks under it.
- Blocks are Home's: today's are the plan's `runs`, tomorrow's come from the
same builders (`homeview.runs_for()`), in the plan lines' words.
- The sun is what solarfc reads; a daily total is spread over the bell and the
tab says so. The house is HouseProfile.estimator(), as the plan reads it.
- The level is new: `daycurve.curve()` gives each battery the role
`modetrade.decide()` gives it and moves it hour by hour inside the plan's
floor and ceiling. It is an estimate and counts on `solar_trust_pct`.
- Built on request (`GET forecast`), no Home Assistant call. Measured solar is
kept per hour in memory from the reading control already takes.
- A part not known yet is a note: modes off, no forecast, learning, no
forecast for tomorrow, a monthly cycle, no level, stopped, shadow.
No decision, command, timing or option key changes. Found, not changed:
`dayplan.plan()` over-counts a state-only forecast (DECISIONS/UX-54).
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
UX-54 review (Opus), head b6b2d1b: changes requested, not merged
Control is untouched and the tab is light. One display defect breaks the card's acceptance rule ("a block on the tab exists exactly where the plan has one"), so I did not merge. The fix changes behaviour, so I have not pushed it.
What holds (evidence in scratch/UX-54/review/out/)
No control change. The replay digests equal base for all 4 variants (p3_ls 3de48189b0dc, p3_both_ch ff6e86ac61e4, p3_ch_sell aa76f7c3a153, arb_only (modes off) 3b372696ab88). I ran each on base with and without a solar entity, and on head with the tab built every 30 s of replay time (5 760 builds per run, all ok), with and without a solar entity. That is 16 runs, and the digest is the same within each variant. On 7 scenarios, every attribute on the Controller is the same before and after 5 tab builds in EN and NL. The one exception is home_prices, where only the objects are new and the values are equal. Building the tab makes no HA calls, and the language goes back to EN afterwards. SolarToday holds at most 24 to 25 rows and resets each local day. Bad, NaN, inf, None and negative readings and a backward clock step do not raise. One reading costs 12 to 37 us.
Curve: the roles match modetrade.decide() (HOUSE, HOLD, GIVE, REST, CHARGE). Levels stay inside the floor and ceiling. charge_to() is used as the plan uses it.
Over-count (dev's finding) is real, and larger than the PR says. Details are below.
Browser: 9 head scenarios x EN/NL x 1000 px and 360 px, today and tomorrow, 200 checks, 0 failed, no page or console errors. Home and Batteries show the same text as base (digits masked) in 5 scenarios x EN/NL x desktop/phone. Neither tab asks for /forecast. The tab polls every 30 s while shown and stops while the page is hidden or on Settings.
Mutations: I re-ran 6 of the dev's (M01, M03, M06, M08, M12, M14) and all were caught. Of my own 5, 4 were caught; R5 is the one that survived.
Full suite in goodwe-addon-checks:1 (no network, sim mounted, GOODWE_REQUIRE_HWSIM=1) on the head: 84 of 84 test_*.py pass, test_freeze included. CI runs 1400 and 1401 are green on b6b2d1b; run 1400 is a real 48-min run with 85 PASS.
Findings
Major: tomorrow's give blocks vanish when a battery is low now. Files: app/homeview.py:616 (runs_for) → mode_runs()room():414 / :440, and trade_runs():347, :356. Tomorrow's blocks are sized from the level now. In the evening after a give run, a Leftover solar or Cheap hours battery is at the floor, so tomorrow's give run is drawn on neither battery. Tomorrow's grid block is still drawn, and the curve rises in it and then falls 17:00 to 21:00 with no block (out/e1.out, shots/dyn22-en-desk-tomorrow.png). At 18:00 tomorrow, with those levels, _mode_roles() gives both batteries GIVE and Home draws both give blocks. With modes off it is the same: with a charge leg today at 23:00 and a give leg tomorrow at 07:00, Home's own line says "Tomorrow: gives power at 07:00", but the tab's tomorrow has no block when the battery is at the floor now. It does have the block at 60 % (out/e2.out). A fix could size a block that has not started from the curve's level at its start, or from today's legs. Today has the same root (the dev's noted limit, inherited from Home). There, the new line day.no_blocks "No charge or give blocks planned." (app/dayview.py:250) is shown for a battery whose give run is in the plan (shots/sunny10-*, out/e1.out E1b), so that sentence is false.
Minor: the tests cannot see finding 1.test_ux54.py:196 compares tomorrow's tab blocks with runs_for() itself, which proves nothing. My mutation R5 makes tomorrow's blocks ignore the UX-53 gate, and it survives. Please add a check of tomorrow's blocks against what control does at that time (as in e1.py / e2.py).
Minor: trust is applied differently from the plan.app/daycurve.py:117 counts trust x sun - house and stores the spare at EFF. dayplan.plan() (app/dayplan.py:195-202) counts trust x EFF x max(0, sun - house). For 10 kWh of sun and 2 kWh of house at 70 %, the curve stores 4.75 kWh and the plan 5.32 kWh. That contradicts the DECISIONS line "as the plan does". Align it, or say in DECISIONS that it differs.
Info, pre-existing: on the autumn DST day, dayplan._hour_slices() gives 24 slices for 25 h (the 02:00 slice is 2 h). The tab then draws 24 equal columns on a 25 h axis. The totals are right and it only affects the night hour.
Over-count, reproduced (not changed here; it needs its own card)
Forecast.between(s, e) passes s as "now" to _day_between() (app/solarfc.py:95). dayplan.plan() calls it once per hour slice (app/dayplan.py:195), so for a state-only forecast each hour is spread over what is left of the bell from that hour. On the owner's shape at 10:07, 14.45 kWh left becomes 57.50 kWh by the plan's per-hour sum. At 07:00, 17.77 becomes 74.26. At 13:00, 9.00 becomes 31.87. The tab's sum equals the total, and an hourly split (periods) is not affected. This is not today only: the evening-before plan that sizes the night grid charge covers tomorrow's hours too.
Day forecast, A Leftover solar at 20 %, plan at 23:30 for 17:00 tomorrow
forecast summed
A planned level
A grid charge
6 kWh
11.39 vs 5.34
43.5 vs 68.5 %
3.60 vs 7.44 kWh
12 kWh
22.77 vs 10.69
0.0 vs 49.5 %
0.00 vs 4.53 kWh
20 kWh
37.95 vs 17.82
0.0 vs 21.2 %
0.00 vs 0.18 kWh
(Shipped vs each span measured from now, out/e3c.out.) On any site with Leftover solar and Forecast.Solar or another state-only sensor, the plan under-charges from the grid. Proposed card: Forecast.between() over-counts a state-only forecast in dayplan.plan().
Text (UX-08), proposed and not pushed, because the PR goes back anyway
key
now
proposed
home_words.day.cycle
Its level is not shown from the start. / Het niveau wordt niet getoond vanaf de start.
Its level is not shown once the cycle starts. / Het niveau wordt niet getoond zodra de cyclus start.
home_words.day.no_tomorrow
...The levels stop at sunrise. / ...stoppen bij zonsopgang.
...The levels stop at 06:00. / ...stoppen om 06:00. (the curve ends at BELL_FROM_H, while sunrise in winter is near 08:45)
home_words.day.ahead
The add-on plans prices about a day ahead. / plant prijzen ongeveer...
The add-on plans about a day ahead. / De add-on plant ongeveer een dag vooruit.
home_words.day.no_level
Its levels are not shown. / De niveaus worden niet getoond.
Its level is not shown. / Het niveau wordt niet getoond.
home.forecast.measured
Solar measured / Zon gemeten
Measured solar / Gemeten zon
home_words.day.no_blocks
No charge or give blocks planned.
Reword it with the fix for finding 1. Today it is shown where a give run is planned.
The other strings are correct, natural Dutch, short, and mean the same in both languages. The "not known yet" notes cover modes off, no forecast set, a missing or stale forecast, learning, no tomorrow, stopped and shadow.
Verdict: changes requested. Finding 1 must be fixed and 2 is needed; 3 and the text are small. Not merged.
## UX-54 review (Opus), head `b6b2d1b`: changes requested, not merged
Control is untouched and the tab is light. One display defect breaks the card's acceptance rule ("a block on the tab exists exactly where the plan has one"), so I did not merge. The fix changes behaviour, so I have not pushed it.
### What holds (evidence in `scratch/UX-54/review/out/`)
- **No control change.** The replay digests equal base for all 4 variants (`p3_ls` 3de48189b0dc, `p3_both_ch` ff6e86ac61e4, `p3_ch_sell` aa76f7c3a153, `arb_only` (modes off) 3b372696ab88). I ran each on base with and without a solar entity, and on head with the tab built every 30 s of replay time (5 760 builds per run, all ok), with and without a solar entity. That is 16 runs, and the digest is the same within each variant. On 7 scenarios, every attribute on the Controller is the same before and after 5 tab builds in EN and NL. The one exception is `home_prices`, where only the objects are new and the values are equal. Building the tab makes no HA calls, and the language goes back to EN afterwards. `SolarToday` holds at most 24 to 25 rows and resets each local day. Bad, NaN, inf, None and negative readings and a backward clock step do not raise. One reading costs 12 to 37 us.
- **Curve:** the roles match `modetrade.decide()` (HOUSE, HOLD, GIVE, REST, CHARGE). Levels stay inside the floor and ceiling. `charge_to()` is used as the plan uses it.
- **Over-count (dev's finding) is real, and larger than the PR says.** Details are below.
- **Browser:** 9 head scenarios x EN/NL x 1000 px and 360 px, today and tomorrow, 200 checks, 0 failed, no page or console errors. Home and Batteries show the same text as base (digits masked) in 5 scenarios x EN/NL x desktop/phone. Neither tab asks for `/forecast`. The tab polls every 30 s while shown and stops while the page is hidden or on Settings.
- **Mutations:** I re-ran 6 of the dev's (M01, M03, M06, M08, M12, M14) and all were caught. Of my own 5, 4 were caught; R5 is the one that survived.
- **Full suite** in `goodwe-addon-checks:1` (no network, sim mounted, `GOODWE_REQUIRE_HWSIM=1`) on the head: 84 of 84 `test_*.py` pass, `test_freeze` included. CI runs 1400 and 1401 are green on `b6b2d1b`; run 1400 is a real 48-min run with 85 PASS.
### Findings
1. **Major: tomorrow's give blocks vanish when a battery is low now.** Files: `app/homeview.py:616` (`runs_for`) → `mode_runs()` `room()` `:414` / `:440`, and `trade_runs()` `:347`, `:356`. Tomorrow's blocks are sized from the level **now**. In the evening after a give run, a Leftover solar or Cheap hours battery is at the floor, so tomorrow's give run is drawn on neither battery. Tomorrow's grid block is still drawn, and the curve rises in it and then falls 17:00 to 21:00 with no block (`out/e1.out`, `shots/dyn22-en-desk-tomorrow.png`). At 18:00 tomorrow, with those levels, `_mode_roles()` gives both batteries GIVE and Home draws both give blocks. With modes off it is the same: with a charge leg today at 23:00 and a give leg tomorrow at 07:00, Home's own line says "Tomorrow: gives power at 07:00", but the tab's tomorrow has no block when the battery is at the floor now. It does have the block at 60 % (`out/e2.out`). A fix could size a block that has not started from the curve's level at its start, or from today's legs. Today has the same root (the dev's noted limit, inherited from Home). There, the new line `day.no_blocks` "No charge or give blocks planned." (`app/dayview.py:250`) is shown for a battery whose give run is in the plan (`shots/sunny10-*`, `out/e1.out` E1b), so that sentence is false.
2. **Minor: the tests cannot see finding 1.** `test_ux54.py:196` compares tomorrow's tab blocks with `runs_for()` itself, which proves nothing. My mutation R5 makes tomorrow's blocks ignore the UX-53 gate, and it survives. Please add a check of tomorrow's blocks against what control does at that time (as in `e1.py` / `e2.py`).
3. **Minor: trust is applied differently from the plan.** `app/daycurve.py:117` counts `trust x sun - house` and stores the spare at EFF. `dayplan.plan()` (`app/dayplan.py:195-202`) counts `trust x EFF x max(0, sun - house)`. For 10 kWh of sun and 2 kWh of house at 70 %, the curve stores 4.75 kWh and the plan 5.32 kWh. That contradicts the DECISIONS line "as the plan does". Align it, or say in DECISIONS that it differs.
4. **Info, pre-existing:** on the autumn DST day, `dayplan._hour_slices()` gives 24 slices for 25 h (the 02:00 slice is 2 h). The tab then draws 24 equal columns on a 25 h axis. The totals are right and it only affects the night hour.
### Over-count, reproduced (not changed here; it needs its own card)
`Forecast.between(s, e)` passes `s` as "now" to `_day_between()` (`app/solarfc.py:95`). `dayplan.plan()` calls it once per hour slice (`app/dayplan.py:195`), so for a state-only forecast each hour is spread over what is left of the bell **from that hour**. On the owner's shape at 10:07, 14.45 kWh left becomes 57.50 kWh by the plan's per-hour sum. At 07:00, 17.77 becomes 74.26. At 13:00, 9.00 becomes 31.87. The tab's sum equals the total, and an hourly split (periods) is not affected. This is **not today only**: the evening-before plan that sizes the night grid charge covers tomorrow's hours too.
| Day forecast, A Leftover solar at 20 %, plan at 23:30 for 17:00 tomorrow | forecast summed | A planned level | A grid charge |
|---|---|---|---|
| 6 kWh | 11.39 vs 5.34 | 43.5 vs 68.5 % | 3.60 vs 7.44 kWh |
| 12 kWh | 22.77 vs 10.69 | 0.0 vs 49.5 % | 0.00 vs 4.53 kWh |
| 20 kWh | 37.95 vs 17.82 | 0.0 vs 21.2 % | 0.00 vs 0.18 kWh |
(Shipped vs each span measured from now, `out/e3c.out`.) On any site with Leftover solar and Forecast.Solar or another state-only sensor, the plan under-charges from the grid. Proposed card: `Forecast.between()` over-counts a state-only forecast in `dayplan.plan()`.
### Text (UX-08), proposed and not pushed, because the PR goes back anyway
| key | now | proposed |
|---|---|---|
| `home_words.day.cycle` | Its level is not shown from the start. / Het niveau wordt niet getoond vanaf de start. | Its level is not shown once the cycle starts. / Het niveau wordt niet getoond zodra de cyclus start. |
| `home_words.day.no_tomorrow` | ...The levels stop at sunrise. / ...stoppen bij zonsopgang. | ...The levels stop at 06:00. / ...stoppen om 06:00. (the curve ends at `BELL_FROM_H`, while sunrise in winter is near 08:45) |
| `home_words.day.ahead` | The add-on plans prices about a day ahead. / plant prijzen ongeveer... | The add-on plans about a day ahead. / De add-on plant ongeveer een dag vooruit. |
| `home_words.day.no_level` | Its levels are not shown. / De niveaus worden niet getoond. | Its level is not shown. / Het niveau wordt niet getoond. |
| `home.forecast.measured` | Solar measured / Zon gemeten | Measured solar / Gemeten zon |
| `home_words.day.no_blocks` | No charge or give blocks planned. | Reword it with the fix for finding 1. Today it is shown where a give run is planned. |
The other strings are correct, natural Dutch, short, and mean the same in both languages. The "not known yet" notes cover modes off, no forecast set, a missing or stale forecast, learning, no tomorrow, stopped and shadow.
Verdict: **changes requested.** Finding 1 must be fixed and 2 is needed; 3 and the text are small. Not merged.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
F1: tomorrow's blocks no longer depend on the battery's level now. Home sizes
a block from the level now (`room()`, `plan_need()` in mode_runs, the leg
sizing in trade_runs) and draws none when there is no room or energy. For a
day that has not started the builders take `ahead=True` (Home never passes
it): a charge is read from the floor, a give from the ceiling. A grid block is
then kept only where `modetrade.chargers()` would charge at the curve's level
when the run starts. The UX-53 gate is unchanged. Today still equals Home; a
give run in the plan with no block (empty battery) has its own sentence
instead of "No charge or give blocks planned".
F2: test_ux54.py part 7 walks the real `_mode_roles()` every 15 minutes
through tomorrow and compares the roles with the tab's blocks (modes on, the
peak gate, part-full batteries), and the control cycle's verdict at 07:00
(modes off).
F3: the curve counts spare solar as trust x max(0, sun - house), stored at
EFF, as `dayplan.plan()` does (10 kWh sun, 2 kWh house, 70 %: 5.32 kWh).
Strings: cycle, no_tomorrow, ahead, no_level, measured; new give_later.
No decision, command, timing or option key changes.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
- Tomorrow's grid block is drawn to where control stops charging. A cheap run
is sized for all batteries together; a slower one is still below
`charge_to()` when it ends and control plans the next cheap slots. The tab
asks `modetrade.energy()` and `priceruns.plan()` with the packs at the
curve's level and extends the run, so the curve charges as long.
- Each battery's block ends where it is at its own level (grid) or at the
floor (give). The curve puts a point where a battery reaches its charge
level inside a step.
- `homeview.mode_runs(ahead=f, rp=...)` reads each block at the level the
curve has when it starts, with Home's own filter and MIN_KWH: no give block
at the floor, no grid block at the planned level. Home never passes either.
- `no_tomorrow` names the hour from `solarfc.BELL_FROM_H`, where the curve
stops.
- test_ux54.py part 7 compares control (`_mode_roles()` every 15 minutes) with
the tab with no tolerance, and adds the cloudy / floor case.
No decision, command, timing or option key changes.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Modes off has no curve (no forecast is read, no house profile kept), so
dayview._levels() never ran there and tomorrow's legs were drawn from the
bound. homeview.trade_runs(ahead=f) now reads each leg at f(name, kind,
start), as mode_runs() does: no give while the emptiest battery is at the
floor, each share sized from that level. dayview._counted() gives a give
the level by Home's own count (the level now, plus what the blocks before
it put in, less what they take out); a charge is read from the floor,
because a full battery covers the house before the cheap hour.
The battery line says a give is planned when one is and the battery has
no give block, also with modes off.
test_ux54.py: the modes-on walk no longer relabels a GIVE role at the
floor; it asks control's own discharge envelope (packshare.envelope())
with each pack's cut-out at the floor. The peak-guard 20 % case asserts
the curve is at the floor at the 17:00 give start. A modes-off walk on a
cloudy day (arbitrage.decide() with the cycle's inputs, the packs moved by
what control does and by the house), cases A-D: control charges and gives
in exactly the quarters the tab has a block. trade_runs(ahead=f) unit
check. _stops() ends a give block where the level the tab shows is at the
floor (LEVEL_DP).
No decision, command, timing or option key changes.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Dev round 4, head 4256bd3 (verifier findings on round 3)
With modes off there is no curve: no forecast is read and no house profile is kept. _levels() and settle() never ran there, in round 2 or round 3 (missing = "modes_off"). Tomorrow's legs were drawn from the bound.
homeview.trade_runs(ahead=f) now reads each leg at f(name, kind, start), the same as mode_runs(): no give while the emptiest battery is at the floor, and each share is sized from that level. With modes off, f is Home's own count (dayview._counted()): the level now, plus earlier charges, less earlier gives. A charge is read from the floor, because a full battery covers the house before the cheap hour and control then charges it (case C).
The battery line says "The add-on plans to give power. ..." when a give is planned and there is no give block (modes off too).
Test: the modes-on walk has no "no energy" relabel any more. It asks control's discharge envelope (packshare.envelope()), with the cut-out at the floor. The peak-guard 20 % case asserts the curve is at the floor at 17:00. The new modes-off walk (arbitrage.decide() with the cycle's inputs, a cloudy day, the house drawing power) has cases A-D, including the cloudy-day give at the floor. In every case control charges and gives in exactly the quarters the tab has a block.
Mutations N1-N6 and T1-T6, R5, R6, M01, M03, R9 are all caught. The replay digests are unchanged (modes on and off, with the tab built during the run). Browser: 192 checks, 0 failed. CI run 1406 is green.
Found, not changed: control stops a pack at its own cut-out, not at the arbitrage floor. With modes on, a battery in a give run covers the house below the floor, and Home draws no block there.
**Dev round 4, head `4256bd3`** (verifier findings on round 3)
- With modes off there is no curve: no forecast is read and no house profile is kept. `_levels()` and `settle()` never ran there, in round 2 or round 3 (`missing = "modes_off"`). Tomorrow's legs were drawn from the bound.
- `homeview.trade_runs(ahead=f)` now reads each leg at `f(name, kind, start)`, the same as `mode_runs()`: no give while the emptiest battery is at the floor, and each share is sized from that level. With modes off, `f` is Home's own count (`dayview._counted()`): the level now, plus earlier charges, less earlier gives. A charge is read from the floor, because a full battery covers the house before the cheap hour and control then charges it (case C).
- The battery line says "The add-on plans to give power. ..." when a give is planned and there is no give block (modes off too).
- Test: the modes-on walk has no "no energy" relabel any more. It asks control's discharge envelope (`packshare.envelope()`), with the cut-out at the floor. The peak-guard 20 % case asserts the curve is at the floor at 17:00. The new modes-off walk (`arbitrage.decide()` with the cycle's inputs, a cloudy day, the house drawing power) has cases A-D, including the cloudy-day give at the floor. In every case control charges and gives in exactly the quarters the tab has a block.
- Mutations N1-N6 and T1-T6, R5, R6, M01, M03, R9 are all caught. The replay digests are unchanged (modes on and off, with the tab built during the run). Browser: 192 checks, 0 failed. CI run 1406 is green.
- Found, not changed: control stops a pack at its own cut-out, not at the arbitrage floor. With modes on, a battery in a give run covers the house below the floor, and Home draws no block there.
Opus re-review of head 4256bd3: changes requested. Not merged.
Rounds 2 to 4 fixed everything from my round 1 and from the router rounds 2 and 3. Each fix has a test that fails when the fix is taken out. Round 4 is right about one point: with modes off, _levels() was never reached in rounds 2 and 3. The replay digests match base, Home matches base in 1,080 builds, the suite passes 84 of 84, and the browser run had 248 checks with 0 failures. I am still not merging, because of one blocker:
Blocker.app/dayplan.py:159-169_hour_slices() never returns on the spring DST day. At 00:00 UTC on 28 March 2027 (01:00 CET), the next local hour is 02:00, which the billing time zone reads as CEST, and that is 00:00 UTC again. So t never moves and the list grows until memory runs out. The tab calls this function for today and tomorrow on every site in every mode (dayview.py:324, :177, daycurve.py:102). So on 27 and 28 March, every GET /forecast blocks the add-on's only event loop, and control runs on that loop too. In 27 minutes one process reached 10 GB. In a fuzz of 60 builds, 9 hung, all with today or tomorrow on 28 March. The bug in _hour_slices() is old, but this PR gives it a new trigger: opening a page. Fix _hour_slices() first (it is control code, see 2), then add a test that builds the tab on both DST days.
Critical, already on release/1.0, needs its own card. Control has the same loop: dayplan.plan() calls _hour_slices(now, until.start) at dayplan.py:194. On base a1d0703, Controller._mode_roles() hangs at 22:00 on 27 March 2027 for a modes-on site with a forecast (Leftover solar and Cheap hours). The function is the same in v0.19.0. That is the date it must be fixed by.
Minor, but it breaks "no tolerance". The curve has no point where a battery that is giving reaches the floor inside a step (daycurve.py:155-178). _stops() (dayview.py:297-300) then ends the give block at the next hour edge. I walked control with its own model of the batteries, not the curve's levels (no sun, cut-out at the floor). At 25/45 % with the peak guard on, the tab shows 17:00-21:00 but control gives only 17:00-20:15. With 6 kWh of sun the tab shows 17:00-18:00 and control gives 17:00-17:15. The dev's walk feeds control the curve's own hourly levels, so it cannot see this. Fix: cut the step at the point where the battery reaches the floor, as daycurve.py:134-147 does for a grid charge, and test against levels the curve did not make.
Minor, text. With modes off, give_later tells a battery at 60 % "No block shows while the battery has no energy to give." The real reason is that the other battery is at the floor (case D). Proposed: EN "...No block shows while a battery has no energy to give." / NL "...Er verschijnt geen blok zolang een batterij geen stroom heeft om te leveren."
Info, DECISIONS. Case C: keeping the modes-off grid block is the right call, because dropping it would hide night charges that control does make. One limit should be added. On a sunny day with a cheap midday quarter, the tab draws a charge that control will not take once the sun has filled the packs: at 95 % or 100 % control does nothing.
Info: cut-out below the floor. With modes on, control covering the house below the floor in a give run is intended (OPT-11 design §1, Leftover solar: "Covering the house goes down to the cut-out level"; SAFETY-25 Option A). Home and the tab stop at the floor, so with the example cut-out of 10 % they draw no give block that control still gives in. That is a display card, not a control defect.
Cost: a tab build now takes 17-57 ms on this host, against 7-9 ms in round 1. With the CPU throttled to 1/3 it takes 51-166 ms. Each build is at most 101 curve passes, which works out to about 0.4 s here and about 1.5 s at 1/3 CPU. The build runs on the event loop.
Evidence is in scratch/UX-54/review2/: e5*.py (the independent walk), e11.py and e12.py (DST), e9.py (fuzz), e8.py (cost) and mut.out (22 mutations: 20 caught, 2 equivalent).
**Opus re-review of head `4256bd3`: changes requested. Not merged.**
Rounds 2 to 4 fixed everything from my round 1 and from the router rounds 2 and 3. Each fix has a test that fails when the fix is taken out. Round 4 is right about one point: with modes off, `_levels()` was never reached in rounds 2 and 3. The replay digests match base, Home matches base in 1,080 builds, the suite passes 84 of 84, and the browser run had 248 checks with 0 failures. I am still not merging, because of one blocker:
1. **Blocker.** `app/dayplan.py:159-169` `_hour_slices()` never returns on the spring DST day. At 00:00 UTC on 28 March 2027 (01:00 CET), the next local hour is 02:00, which the billing time zone reads as CEST, and that is 00:00 UTC again. So `t` never moves and the list grows until memory runs out. The tab calls this function for today and tomorrow on every site in every mode (`dayview.py:324`, `:177`, `daycurve.py:102`). So on 27 and 28 March, every `GET /forecast` blocks the add-on's only event loop, and control runs on that loop too. In 27 minutes one process reached 10 GB. In a fuzz of 60 builds, 9 hung, all with today or tomorrow on 28 March. The bug in `_hour_slices()` is old, but this PR gives it a new trigger: opening a page. Fix `_hour_slices()` first (it is control code, see 2), then add a test that builds the tab on both DST days.
2. **Critical, already on release/1.0, needs its own card.** Control has the same loop: `dayplan.plan()` calls `_hour_slices(now, until.start)` at `dayplan.py:194`. On base `a1d0703`, `Controller._mode_roles()` hangs at 22:00 on 27 March 2027 for a modes-on site with a forecast (Leftover solar and Cheap hours). The function is the same in v0.19.0. That is the date it must be fixed by.
3. **Minor, but it breaks "no tolerance".** The curve has no point where a battery that is giving reaches the floor inside a step (`daycurve.py:155-178`). `_stops()` (`dayview.py:297-300`) then ends the give block at the next hour edge. I walked control with its own model of the batteries, not the curve's levels (no sun, cut-out at the floor). At 25/45 % with the peak guard on, the tab shows 17:00-21:00 but control gives only 17:00-20:15. With 6 kWh of sun the tab shows 17:00-18:00 and control gives 17:00-17:15. The dev's walk feeds control the curve's own hourly levels, so it cannot see this. Fix: cut the step at the point where the battery reaches the floor, as `daycurve.py:134-147` does for a grid charge, and test against levels the curve did not make.
4. **Minor, text.** With modes off, `give_later` tells a battery at 60 % "No block shows while the battery has no energy to give." The real reason is that the other battery is at the floor (case D). Proposed: EN "...No block shows while a battery has no energy to give." / NL "...Er verschijnt geen blok zolang een batterij geen stroom heeft om te leveren."
5. **Info, DECISIONS.** Case C: keeping the modes-off grid block is the right call, because dropping it would hide night charges that control does make. One limit should be added. On a sunny day with a cheap midday quarter, the tab draws a charge that control will not take once the sun has filled the packs: at 95 % or 100 % control does nothing.
6. **Info: cut-out below the floor.** With modes on, control covering the house below the floor in a give run is intended (OPT-11 design §1, Leftover solar: "Covering the house goes down to the cut-out level"; SAFETY-25 Option A). Home and the tab stop at the floor, so with the example cut-out of 10 % they draw no give block that control still gives in. That is a display card, not a control defect.
Cost: a tab build now takes 17-57 ms on this host, against 7-9 ms in round 1. With the CPU throttled to 1/3 it takes 51-166 ms. Each build is at most 101 curve passes, which works out to about 0.4 s here and about 1.5 s at 1/3 CPU. The build runs on the event loop.
Evidence is in `scratch/UX-54/review2/`: `e5*.py` (the independent walk), `e11.py` and `e12.py` (DST), `e9.py` (fuzz), `e8.py` (cost) and `mut.out` (22 mutations: 20 caught, 2 equivalent).
daycurve.curve() puts a point where a giving battery reaches the floor
inside a step (_floor_cut(), as it already does for a grid charge reaching
its level), so _stops() ends a give block in the quarter control stops. A
walk where control moves the packs with its own model (not the curve's
levels) matches the tab quarter by quarter in 11 cases; 25/45 % with the
peak guard: control 17:00-20:15, block 17:00-20:06.
SolarToday keys an hour by its start taken back from the UTC instant, so
the two 02:00 hours of the autumn change stay apart. test_ux54_dst.py builds
the tab (forecast_payload()) at every quarter of 27-29 March and
30 October-1 November 2027, modes on and off, in the no-tzdata zone and,
where the host has tzdata, ZoneInfo, every call under a time limit raised
as a BaseException that forecast_payload() cannot swallow.
give_later: "No block shows while a battery has no energy to give." /
"Er verschijnt geen blok zolang een batterij niets kan leveren."
DECISIONS: what the walks prove, the modes-off charge drawn on a sunny day,
the clock-change days.
No decision, command, timing or option key changes.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Dev round 5, head a4b769f (re-review findings on 4256bd3, on top of SAFETY-123)
I merged release/1.0 f5afb42 (SAFETY-123) into UX-54 as merge commit 00c8ae3. There were no conflicts.
Finding 1 (DST): the new test_ux54_dst.py builds the tab with forecast_payload() at every quarter of 27-29 March and 30 October-1 November 2027, with modes on and with modes off. Each zone gets 576 builds per mode. It runs in the no-tzdata zone, and also in ZoneInfo Europe/Brussels where the host has tzdata. Every call runs under a 10 s limit raised as a BaseException, which forecast_payload()'s except Exception cannot swallow; a planted endless build checks this. The test also checks the 23, 24 and 25 hours and their labels. The tab's own hour loop had one more wall-clock step: SolarToday keyed an hour by its local wall clock. It now uses the start of the local hour taken back from the UTC instant, so the two autumn 02:00 hours stay apart.
Finding 3:daycurve.curve() now puts a point where a giving battery reaches the floor (_floor_cut()), the same way it already does for a grid charge. A new walk lets control move the packs with its own model, not the curve's levels. In all 11 cases it matches the tab quarter by quarter. At 25/45 % with the peak guard, control gives 17:00-20:15 and the block runs 17:00-20:06. At 20/20 % with the guard and 6 kWh of sun, control gives 17:00-17:15 and the block runs 17:00-17:07.
Finding 4: EN "No block shows while a battery has no energy to give." NL "Er verschijnt geen blok zolang een batterij niets kan leveren." The proposed NL sentence had 13 words, one over the STYLE limit that test_messages.py enforces, so I shortened it.
Finding 5: DECISIONS now lists the sunny-day modes-off charge as a limit, and says what the walks prove.
Evidence: all mutations are caught, both the new ones and earlier ones. Replay digests match base f5afb42. The browser run had 144 checks and 0 failures. The suite passed 86 of 86. CI run 1413 is green. Run 1411 on 8dbf831 failed in test_bytecode.py, because the new test file lacked the MUTATION.md pointer; that is fixed in a4b769f.
**Dev round 5, head `a4b769f`** (re-review findings on `4256bd3`, on top of SAFETY-123)
- I merged release/1.0 `f5afb42` (SAFETY-123) into `UX-54` as merge commit `00c8ae3`. There were no conflicts.
- **Finding 1 (DST):** the new `test_ux54_dst.py` builds the tab with `forecast_payload()` at every quarter of 27-29 March and 30 October-1 November 2027, with modes on and with modes off. Each zone gets 576 builds per mode. It runs in the no-tzdata zone, and also in ZoneInfo Europe/Brussels where the host has tzdata. Every call runs under a 10 s limit raised as a `BaseException`, which `forecast_payload()`'s `except Exception` cannot swallow; a planted endless build checks this. The test also checks the 23, 24 and 25 hours and their labels. The tab's own hour loop had one more wall-clock step: `SolarToday` keyed an hour by its local wall clock. It now uses the start of the local hour taken back from the UTC instant, so the two autumn 02:00 hours stay apart.
- **Finding 3:** `daycurve.curve()` now puts a point where a giving battery reaches the floor (`_floor_cut()`), the same way it already does for a grid charge. A new walk lets control move the packs with its own model, not the curve's levels. In all 11 cases it matches the tab quarter by quarter. At 25/45 % with the peak guard, control gives 17:00-20:15 and the block runs 17:00-20:06. At 20/20 % with the guard and 6 kWh of sun, control gives 17:00-17:15 and the block runs 17:00-17:07.
- **Finding 4:** EN "No block shows while a battery has no energy to give." NL "Er verschijnt geen blok zolang een batterij niets kan leveren." The proposed NL sentence had 13 words, one over the STYLE limit that `test_messages.py` enforces, so I shortened it.
- **Finding 5:** DECISIONS now lists the sunny-day modes-off charge as a limit, and says what the walks prove.
- **Evidence:** all mutations are caught, both the new ones and earlier ones. Replay digests match base `f5afb42`. The browser run had 144 checks and 0 failures. The suite passed 86 of 86. CI run 1413 is green. Run 1411 on `8dbf831` failed in `test_bytecode.py`, because the new test file lacked the MUTATION.md pointer; that is fixed in `a4b769f`.
Re-review 3 of a4b769f: changes requested. Not merged.
The round-5 fixes for findings 1, 4 and 5 are right, and finding 3 is right for the 11 test cases. The new floor cut has one defect that the tests do not reach (finding 1 below). Fixing it changes behaviour, so I pushed nothing.
Findings
Major.goodwe_controller/app/daycurve.py:114-115, in _floor_cut().
The bug: the condition any(c < rating*hrs ...) also counts a battery that is already at the floor, because its cap is 0. In a give run, once one battery is empty and the house needs more than the other battery's rating, the step is cut again and again, about 1 s at a time, until MAX_FLOOR_CUTS (400) runs out. After that, no floor point is added anywhere in that curve.
Example:dynamic, 22:00, 30/80 % with the guard and a 7 kW evening house. A second example uses 2 x 3 kW packs with a 3.5 kW house, which is a normal size for a client site.
Effect:
400 floor cuts in every curve: 2,800 per tab build.
425 level points per battery instead of about 28, so the payload grows from 9.8 kB to 22 kB.
A tab build takes 330-470 ms on this host and 1.1-1.5 s at --cpus=0.33, against 30-50 ms before. It runs on the event loop control uses, every 30 s while the tab is open.
B's give block ends at the hour edge again: the tab shows 17:00-20:00, while control gives until 19:45 (my walk, 1 mismatched quarter).
Fix (behaviour, not pushed): only a battery that still holds energy can reach the floor, so the condition becomes EPS < c < by[n].rating_kw * hrs * (1.0 - 1e-9). I tried it in a scratch copy: 23 cuts per curve, 52 ms, 10.0 kB, 0 mismatched quarters in all 8 cases of my walk, and B ends at 19:32, inside control's last quarter. test_ux54.py and test_ux54_dst.py pass with the change. Optional: when the others are held to their rating, cut where the battery runs out at the rate it really gives. That puts the cut at the right point at once, instead of after about 20 cuts that get closer each time.
Test: add this case (one battery at the floor, the other held to its rating) to own_walk(), and check that the number of level points stays small.
Minor, tests. My mutation M1 (the floor cut placed 30 % later) and M2 (no cut for the second group) both survive. The walk checks a block end only to the quarter. A unit check of _floor_cut() against a time worked out by hand would catch M1. M2 changes only the level points, not a block, so it is near-equivalent.
What is right
Merge 00c8ae3: its tree equals git merge-tree 4256bd3 f5afb42 (f6cddf2d), so it brings in release/1.0 and nothing else. Against f5afb42, the PR changes main.py, web.py and homeview.py exactly as round 4 did (same diff hash). Round 5 changes only daycurve.py, the two text files, DECISIONS and tests.
DST:test_ux54_dst.py passes in goodwe-addon-checks:1 (no tzdata) and in goodwe-checks-tz:1 (both zones). My own walk covered owner, dynamic, periods, sunny and off, every quarter of 27-29 March and 30 October-1 November 2027, three level pairs, both zones, under a faulthandler hard exit. That is 8,640 builds: 0 wrong, the days have 23, 24 and 25 hours, and the slowest build took 0.44 s. Nothing on the path catches BaseException. The _hour_start() change is right: it subtracts the local minutes from the UTC instant, so it never steps the wall clock.
Give block at the floor: my walk lets control move the packs, with a water fill like the allocator's. These cases gave 0 mismatched quarters: 25/45 with the guard (block 17:00-20:06, control 17:00-20:15), 20/20 with the guard and 6 kWh (17:00-17:07, control 17:00-17:15), and 14 kWh and 30/30 with the guard. Three of my four new cases also gave 0: a 3 kW house at 25/45, 40/25 with sun and no guard, and 35/35 with a 2.5 kW house and sun. The fourth new case is finding 1. Grid blocks match exactly.
Home and control:
Variant
Base f5afb42
Head with the tab built every 6 ticks
p3_ls pv
3de48189b0dc
3de48189b0dc
p3_both_ch pv
ff6e86ac61e4
ff6e86ac61e4
arb_only (modes off)
3b372696ab88
3b372696ab88, also with pv
Every head replay had 5,760 tab builds, all ok. Home's output is identical across 1,080 builds (5757713d1d52…, base = head).
Text:give_later is fine in EN and NL. The NL sentence is natural, has 12 words and passes test_messages.py.
Browser: owner, dynamic, off, 25/45 with the guard (20:06 is shown), off case D (the give_later line), and the 7 kW case. Each was checked in EN and NL, desktop and 360 px: 124 checks, 0 failed, no page or console errors. Home and Batteries match base.
Mutations: the dev's F1-F6 are all caught. Of mine, M3 (hour key = instant) and M4 (MIN_CUT 20 min) are caught; M1 and M2 survive (finding 2).
CI: run 1413 checks / unit and run 1414 infra-pin / fresh are green on a4b769f. Both are real runs: 86 PASS, merged into release/1.0.
Suite: the offline suite in goodwe-addon-checks:1 (--network none, sim mounted, GOODWE_REQUIRE_HWSIM=1) passed 86 of 86 on a4b769f.
**Re-review 3 of `a4b769f`: changes requested. Not merged.**
The round-5 fixes for findings 1, 4 and 5 are right, and finding 3 is right for the 11 test cases. The new floor cut has one defect that the tests do not reach (finding 1 below). Fixing it changes behaviour, so I pushed nothing.
**Findings**
1. **Major.** `goodwe_controller/app/daycurve.py:114-115`, in `_floor_cut()`.
- **The bug:** the condition `any(c < rating*hrs ...)` also counts a battery that is already at the floor, because its cap is 0. In a give run, once one battery is empty and the house needs more than the other battery's rating, the step is cut again and again, about 1 s at a time, until `MAX_FLOOR_CUTS` (400) runs out. After that, no floor point is added anywhere in that curve.
- **Example:** `dynamic`, 22:00, 30/80 % with the guard and a 7 kW evening house. A second example uses 2 x 3 kW packs with a 3.5 kW house, which is a normal size for a client site.
- **Effect:**
- 400 floor cuts in every curve: 2,800 per tab build.
- 425 level points per battery instead of about 28, so the payload grows from 9.8 kB to 22 kB.
- A tab build takes 330-470 ms on this host and 1.1-1.5 s at `--cpus=0.33`, against 30-50 ms before. It runs on the event loop control uses, every 30 s while the tab is open.
- B's give block ends at the hour edge again: the tab shows 17:00-20:00, while control gives until 19:45 (my walk, 1 mismatched quarter).
- **Fix (behaviour, not pushed):** only a battery that still holds energy can reach the floor, so the condition becomes `EPS < c < by[n].rating_kw * hrs * (1.0 - 1e-9)`. I tried it in a scratch copy: 23 cuts per curve, 52 ms, 10.0 kB, 0 mismatched quarters in all 8 cases of my walk, and B ends at 19:32, inside control's last quarter. `test_ux54.py` and `test_ux54_dst.py` pass with the change. Optional: when the others are held to their rating, cut where the battery runs out at the rate it really gives. That puts the cut at the right point at once, instead of after about 20 cuts that get closer each time.
- **Test:** add this case (one battery at the floor, the other held to its rating) to `own_walk()`, and check that the number of level points stays small.
2. **Minor, tests.** My mutation M1 (the floor cut placed 30 % later) and M2 (no cut for the second group) both survive. The walk checks a block end only to the quarter. A unit check of `_floor_cut()` against a time worked out by hand would catch M1. M2 changes only the level points, not a block, so it is near-equivalent.
**What is right**
- **Merge `00c8ae3`:** its tree equals `git merge-tree 4256bd3 f5afb42` (`f6cddf2d`), so it brings in release/1.0 and nothing else. Against `f5afb42`, the PR changes `main.py`, `web.py` and `homeview.py` exactly as round 4 did (same diff hash). Round 5 changes only `daycurve.py`, the two text files, DECISIONS and tests.
- **DST:** `test_ux54_dst.py` passes in `goodwe-addon-checks:1` (no tzdata) and in `goodwe-checks-tz:1` (both zones). My own walk covered owner, dynamic, periods, sunny and off, every quarter of 27-29 March and 30 October-1 November 2027, three level pairs, both zones, under a `faulthandler` hard exit. That is 8,640 builds: 0 wrong, the days have 23, 24 and 25 hours, and the slowest build took 0.44 s. Nothing on the path catches `BaseException`. The `_hour_start()` change is right: it subtracts the local minutes from the UTC instant, so it never steps the wall clock.
- **Give block at the floor:** my walk lets control move the packs, with a water fill like the allocator's. These cases gave 0 mismatched quarters: 25/45 with the guard (block 17:00-20:06, control 17:00-20:15), 20/20 with the guard and 6 kWh (17:00-17:07, control 17:00-17:15), and 14 kWh and 30/30 with the guard. Three of my four new cases also gave 0: a 3 kW house at 25/45, 40/25 with sun and no guard, and 35/35 with a 2.5 kW house and sun. The fourth new case is finding 1. Grid blocks match exactly.
- **Home and control:**
| Variant | Base `f5afb42` | Head with the tab built every 6 ticks |
|---|---|---|
| `p3_ls` pv | `3de48189b0dc` | `3de48189b0dc` |
| `p3_both_ch` pv | `ff6e86ac61e4` | `ff6e86ac61e4` |
| `arb_only` (modes off) | `3b372696ab88` | `3b372696ab88`, also with pv |
Every head replay had 5,760 tab builds, all `ok`. Home's output is identical across 1,080 builds (`5757713d1d52…`, base = head).
- **Text:** `give_later` is fine in EN and NL. The NL sentence is natural, has 12 words and passes `test_messages.py`.
- **Browser:** owner, dynamic, off, 25/45 with the guard (20:06 is shown), off case D (the `give_later` line), and the 7 kW case. Each was checked in EN and NL, desktop and 360 px: 124 checks, 0 failed, no page or console errors. Home and Batteries match base.
- **Mutations:** the dev's F1-F6 are all caught. Of mine, M3 (hour key = instant) and M4 (`MIN_CUT` 20 min) are caught; M1 and M2 survive (finding 2).
- **CI:** run 1413 `checks / unit` and run 1414 `infra-pin / fresh` are green on `a4b769f`. Both are real runs: 86 PASS, merged into release/1.0.
- **Suite:** the offline suite in `goodwe-addon-checks:1` (`--network none`, sim mounted, `GOODWE_REQUIRE_HWSIM=1`) passed 86 of 86 on `a4b769f`.
Evidence: `scratch/UX-54/review3/`. Report: `reports/UX-54-review3-report.md`.
daycurve._floor_cut() set a cut from a battery already at the floor (its
cap 0 is below its rating), so a give run with one battery empty and the
other held to its rating was cut until MAX_FLOOR_CUTS ran out: 2,800 cuts
and 460 ms a build, and give blocks back at the hour edge after that.
The shortfall in a step is now shared as control shares it (by what each
battery holds above the floor, packshare.weights(), each at most its
rating, the rest passed on), in the step and in the cut. A battery giving
v kW that holds h kWh is empty at h / v; one at the floor holds nothing and
never sets a cut. The cut lands where the step then comes out exact, so it
is placed once. curve(stats=) reports the floor cuts and whether the bound
stopped one.
test_ux54.py: _floor_cut() checked to the second against floor times worked
out by hand (one battery, a group with one held to its rating, the second
group, the split by what they hold), a battery at the floor sets no cut,
the bound is reported; own_walk() with the reviewer's two cases (30/80 %,
guard, 7 kW house; 2 x 3 kW, 3.5 kW house) and, per build, the floor cuts,
the level points and the time.
No decision, command, timing or option key changes.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Finding 1: a battery already at the floor no longer sets a floor cut. I went one step past the one-line fix:
The curve now splits a step's shortfall the way control does (packshare.weights()): by what each battery holds above the floor, each battery at most its rating, and any rest passed on. The module docstring already said this; the code used to split by step caps instead.
_floor_cut() places each cut where the battery runs out at the rate it really gives (h / v). The step then comes out exact, so one cut is enough.
Cost:
Case
Floor cuts per build, before
After
Build time, before (host / --cpus=0.33)
After
7 kW house
2,800
21
463 / 1,500 ms
32 / 109 ms
2 x 3 kW
2,000
10
335 / 1,072 ms
22 / 79 ms
Ordinary cases are unchanged. MAX_FLOOR_CUTS is never reached.
Walk:own_walk() gains both of your cases, so it now has 13 cases with 0 mismatched quarters. In the 7 kW case B's block is 17:00-19:32, and control's last giving quarter is 19:30-19:45. Per build the test also checks: at most 40 floor cuts, the bound never reached, at most 40 level points per battery and day, and under 0.2 s. Observed maxima are 14 cuts and 28 points.
Finding 2: I added unit checks against hand-worked times, to the second: 14:15, 28:30, 30:00 (second group) and 3:57:30 (split by what each holds). There is also a check that a battery at the floor sets no cut, and that curve(stats=) reports when the bound stops a cut.
Mutations: all caught, after one bound change. Caught on the first run: the old condition, the cut 30 % late, the split by caps, no cut for the second group, and a bound hit that is not reported.
A bound of 3 survived. It is equivalent, because no tested curve needs more than 3 cuts.
A bound of 1 is caught.
Other checks: the replay digests equal base f5afb42. The suite passes 86 of 86. test_ux54_dst.py passes in both images. CI run 1415 is green.
**Dev round 6, head `a0ace38`** (re-review 3)
- **Finding 1:** a battery already at the floor no longer sets a floor cut. I went one step past the one-line fix:
- The curve now splits a step's shortfall the way control does (`packshare.weights()`): by what each battery holds above the floor, each battery at most its rating, and any rest passed on. The module docstring already said this; the code used to split by step caps instead.
- `_floor_cut()` places each cut where the battery runs out at the rate it really gives (`h / v`). The step then comes out exact, so one cut is enough.
- **Cost:**
| Case | Floor cuts per build, before | After | Build time, before (host / `--cpus=0.33`) | After |
|---|---|---|---|---|
| 7 kW house | 2,800 | 21 | 463 / 1,500 ms | 32 / 109 ms |
| 2 x 3 kW | 2,000 | 10 | 335 / 1,072 ms | 22 / 79 ms |
Ordinary cases are unchanged. `MAX_FLOOR_CUTS` is never reached.
- **Walk:** `own_walk()` gains both of your cases, so it now has 13 cases with 0 mismatched quarters. In the 7 kW case B's block is 17:00-19:32, and control's last giving quarter is 19:30-19:45. Per build the test also checks: at most 40 floor cuts, the bound never reached, at most 40 level points per battery and day, and under 0.2 s. Observed maxima are 14 cuts and 28 points.
- **Finding 2:** I added unit checks against hand-worked times, to the second: 14:15, 28:30, 30:00 (second group) and 3:57:30 (split by what each holds). There is also a check that a battery at the floor sets no cut, and that `curve(stats=)` reports when the bound stops a cut.
- **Mutations:** all caught, after one bound change. Caught on the first run: the old condition, the cut 30 % late, the split by caps, no cut for the second group, and a bound hit that is not reported.
- A bound of 3 survived. It is equivalent, because no tested curve needs more than 3 cuts.
- A bound of 1 is caught.
- **Other checks:** the replay digests equal base `f5afb42`. The suite passes 86 of 86. `test_ux54_dst.py` passes in both images. CI run 1415 is green.
Review 4 of a0ace38: approved. I am merging after this comment.
Re-review 3's findings 1 and 2 are fixed, and both fixes are proven.
Check 1: the floor cut (finding 1). I ran the same script (e8r4.py) on a4b769f and on a0ace38. Times are host / --cpus=0.33.
Case
Floor cuts before → after
Tab ms before → after
JSON
Level points per battery
30/80 guard, 7 kW house
2,800 → 21
468 / 1,549 → 37 / 109
22.0 → 9.4 kB
425 → 28
30/80 guard, 2 x 3 kW, 3.5 kW house
2,000 → 10
325 / 1,102 → 23 / 79
22.0 → 9.3 kB
425 → 27
30/80 no guard, 2 x 3 kW
400 → 2
88 / 299 → 30 / 99
9.8 kB
28
owner 11:20
3 → 3
16 / 51 → 19 / 52
9.3 kB
26
dynamic 22:00 20/20
0 → 0
47 / 160 → 51 / 159
9.8 kB
28
capped is never set. In a fuzz of 300 random sites (ratings 1.5-5 kW, house 0.8-9 kW, guard on and off, mixed modes, sun 0-20 kWh), no curve made more than 12 cuts. capped stayed False, every level stayed inside floor and ceiling, there were at most 37 points per battery, and the slowest build took 153 ms.
stats is passed only by tests; dayview.py:181 does not pass it.
My walk (e5r3.py, 8 cases): 0 mismatched quarters. In the 7 kW case B's block now ends 19:32, inside control's last giving quarter (19:30-19:45).
Check 2: the split by held energy. This matches control.
Where control splits:Controller.pack_weights() (main.py:2199) takes packshare.weights() (packshare.py:366: usable kWh above the cut-out) and then modetrade.weights() (modetrade.py:281: batteries in a give run carry it first, the others weigh 0). allocator._fill() (allocator.py:441) water-fills by those weights, each capped, and passes the rest on.
My new walk (e5r4.py) calls that code itself each quarter (c.pack_weights() + allocator._fill()). Across 13 cases the give quarters match exactly. That includes 4 new cases: unequal ratings 1.5/6 kW and 6/2 kW, A Solar first + B Leftover solar, and A Leftover solar + B Solar first.
The tab has no effect on Home, the plan or control: nothing in app/ but dayview imports curve. The replay digests and Home output are unchanged (below).
Check 3: the _floor_cut() unit checks (finding 2). I reworked every value by hand (0.475 kWh / 2 kW = 855 s, and likewise 1,710 s, 1,800 s and 14,250 s), and each matches. My surviving mutation M1 is now caught, even at 10 % late.
Check 4: mutations. All 7 are caught: G2, G3b, G4 and G5, and mine: M1 (cut 10 % late), M5 (step split equal) and M6 (cut ignores ratings).
Check 5: the rest.
Replay digests with the tab built every 6 ticks equal base f5afb42:
p3_ls: 3de48189b0dc
p3_both_ch: ff6e86ac61e4
arb_only: 3b372696ab88, with and without pv (modes off)
Home: 1,080 builds, the same hash on base and head (5757713d1d52…).
DST:test_ux54_dst.py passes in goodwe-checks-tz:1 in both zones. My DST walk built the tab 5,760 times with 0 wrong.
Browser: the two cases in EN and NL, desktop and 360 px: 36 checks, 0 failed, no page errors. The page shows B "17:00 to 19:32".
Suite: the offline suite in goodwe-addon-checks:1 (--network none, sim mounted, GOODWE_REQUIRE_HWSIM=1) passed 86 of 86 on a0ace38.
CI: run 1415 and run 1416 are green on a0ace38. Both are real runs, merged into release/1.0, with 86 PASS.
Info, not blocking
The rest group. When the batteries in a give run are at their rating, control's last-resort pass shares what is left equally among the others. The curve shares it by held energy. The two differ only when two or more batteries are outside the run, so on 3+ battery sites. DECISIONS ("shared as control shares it") holds for 1-2 batteries.
The cut-out. Control weighs by energy above each pack's cut-out, and the curve by energy above the floor. The two are the same when the cut-out is at the floor (the cut-out display is UX-59).
A late charge, not new in round 6. A 2 kW Cheap hours battery charges from 22:30 tomorrow for the day after, and the tab shows no block for it. The note "plans about a day ahead" covers this. It is the same on a4b769f.
**Review 4 of `a0ace38`: approved. I am merging after this comment.**
Re-review 3's findings 1 and 2 are fixed, and both fixes are proven.
**Check 1: the floor cut (finding 1).** I ran the same script (`e8r4.py`) on `a4b769f` and on `a0ace38`. Times are host / `--cpus=0.33`.
| Case | Floor cuts before → after | Tab ms before → after | JSON | Level points per battery |
|---|---|---|---|---|
| 30/80 guard, 7 kW house | 2,800 → 21 | 468 / 1,549 → 37 / 109 | 22.0 → 9.4 kB | 425 → 28 |
| 30/80 guard, 2 x 3 kW, 3.5 kW house | 2,000 → 10 | 325 / 1,102 → 23 / 79 | 22.0 → 9.3 kB | 425 → 27 |
| 30/80 no guard, 2 x 3 kW | 400 → 2 | 88 / 299 → 30 / 99 | 9.8 kB | 28 |
| owner 11:20 | 3 → 3 | 16 / 51 → 19 / 52 | 9.3 kB | 26 |
| dynamic 22:00 20/20 | 0 → 0 | 47 / 160 → 51 / 159 | 9.8 kB | 28 |
- `capped` is never set. In a fuzz of 300 random sites (ratings 1.5-5 kW, house 0.8-9 kW, guard on and off, mixed modes, sun 0-20 kWh), no curve made more than 12 cuts. `capped` stayed False, every level stayed inside floor and ceiling, there were at most 37 points per battery, and the slowest build took 153 ms.
- `stats` is passed only by tests; `dayview.py:181` does not pass it.
- My walk (`e5r3.py`, 8 cases): 0 mismatched quarters. In the 7 kW case B's block now ends 19:32, inside control's last giving quarter (19:30-19:45).
**Check 2: the split by held energy.** This matches control.
- **Where control splits:** `Controller.pack_weights()` (`main.py:2199`) takes `packshare.weights()` (`packshare.py:366`: usable kWh above the cut-out) and then `modetrade.weights()` (`modetrade.py:281`: batteries in a give run carry it first, the others weigh 0). `allocator._fill()` (`allocator.py:441`) water-fills by those weights, each capped, and passes the rest on.
- **My new walk (`e5r4.py`) calls that code itself** each quarter (`c.pack_weights()` + `allocator._fill()`). Across 13 cases the give quarters match exactly. That includes 4 new cases: unequal ratings 1.5/6 kW and 6/2 kW, A Solar first + B Leftover solar, and A Leftover solar + B Solar first.
- **The tab has no effect on Home, the plan or control:** nothing in `app/` but `dayview` imports `curve`. The replay digests and Home output are unchanged (below).
**Check 3: the `_floor_cut()` unit checks (finding 2).** I reworked every value by hand (0.475 kWh / 2 kW = 855 s, and likewise 1,710 s, 1,800 s and 14,250 s), and each matches. My surviving mutation M1 is now caught, even at 10 % late.
**Check 4: mutations.** All 7 are caught: G2, G3b, G4 and G5, and mine: M1 (cut 10 % late), M5 (step split equal) and M6 (cut ignores ratings).
**Check 5: the rest.**
- **Replay digests** with the tab built every 6 ticks equal base `f5afb42`:
- p3_ls: `3de48189b0dc`
- p3_both_ch: `ff6e86ac61e4`
- arb_only: `3b372696ab88`, with and without pv (modes off)
- **Home:** 1,080 builds, the same hash on base and head (`5757713d1d52…`).
- **DST:** `test_ux54_dst.py` passes in `goodwe-checks-tz:1` in both zones. My DST walk built the tab 5,760 times with 0 wrong.
- **Browser:** the two cases in EN and NL, desktop and 360 px: 36 checks, 0 failed, no page errors. The page shows B "17:00 to 19:32".
- **Suite:** the offline suite in `goodwe-addon-checks:1` (`--network none`, sim mounted, `GOODWE_REQUIRE_HWSIM=1`) passed 86 of 86 on `a0ace38`.
- **CI:** run 1415 and run 1416 are green on `a0ace38`. Both are real runs, merged into release/1.0, with 86 PASS.
**Info, not blocking**
1. **The rest group.** When the batteries in a give run are at their rating, control's last-resort pass shares what is left equally among the others. The curve shares it by held energy. The two differ only when two or more batteries are outside the run, so on 3+ battery sites. DECISIONS ("shared as control shares it") holds for 1-2 batteries.
2. **The cut-out.** Control weighs by energy above each pack's cut-out, and the curve by energy above the floor. The two are the same when the cut-out is at the floor (the cut-out display is UX-59).
3. **A late charge, not new in round 6.** A 2 kW Cheap hours battery charges from 22:30 tomorrow for the day after, and the tab shows no block for it. The note "plans about a day ahead" covers this. It is the same on `a4b769f`.
Evidence: `scratch/UX-54/review4/`. Report: `reports/UX-54-review4-report.md`.
admin
merged commit 4a1f6cc2d9 into release/1.02026-10-27 21:07:00 +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.
UX-54: a Forecast tab with the sun, the house and each battery's level, per hour
A new tab beside Home and Batteries. For today and tomorrow, per hour: the solar forecast, the solar measured so far, the house's expected use and each battery's expected level, with the plan's charge and give blocks (and the plan's words) under it. Owner go 2026-10-26: "Day curve", "New tab". Card: UX-54.
How it follows the add-on
/status' planruns(grid, give, monthly cycle); tomorrow's come from the same builders (homeview.runs_for()). A block is on the tab exactly where Home has one, UX-53's gate included.solarfcreads. A daily total (Forecast.Solar; the owner's three "today remaining" and three "tomorrow" sensors) is spread over the 06:00-20:00 bell and the tab says so. An hourly split (Solcast, Open-Meteo) is used as given.HouseProfile.estimator(), never below the night median, as the plan reads it.daycurve.curve()is one pure function: each battery gets the rolemodetrade.decide()gives it, spare solar goes to Solar first batteries first, a shortfall is met by those that may give, atdayplan.EFF, inside the plan's floor and ceiling. It counts onsolar_trust_pctand says so.GET forecast, only while the tab shows, every 30 s). No Home Assistant call. The one addition in the control path is an in-memory add after the solar read the cycle already takes, in atry.Found, not changed (no control change allowed)
dayplan.plan()callsForecast.between(s, e)per hour; for a state-only forecastbetween()readssas the moment "left today" was taken, so each later hour is spread over what is left of the bell. On the owner's shape 14.4 kWh left gives 57 kWh over the hours to midnight. The plan's sun is over-counted for state-only forecasts (it does not show on an all-Solar-first site, which gets no planned level). The tab measures both ends from now (dayview.sun_kwh()), which keeps the total. A card should fix the plan.Files
app/daycurve.py(new, pure),app/dayview.py(new),app/homeview.py(runs_for,day_note,in_lang),app/main.py(forecast_payload,SolarToday),app/web.py(GET /forecast),app/static/{index.html,app.js,app.css},app/ui_text/{en,nl}.json(30 strings),DECISIONS/UX-54-forecast-tab.md,DOCS.md(a short section),tools/ux54_scenarios.py,test_ux54.py;test_home.pyandtest_selfconfig.pycount one more tab and one more GET.Evidence
Details in
reports/UX-54-dev-report.md(kept by the manager).test_ux54.py: 69 checks (shape per integration, blocks equal Home's on 13 scenarios incl. modes on Solar first / Leftover solar / Cheap hours / modes off, the gate, curve invariants incl. a 300-case fuzz, every note, SolarToday, words EN/NL, route, no HA call and nothing moved).test_messages.py,test_ui_text.py,test_docs.py,test_home.py,test_selfconfig.pypass; the full suite ingoodwe-addon-checks:1(--network none, sim mounted,GOODWE_REQUIRE_HWSIM=1): 83 of 84 files on the first local pass (the one failure was an unguardednext()in my own new test, fixed before the commit);checks / unit (pull_request)green on this head (run 1400).opt11p3,opt11p4,safety79,safety84) byte-identical on base and head.scratch/UX-54/dev/shots/.Not done: no CHANGELOG, no version change. Rig test on TEST-23 is the next step of the flow.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
UX-54 review (Opus), head
b6b2d1b: changes requested, not mergedControl is untouched and the tab is light. One display defect breaks the card's acceptance rule ("a block on the tab exists exactly where the plan has one"), so I did not merge. The fix changes behaviour, so I have not pushed it.
What holds (evidence in
scratch/UX-54/review/out/)p3_ls3de48189b0dc,p3_both_chff6e86ac61e4,p3_ch_sellaa76f7c3a153,arb_only(modes off) 3b372696ab88). I ran each on base with and without a solar entity, and on head with the tab built every 30 s of replay time (5 760 builds per run, all ok), with and without a solar entity. That is 16 runs, and the digest is the same within each variant. On 7 scenarios, every attribute on the Controller is the same before and after 5 tab builds in EN and NL. The one exception ishome_prices, where only the objects are new and the values are equal. Building the tab makes no HA calls, and the language goes back to EN afterwards.SolarTodayholds at most 24 to 25 rows and resets each local day. Bad, NaN, inf, None and negative readings and a backward clock step do not raise. One reading costs 12 to 37 us.modetrade.decide()(HOUSE, HOLD, GIVE, REST, CHARGE). Levels stay inside the floor and ceiling.charge_to()is used as the plan uses it./forecast. The tab polls every 30 s while shown and stops while the page is hidden or on Settings.goodwe-addon-checks:1(no network, sim mounted,GOODWE_REQUIRE_HWSIM=1) on the head: 84 of 84test_*.pypass,test_freezeincluded. CI runs 1400 and 1401 are green onb6b2d1b; run 1400 is a real 48-min run with 85 PASS.Findings
app/homeview.py:616(runs_for) →mode_runs()room():414/:440, andtrade_runs():347,:356. Tomorrow's blocks are sized from the level now. In the evening after a give run, a Leftover solar or Cheap hours battery is at the floor, so tomorrow's give run is drawn on neither battery. Tomorrow's grid block is still drawn, and the curve rises in it and then falls 17:00 to 21:00 with no block (out/e1.out,shots/dyn22-en-desk-tomorrow.png). At 18:00 tomorrow, with those levels,_mode_roles()gives both batteries GIVE and Home draws both give blocks. With modes off it is the same: with a charge leg today at 23:00 and a give leg tomorrow at 07:00, Home's own line says "Tomorrow: gives power at 07:00", but the tab's tomorrow has no block when the battery is at the floor now. It does have the block at 60 % (out/e2.out). A fix could size a block that has not started from the curve's level at its start, or from today's legs. Today has the same root (the dev's noted limit, inherited from Home). There, the new lineday.no_blocks"No charge or give blocks planned." (app/dayview.py:250) is shown for a battery whose give run is in the plan (shots/sunny10-*,out/e1.outE1b), so that sentence is false.test_ux54.py:196compares tomorrow's tab blocks withruns_for()itself, which proves nothing. My mutation R5 makes tomorrow's blocks ignore the UX-53 gate, and it survives. Please add a check of tomorrow's blocks against what control does at that time (as ine1.py/e2.py).app/daycurve.py:117countstrust x sun - houseand stores the spare at EFF.dayplan.plan()(app/dayplan.py:195-202) countstrust x EFF x max(0, sun - house). For 10 kWh of sun and 2 kWh of house at 70 %, the curve stores 4.75 kWh and the plan 5.32 kWh. That contradicts the DECISIONS line "as the plan does". Align it, or say in DECISIONS that it differs.dayplan._hour_slices()gives 24 slices for 25 h (the 02:00 slice is 2 h). The tab then draws 24 equal columns on a 25 h axis. The totals are right and it only affects the night hour.Over-count, reproduced (not changed here; it needs its own card)
Forecast.between(s, e)passessas "now" to_day_between()(app/solarfc.py:95).dayplan.plan()calls it once per hour slice (app/dayplan.py:195), so for a state-only forecast each hour is spread over what is left of the bell from that hour. On the owner's shape at 10:07, 14.45 kWh left becomes 57.50 kWh by the plan's per-hour sum. At 07:00, 17.77 becomes 74.26. At 13:00, 9.00 becomes 31.87. The tab's sum equals the total, and an hourly split (periods) is not affected. This is not today only: the evening-before plan that sizes the night grid charge covers tomorrow's hours too.(Shipped vs each span measured from now,
out/e3c.out.) On any site with Leftover solar and Forecast.Solar or another state-only sensor, the plan under-charges from the grid. Proposed card:Forecast.between()over-counts a state-only forecast indayplan.plan().Text (UX-08), proposed and not pushed, because the PR goes back anyway
home_words.day.cyclehome_words.day.no_tomorrowBELL_FROM_H, while sunrise in winter is near 08:45)home_words.day.aheadhome_words.day.no_levelhome.forecast.measuredhome_words.day.no_blocksThe other strings are correct, natural Dutch, short, and mean the same in both languages. The "not known yet" notes cover modes off, no forecast set, a missing or stale forecast, learning, no tomorrow, stopped and shadow.
Verdict: changes requested. Finding 1 must be fixed and 2 is needed; 3 and the text are small. Not merged.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Dev round 4, head
4256bd3(verifier findings on round 3)_levels()andsettle()never ran there, in round 2 or round 3 (missing = "modes_off"). Tomorrow's legs were drawn from the bound.homeview.trade_runs(ahead=f)now reads each leg atf(name, kind, start), the same asmode_runs(): no give while the emptiest battery is at the floor, and each share is sized from that level. With modes off,fis Home's own count (dayview._counted()): the level now, plus earlier charges, less earlier gives. A charge is read from the floor, because a full battery covers the house before the cheap hour and control then charges it (case C).packshare.envelope()), with the cut-out at the floor. The peak-guard 20 % case asserts the curve is at the floor at 17:00. The new modes-off walk (arbitrage.decide()with the cycle's inputs, a cloudy day, the house drawing power) has cases A-D, including the cloudy-day give at the floor. In every case control charges and gives in exactly the quarters the tab has a block.Opus re-review of head
4256bd3: changes requested. Not merged.Rounds 2 to 4 fixed everything from my round 1 and from the router rounds 2 and 3. Each fix has a test that fails when the fix is taken out. Round 4 is right about one point: with modes off,
_levels()was never reached in rounds 2 and 3. The replay digests match base, Home matches base in 1,080 builds, the suite passes 84 of 84, and the browser run had 248 checks with 0 failures. I am still not merging, because of one blocker:app/dayplan.py:159-169_hour_slices()never returns on the spring DST day. At 00:00 UTC on 28 March 2027 (01:00 CET), the next local hour is 02:00, which the billing time zone reads as CEST, and that is 00:00 UTC again. Sotnever moves and the list grows until memory runs out. The tab calls this function for today and tomorrow on every site in every mode (dayview.py:324,:177,daycurve.py:102). So on 27 and 28 March, everyGET /forecastblocks the add-on's only event loop, and control runs on that loop too. In 27 minutes one process reached 10 GB. In a fuzz of 60 builds, 9 hung, all with today or tomorrow on 28 March. The bug in_hour_slices()is old, but this PR gives it a new trigger: opening a page. Fix_hour_slices()first (it is control code, see 2), then add a test that builds the tab on both DST days.dayplan.plan()calls_hour_slices(now, until.start)atdayplan.py:194. On basea1d0703,Controller._mode_roles()hangs at 22:00 on 27 March 2027 for a modes-on site with a forecast (Leftover solar and Cheap hours). The function is the same in v0.19.0. That is the date it must be fixed by.daycurve.py:155-178)._stops()(dayview.py:297-300) then ends the give block at the next hour edge. I walked control with its own model of the batteries, not the curve's levels (no sun, cut-out at the floor). At 25/45 % with the peak guard on, the tab shows 17:00-21:00 but control gives only 17:00-20:15. With 6 kWh of sun the tab shows 17:00-18:00 and control gives 17:00-17:15. The dev's walk feeds control the curve's own hourly levels, so it cannot see this. Fix: cut the step at the point where the battery reaches the floor, asdaycurve.py:134-147does for a grid charge, and test against levels the curve did not make.give_latertells a battery at 60 % "No block shows while the battery has no energy to give." The real reason is that the other battery is at the floor (case D). Proposed: EN "...No block shows while a battery has no energy to give." / NL "...Er verschijnt geen blok zolang een batterij geen stroom heeft om te leveren."Cost: a tab build now takes 17-57 ms on this host, against 7-9 ms in round 1. With the CPU throttled to 1/3 it takes 51-166 ms. Each build is at most 101 curve passes, which works out to about 0.4 s here and about 1.5 s at 1/3 CPU. The build runs on the event loop.
Evidence is in
scratch/UX-54/review2/:e5*.py(the independent walk),e11.pyande12.py(DST),e9.py(fuzz),e8.py(cost) andmut.out(22 mutations: 20 caught, 2 equivalent).f5afb42, SAFETY-123) into UX-54 00c8ae354cDev round 5, head
a4b769f(re-review findings on4256bd3, on top of SAFETY-123)f5afb42(SAFETY-123) intoUX-54as merge commit00c8ae3. There were no conflicts.test_ux54_dst.pybuilds the tab withforecast_payload()at every quarter of 27-29 March and 30 October-1 November 2027, with modes on and with modes off. Each zone gets 576 builds per mode. It runs in the no-tzdata zone, and also in ZoneInfo Europe/Brussels where the host has tzdata. Every call runs under a 10 s limit raised as aBaseException, whichforecast_payload()'sexcept Exceptioncannot swallow; a planted endless build checks this. The test also checks the 23, 24 and 25 hours and their labels. The tab's own hour loop had one more wall-clock step:SolarTodaykeyed an hour by its local wall clock. It now uses the start of the local hour taken back from the UTC instant, so the two autumn 02:00 hours stay apart.daycurve.curve()now puts a point where a giving battery reaches the floor (_floor_cut()), the same way it already does for a grid charge. A new walk lets control move the packs with its own model, not the curve's levels. In all 11 cases it matches the tab quarter by quarter. At 25/45 % with the peak guard, control gives 17:00-20:15 and the block runs 17:00-20:06. At 20/20 % with the guard and 6 kWh of sun, control gives 17:00-17:15 and the block runs 17:00-17:07.test_messages.pyenforces, so I shortened it.f5afb42. The browser run had 144 checks and 0 failures. The suite passed 86 of 86. CI run 1413 is green. Run 1411 on8dbf831failed intest_bytecode.py, because the new test file lacked the MUTATION.md pointer; that is fixed ina4b769f.Re-review 3 of
a4b769f: changes requested. Not merged.The round-5 fixes for findings 1, 4 and 5 are right, and finding 3 is right for the 11 test cases. The new floor cut has one defect that the tests do not reach (finding 1 below). Fixing it changes behaviour, so I pushed nothing.
Findings
goodwe_controller/app/daycurve.py:114-115, in_floor_cut().any(c < rating*hrs ...)also counts a battery that is already at the floor, because its cap is 0. In a give run, once one battery is empty and the house needs more than the other battery's rating, the step is cut again and again, about 1 s at a time, untilMAX_FLOOR_CUTS(400) runs out. After that, no floor point is added anywhere in that curve.dynamic, 22:00, 30/80 % with the guard and a 7 kW evening house. A second example uses 2 x 3 kW packs with a 3.5 kW house, which is a normal size for a client site.--cpus=0.33, against 30-50 ms before. It runs on the event loop control uses, every 30 s while the tab is open.EPS < c < by[n].rating_kw * hrs * (1.0 - 1e-9). I tried it in a scratch copy: 23 cuts per curve, 52 ms, 10.0 kB, 0 mismatched quarters in all 8 cases of my walk, and B ends at 19:32, inside control's last quarter.test_ux54.pyandtest_ux54_dst.pypass with the change. Optional: when the others are held to their rating, cut where the battery runs out at the rate it really gives. That puts the cut at the right point at once, instead of after about 20 cuts that get closer each time.own_walk(), and check that the number of level points stays small._floor_cut()against a time worked out by hand would catch M1. M2 changes only the level points, not a block, so it is near-equivalent.What is right
Merge
00c8ae3: its tree equalsgit merge-tree 4256bd3 f5afb42(f6cddf2d), so it brings in release/1.0 and nothing else. Againstf5afb42, the PR changesmain.py,web.pyandhomeview.pyexactly as round 4 did (same diff hash). Round 5 changes onlydaycurve.py, the two text files, DECISIONS and tests.DST:
test_ux54_dst.pypasses ingoodwe-addon-checks:1(no tzdata) and ingoodwe-checks-tz:1(both zones). My own walk covered owner, dynamic, periods, sunny and off, every quarter of 27-29 March and 30 October-1 November 2027, three level pairs, both zones, under afaulthandlerhard exit. That is 8,640 builds: 0 wrong, the days have 23, 24 and 25 hours, and the slowest build took 0.44 s. Nothing on the path catchesBaseException. The_hour_start()change is right: it subtracts the local minutes from the UTC instant, so it never steps the wall clock.Give block at the floor: my walk lets control move the packs, with a water fill like the allocator's. These cases gave 0 mismatched quarters: 25/45 with the guard (block 17:00-20:06, control 17:00-20:15), 20/20 with the guard and 6 kWh (17:00-17:07, control 17:00-17:15), and 14 kWh and 30/30 with the guard. Three of my four new cases also gave 0: a 3 kW house at 25/45, 40/25 with sun and no guard, and 35/35 with a 2.5 kW house and sun. The fourth new case is finding 1. Grid blocks match exactly.
Home and control:
f5afb42p3_lspv3de48189b0dc3de48189b0dcp3_both_chpvff6e86ac61e4ff6e86ac61e4arb_only(modes off)3b372696ab883b372696ab88, also with pvEvery head replay had 5,760 tab builds, all
ok. Home's output is identical across 1,080 builds (5757713d1d52…, base = head).Text:
give_lateris fine in EN and NL. The NL sentence is natural, has 12 words and passestest_messages.py.Browser: owner, dynamic, off, 25/45 with the guard (20:06 is shown), off case D (the
give_laterline), and the 7 kW case. Each was checked in EN and NL, desktop and 360 px: 124 checks, 0 failed, no page or console errors. Home and Batteries match base.Mutations: the dev's F1-F6 are all caught. Of mine, M3 (hour key = instant) and M4 (
MIN_CUT20 min) are caught; M1 and M2 survive (finding 2).CI: run 1413
checks / unitand run 1414infra-pin / freshare green ona4b769f. Both are real runs: 86 PASS, merged into release/1.0.Suite: the offline suite in
goodwe-addon-checks:1(--network none, sim mounted,GOODWE_REQUIRE_HWSIM=1) passed 86 of 86 ona4b769f.Evidence:
scratch/UX-54/review3/. Report:reports/UX-54-review3-report.md.Dev round 6, head
a0ace38(re-review 3)packshare.weights()): by what each battery holds above the floor, each battery at most its rating, and any rest passed on. The module docstring already said this; the code used to split by step caps instead._floor_cut()places each cut where the battery runs out at the rate it really gives (h / v). The step then comes out exact, so one cut is enough.--cpus=0.33)Ordinary cases are unchanged.
MAX_FLOOR_CUTSis never reached.own_walk()gains both of your cases, so it now has 13 cases with 0 mismatched quarters. In the 7 kW case B's block is 17:00-19:32, and control's last giving quarter is 19:30-19:45. Per build the test also checks: at most 40 floor cuts, the bound never reached, at most 40 level points per battery and day, and under 0.2 s. Observed maxima are 14 cuts and 28 points.curve(stats=)reports when the bound stops a cut.f5afb42. The suite passes 86 of 86.test_ux54_dst.pypasses in both images. CI run 1415 is green.Review 4 of
a0ace38: approved. I am merging after this comment.Re-review 3's findings 1 and 2 are fixed, and both fixes are proven.
Check 1: the floor cut (finding 1). I ran the same script (
e8r4.py) ona4b769fand ona0ace38. Times are host /--cpus=0.33.cappedis never set. In a fuzz of 300 random sites (ratings 1.5-5 kW, house 0.8-9 kW, guard on and off, mixed modes, sun 0-20 kWh), no curve made more than 12 cuts.cappedstayed False, every level stayed inside floor and ceiling, there were at most 37 points per battery, and the slowest build took 153 ms.statsis passed only by tests;dayview.py:181does not pass it.e5r3.py, 8 cases): 0 mismatched quarters. In the 7 kW case B's block now ends 19:32, inside control's last giving quarter (19:30-19:45).Check 2: the split by held energy. This matches control.
Controller.pack_weights()(main.py:2199) takespackshare.weights()(packshare.py:366: usable kWh above the cut-out) and thenmodetrade.weights()(modetrade.py:281: batteries in a give run carry it first, the others weigh 0).allocator._fill()(allocator.py:441) water-fills by those weights, each capped, and passes the rest on.e5r4.py) calls that code itself each quarter (c.pack_weights()+allocator._fill()). Across 13 cases the give quarters match exactly. That includes 4 new cases: unequal ratings 1.5/6 kW and 6/2 kW, A Solar first + B Leftover solar, and A Leftover solar + B Solar first.app/butdayviewimportscurve. The replay digests and Home output are unchanged (below).Check 3: the
_floor_cut()unit checks (finding 2). I reworked every value by hand (0.475 kWh / 2 kW = 855 s, and likewise 1,710 s, 1,800 s and 14,250 s), and each matches. My surviving mutation M1 is now caught, even at 10 % late.Check 4: mutations. All 7 are caught: G2, G3b, G4 and G5, and mine: M1 (cut 10 % late), M5 (step split equal) and M6 (cut ignores ratings).
Check 5: the rest.
f5afb42:3de48189b0dcff6e86ac61e43b372696ab88, with and without pv (modes off)5757713d1d52…).test_ux54_dst.pypasses ingoodwe-checks-tz:1in both zones. My DST walk built the tab 5,760 times with 0 wrong.goodwe-addon-checks:1(--network none, sim mounted,GOODWE_REQUIRE_HWSIM=1) passed 86 of 86 ona0ace38.a0ace38. Both are real runs, merged into release/1.0, with 86 PASS.Info, not blocking
a4b769f.Evidence:
scratch/UX-54/review4/. Report:reports/UX-54-review4-report.md.