The cheap-run roll (cheaproll._Charge) shared the peak room by rating, control shares it by room left to full (packshare.weights). With unequal levels one battery's drawn block ended too early (rig F-1). The roll now uses packshare.weights over the simulated levels, each battery capped at its rating, a full one dropping out. test_ux77 gains two unequal-level walks and a by-rating mutant; its walks model control's split by room too. Co-Authored-By: Claude <noreply@anthropic.com>
152 lines
9.7 KiB
Markdown
152 lines
9.7 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, the packs moved by control and a 400 W
|
|
house (`test_ux54.py` part 7, cases A-D, one price quarter per leg),
|
|
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, and a give block where
|
|
it reaches the floor: the curve puts a point at both (`daycurve.curve()`
|
|
cuts the step there), and the block ends at the first point where the level
|
|
the tab shows (0.1 %) is there. What is proven: control walked every 15
|
|
minutes through tomorrow with its own model of the packs, not the curve's
|
|
levels (`_mode_roles()` each quarter; the house from the profile, the sun
|
|
at 100 % trust, a GIVE role counted only where control's own discharge
|
|
envelope `packshare.envelope()` is open, the cut-out at the floor), charges
|
|
and gives in exactly the quarters the tab has a block in 13 cases
|
|
(`test_ux54.py` part 7, `own_walk()`), e.g. 25/45 % with the peak guard:
|
|
control gives 17:00-20:15, the block is 17:00-20:06; 30/80 % with a 7 kW
|
|
evening house, B held to its rating: control 17:00-19:45, the block
|
|
17:00-19:32. The floor point is placed in one cut, where the battery runs
|
|
out at the rate it gives (`_floor_cut()`, checked to the second against
|
|
times worked out by hand), so a build stays small: at most 14 floor cuts
|
|
and 28 level points per battery and day in those cases. With a cut-out under
|
|
the floor, or a house or sun that differs from the forecast and profile,
|
|
control differs (Limits).
|
|
- **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, shared as control shares it (by what each holds above the floor,
|
|
`packshare.weights()`, each at most its rating, the rest passed on), 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. UX-77: Home draws today's grid block to where
|
|
control will stop charging, as the tab does for tomorrow's: the cheap run
|
|
rolls on at its end while a battery is short (`cheaproll.roll_end()`, the
|
|
same loop as `dayview._levels()`), the level read at the charge power the
|
|
peak limit leaves (peak aim less the house, else the rating) and shared by
|
|
room left to full as control shares it (`packshare.weights()`, re-read as
|
|
the levels move), and each battery's block ends at its own stop. 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 a 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 a battery has no energy to
|
|
give."), and a small charge may be used by the house before the give. The
|
|
other way round, a charge is drawn even when the sun fills the batteries
|
|
before it: at the ceiling control then takes none (a cheap quarter at 13:00
|
|
on a sunny day: the tab draws the charge, control charges nothing at 95 %
|
|
or 100 %).
|
|
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.
|
|
- Clock changes: the tab walks local hours with `dayplan._hour_slices()`,
|
|
which steps in UTC (SAFETY-123), and keys measured solar by the start of
|
|
the local hour taken back from the UTC instant, so the spring day has 23
|
|
hours, the autumn day 25 (two of them 02:00), and a request always returns
|
|
(`test_ux54_dst.py`, every quarter of both change days, with and without
|
|
tzdata). The solar bell is spread over hours from local midnight, so on
|
|
those two days it is an hour off the clock (SAFETY-123, not changed here).
|
|
|
|
## 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.
|