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
147 lines
9.3 KiB
Markdown
147 lines
9.3 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, 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 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.
|