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
123 lines
7.5 KiB
Markdown
123 lines
7.5 KiB
Markdown
# UX-54 - a Forecast tab: the sun, the house and each battery's level, per hour
|
|
|
|
**Status: DONE 2026-10-26 (owner go 2026-10-26: "Day curve", "New tab").**
|
|
|
|
## Decision
|
|
|
|
A tab beside Home and Batteries shows 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 its blocks. It follows the add-on, as Home does
|
|
(UX-53), and decides nothing.
|
|
|
|
- **Blocks are Home's.** Today's are the `runs` of the same plan Home draws
|
|
(grid, give, monthly cycle). Tomorrow's come from the same builders
|
|
(`homeview.runs_for()`). The words are the plan lines'. A block is on the tab
|
|
exactly where Home has one, so the UX-53 gate is followed too.
|
|
- **Tomorrow is read ahead.** Home sizes a block from the battery's level now
|
|
and draws none when there is no room or energy (`room()` and `plan_need()`
|
|
in `homeview.mode_runs()`, the leg sizing in `trade_runs()`). For a day that
|
|
has not started that level says nothing, so tomorrow's blocks are built with
|
|
`ahead` (Home never passes it): `mode_runs()` reads each block at the level
|
|
the curve has when it starts, with its own filter and `MIN_KWH`, so there is
|
|
no give block where the battery is at the floor and no grid block where it
|
|
is at its planned level. Where the curve has no level yet, a charge is read
|
|
from the floor and a give from the ceiling. The blocks and the curve follow
|
|
each other and are taken to a fixed point. The gate (`mode_gate`, UX-53) is
|
|
not bypassed.
|
|
- **Modes off has no curve** (no forecast is read, no house profile kept), so
|
|
`trade_runs(ahead=f)` reads each leg at Home's own count
|
|
(`dayview._counted()`): the level now, plus what the blocks before it put in,
|
|
less what they take out. No sun is read and the house only takes, so no give
|
|
block where the emptiest battery is at the floor by that count, and control
|
|
does not give there. A charge is read from the floor: a full battery covers
|
|
the house before the cheap hour, and control charges it back then. Walked
|
|
every 15 minutes on a cloudy day with the house (`test_ux54.py` part 7,
|
|
cases A-C), control charges and gives in exactly the quarters the tab has a
|
|
block.
|
|
- **A block lasts as long as control acts.** A cheap run is sized for all the
|
|
batteries charging together, so a slower one is still below
|
|
`modetrade.charge_to()` when it ends, and control plans the next cheap slots
|
|
(`Controller._price_runs()`). `dayview._levels()` asks the same functions,
|
|
`modetrade.energy()` and `priceruns.plan()`, with the packs at the curve's
|
|
level, and draws the grid block to where control stops. Each battery's block
|
|
then ends where the curve has it at its own level (the curve puts a point
|
|
there), and a give block where it reaches the floor (a step end, within the
|
|
0.1 % the tab shows). Control, walked every 15 minutes through tomorrow
|
|
(`test_ux54.py` part 7), charges and gives in exactly the quarters the tab
|
|
has a block. A GIVE role counts only where control's own discharge envelope
|
|
(`packshare.envelope()`) is open; the walk puts each pack's cut-out at the
|
|
floor.
|
|
- **The sun** is what `solarfc` reads. An hourly split (Solcast
|
|
`detailedForecast`, Open-Meteo `wh_period`) is used as given. A daily total
|
|
(Forecast.Solar, and any other state-only sensor, the owner's three "today
|
|
remaining" and three "tomorrow") is spread over the 06:00-20:00 bell, and the
|
|
tab says so.
|
|
- **The house** is `HouseProfile.estimator()`, never below the night median, as
|
|
the plan reads it.
|
|
- **The level did not exist** (the plan holds targets and run blocks only), so
|
|
`daycurve.curve()` derives it: each battery has the role `modetrade.decide()`
|
|
gives (follow the house, hold, give, charge from the grid, rest), spare solar
|
|
goes to Solar first batteries first, a shortfall is met by those that may
|
|
give, both at `dayplan.EFF`, inside the plan's floor and ceiling, from the
|
|
level now. Spare solar is `trust x max(0, sun - house)` and what is stored
|
|
loses `EFF`, the plan's own `trust x EFF x max(0, sun - house)` (10 kWh of
|
|
sun, 2 kWh of house, 70 %: 5.32 kWh stored). A shortfall is not scaled by
|
|
trust. The tab says it counts on `solar_trust_pct`. Grid charging goes to
|
|
`modetrade.charge_to()`. It is an estimate.
|
|
- **Measured solar** is kept per local hour in memory (`SolarToday`), from the
|
|
reading the control cycle already takes; nothing is read back.
|
|
- **On request.** `GET forecast` builds the tab (`Controller.forecast_payload()`
|
|
reads `/status`' plan first, so today's blocks are the very ones Home has). No
|
|
Home Assistant call, nothing cached, nothing in the cycle but one in-memory
|
|
add. The page asks every 30 s while the tab shows.
|
|
- **Not known yet is a note, not an empty chart:** modes off, no forecast set,
|
|
forecast missing or old, learning, no tomorrow forecast, a monthly cycle, no
|
|
level reading, control stopped, shadow.
|
|
|
|
## What does not change
|
|
|
|
No decision, command, timing or option key. The one write in the control path
|
|
is `SolarToday.add()` after the solar read, in a `try`, in memory.
|
|
|
|
## Limits
|
|
|
|
- Prices are planned about a day ahead (`priceruns.HORIZON`), so a block of
|
|
tomorrow later than 24 h from now, or one that needs prices not yet
|
|
published, appears as the day comes near. The tab says so.
|
|
- Today's blocks are Home's, and are not extended or shortened: control may
|
|
plan more once they end, and Home draws it then. The curve stops charging
|
|
at its level (`charge_to()`) and giving at the floor.
|
|
- Today, a give run in the plan on a battery that is empty now has no block on
|
|
Home (no energy to give). The tab equals Home, and says so: "The add-on plans
|
|
to give power. No block shows while the battery has no energy to give." The
|
|
curve gives in that run if the sun fills the battery first.
|
|
- With no forecast for tomorrow the curve stops at the first hour of the sun,
|
|
`solarfc.BELL_FROM_H` (06:00): `Forecast.between()` has no answer past it. The
|
|
note names that hour from the constant.
|
|
- Tomorrow shows price blocks only. A monthly cycle is drawn today, where the
|
|
curve then stops for that battery.
|
|
- The curve uses the arbitrage floor and ceiling, as the plan does, not the
|
|
battery's own cut-out level. Control stops a pack at its cut-out, so with a
|
|
cut-out under the floor a battery in a give run covers the house below the
|
|
floor; Home draws no block there either. The house profile is a median per
|
|
hour, net of the solar the house used.
|
|
- Modes off: the count knows no sun and no house. A battery at the floor that
|
|
the sun fills before a give gets no block until then (the tab says "The
|
|
add-on plans to give power. No block shows while the battery has no energy
|
|
to give."), and a small charge may be used by the house before the give.
|
|
A leg is the verdict's as it stands now, one price period, as on Home; with
|
|
several equal prices in a row control takes the next one as each passes.
|
|
- Measured solar starts when the add-on starts; the tab says from when.
|
|
|
|
## Found, not changed
|
|
|
|
`dayplan.plan()` reads the forecast hour by hour with `Forecast.between(s, e)`.
|
|
For a state-only forecast `between()` takes its start as the moment the "left
|
|
today" figure was read, so each later hour is spread over what is left of the
|
|
bell from that hour: on the owner's shape 14.4 kWh left gives 57 kWh over the
|
|
hours to midnight (`test_ux54.py` part 1). The plan's sun is overcounted for
|
|
those sites. It does not show on a site whose batteries are all Solar first
|
|
(no level is planned for them). The tab measures both ends from now
|
|
(`dayview.sun_kwh()`), which keeps the total and gives the same hours for an
|
|
hourly split. A card should fix the plan.
|