Compare commits

...
12 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 ae0cd542c0 OPT-23-28 review: the unheld-shortfall line says another battery or the grid
checks / unit (pull_request) Waiting to run
infra-pin / fresh (pull_request) Waiting to run
With A on Leftover solar and B on Cheap hours (the dynamic scenario, and the
owner's planned setup), the line said "The batteries cannot hold it all. The
grid covers N kWh", while B holds a full charge for the same hours. It now
says "The expensive hours need more than Leftover solar can hold. The rest,
N kWh, comes from another battery or the grid." (NL likewise). DOCS,
DECISIONS and the dayplan/homeview docstrings say the same. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-29 23:21:31 +01:00
glenn schrooyenandClaude 995028bc5d OPT-23, OPT-28: the day plan counts the sun right, and the night charge covers the expensive-hour shortfall
checks / unit (pull_request) Successful in 55m9s
infra-pin / fresh (pull_request) Successful in 1s
OPT-23: a state-only forecast is read from now for every span
(Forecast.between since=), so the plan's per-hour sun adds up to the day's
figure, today and tomorrow. Hourly splits are unchanged. The Forecast tab
reads spans the same way.

OPT-28: a Leftover solar battery buys, from the floor, the sum over the
expensive hours of max(0, house - trust x sun), / EFF. Several Leftover
solar batteries hold it together by capacity, the ceiling passes the rest
on, and what none can hold is said (Plan.uncovered_kwh). Home's grid block,
the Batteries line and the mode help (EN + NL) say the level, what it
covers, and that Leftover solar buys only what the sun will not give.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-29 21:02:18 +01:00
admin 4a1f6cc2d9 Merge pull request 'UX-54: a Forecast tab with the sun, the house and each battery's level, per hour' (#210) from UX-54 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-27 21:07:00 +01:00
glenn schrooyenandClaude a0ace389a4 UX-54 round 6: the floor cut in one step, never from an empty battery
checks / unit (pull_request) Successful in 50m21s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-10-27 19:03:56 +01:00
glenn schrooyenandClaude a4b769f257 UX-54 round 5: test_ux54_dst.py points at MUTATION.md (test_bytecode.py)
checks / unit (pull_request) Successful in 50m12s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-27 16:19:07 +01:00
glenn schrooyenandClaude 8dbf831854 UX-54 round 5: give blocks end at the floor point, the tab on the clock-change days
checks / unit (pull_request) Failing after 50m11s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-10-27 15:54:33 +01:00
glenn schrooyenandClaude 00c8ae354c Merge release/1.0 (f5afb42, SAFETY-123) into UX-54
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-27 15:30:52 +01:00
adminandClaude Opus 5.5 f5afb420cf Merge pull request 'SAFETY-123: the day plan's hour loop steps in UTC and always ends' (#211) from SAFETY-123 into release/1.0
infra-pin / fresh (push) Successful in 2s
Reviewed head d2820e8 (Opus review; CI run 1408 checks / unit and 1409 infra-pin green on d2820e8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:00:21 +01:00
glenn schrooyenandClaude 4256bd3eda UX-54 round 4: modes off reads tomorrow at Home's count, no relabel in the walk
checks / unit (pull_request) Successful in 48m51s
infra-pin / fresh (pull_request) Successful in 2s
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
2026-10-27 03:06:12 +01:00
glenn schrooyenandClaude 5ab8622265 UX-54 round 3: tomorrow's blocks last as long as control acts, give blocks checked at run start
checks / unit (pull_request) Successful in 48m22s
infra-pin / fresh (pull_request) Successful in 1s
- 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
2026-10-27 00:53:58 +01:00
glenn schrooyenandClaude 75195f7073 UX-54 round 2: tomorrow's blocks where control will act, trust as the plan counts it, strings
checks / unit (pull_request) Successful in 48m25s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-10-26 22:48:48 +01:00
glenn schrooyenandClaude b6b2d1b184 UX-54: a Forecast tab with the sun, the house and each battery's level, per hour
checks / unit (pull_request) Successful in 48m29s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-10-26 19:38:59 +01:00
22 changed files with 4107 additions and 79 deletions
@@ -0,0 +1,11 @@
# OPT-23 and OPT-28 - the day plan counts the sun right, and the night charge covers the expensive-hour shortfall
**Status: for review.** Tests: `test_opt23_28.py`.
`dayplan.plan()` now buys, for Leftover solar batteries, what the house uses in the expensive hours less the sun counted for each hour, shared by capacity, and reads a state-only forecast from now for every span (OPT-23).
- **Shortfall.** `sum_h max(0, house_h - trust x sun_h)` over every expensive hour, `/ EFF`, from the floor. It replaces the use over the run with no sun counted, which on a day and night tariff bought for the whole day (G1, `reports/UX-69-design-report.md`). The sun before the run is unchanged. A run with its dear hours after sunset gives the old number.
- **House use.** An hour with sun is read at no less than the night base, as the sun sum reads it (the profile is net of the sun). An hour without is read as measured. An hour with no forecast counts no sun.
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by another battery (Cheap hours, Solar first) or the grid, as the screen says.
- **Cheap hours** still fills up and is not counted in the share; **Solar first** still never charges from the grid. The mode help says so.
- **Not done:** the run length (OPT-25); netting what a Cheap hours or Solar first battery will hold against the shortfall.
@@ -0,0 +1,146 @@
# 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.
+20 -3
View File
@@ -530,8 +530,8 @@ Off by default. Turn it on, restart the add-on, and the Batteries screen (and Se
- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover.
- **Mode.**
- *Solar first* charges from spare solar before the other batteries and covers the house at any hour. It never charges from the grid and never sells.
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but only what the sun will not give (see below). It saves its power for the expensive hours and covers the house then.
- *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then.
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but it buys only what the sun will not give in the expensive hours (see below). It saves its power for those hours and covers the house then.
- *Cheap hours only* fills up from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then. It does not look at the forecast.
- **Selling** is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A).
With battery modes on, these choices replace the *Charging and selling on price* switch (`arbitrage_enabled`); that switch is only read with modes off.
@@ -548,7 +548,9 @@ With battery modes on, these choices replace the *Charging and selling on price*
- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list.
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
- *Leftover solar* then charges from the grid only the shortfall: for each expensive hour, what the house uses less the sun counted for that hour (never below 0), added up over all the expensive hours, early morning and evening. With a day and night tariff that is 07:00 to 22:00. Not up to the top level.
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. In the expensive hours the rest comes from another battery (*Cheap hours only* or *Solar first*) or from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
- A forecast that gives only one figure for the day (Forecast.Solar) is spread over the hours from now, so the hours add up to that figure.
- *Cheap hours only* ignores the forecast and fills to the top level.
- *Solar first* never charges from the grid.
- The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan.
@@ -3530,6 +3532,21 @@ the peak limit, the batteries' switches and modes, and shadow mode, each
marked "Applies right away" or "Applies after a restart". **Installer** shows
every setting.
### The Forecast screen
The **Forecast** tab shows today and tomorrow, hour by hour: the solar forecast,
the solar measured so far today, the house's expected use, and each battery's
expected level. Under each battery's chart are its planned blocks, in the same
words as Home. A block is there only where the add-on plans one.
The level is an estimate. It counts on `solar_trust_pct` of the forecast, as the
plan does, and stays between the battery's low and high levels. A forecast that
gives one total for the day (Forecast.Solar) is spread over the hours, and the
screen says so. Where a part is not known yet (battery modes off, no forecast,
still learning your house, no forecast for tomorrow) the screen says that
instead of drawing an empty chart. It changes nothing in control, and it asks
Home Assistant for nothing: it is built when you open it.
### Setup, for a new install
The **Setup** tab walks an installer through six steps: the meter, the
+293
View File
@@ -0,0 +1,293 @@
"""UX-54: the expected battery level through the day, for the Forecast tab.
Pure: batteries, the sun and the house's use in; a level per battery over
time out. Display only: nothing here is read by control, and it never
decides a block. The blocks are the plan's (`homeview.mode_runs()`); this
only says where the level goes, hour by hour, if the add-on does what the
plan says.
Each step gives every battery a role, as `modetrade.decide()` does (the
names are modetrade's): in a grid block it charges (CHARGE) and the others
rest (REST, no charge); else it follows the house (HOUSE) if it is Solar
first or no price plan is on; inside a give run it covers the house (GIVE);
else it holds (HOLD, may charge from spare solar, gives nothing). Spare
solar (sun less the house's use) goes to Solar first batteries first, then
the rest, by room. A shortfall comes from the batteries that may give,
those in a give run first, by what they hold above the floor, each at most
its rating, the rest passed on (as control shares it, `packshare.weights()`
and the allocator's fill). A battery that empties inside a step gets a
point there (`_floor_cut()`), as one that reaches its grid level does.
Charging and giving lose `dayplan.EFF`, as the plan counts. A level stays
inside the floor and ceiling the plan uses, and a battery already outside
them does not move further out.
`SolarToday` keeps the site's measured solar per hour, in memory, from the
reading the control cycle already has. It is shown and never read back
(TEL-11 keeps site solar out of control).
"""
import math
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass
from .dayplan import EFF, _hour_slices, _local
from .modetrade import CHARGE, GIVE, HOLD, HOUSE, REST
EPS = 1e-9
MIN_CUT = timedelta(seconds=1) # no step is cut closer to its ends
MAX_FLOOR_CUTS = 400 # a bound on the floor cuts of one curve
GAP_S = 60.0 # a longer gap between readings is not counted
MIN_COVER = 0.5 # share of the elapsed hour a reading must cover
@dataclass(frozen=True)
class Batt:
"""One battery that is on, with a level and a capacity."""
name: str
capacity_kwh: float
rating_kw: float
soc: float # % now
solar_first: bool # Solar first: follows the house all day
priced: bool # a price plan is on and its mode is a price mode
grid: tuple = () # ((start, end, to_pct), ...) grid blocks
give: tuple = () # ((start, end), ...) give runs
stop_at: datetime | None = None # a monthly cycle starts: no level after
def _role(b, t, charging, own):
"""The battery's role at instant `t`; `own`: it charges from the grid;
`charging`: some battery does. Returns `(role, may_charge, may_give)`."""
in_give = any(s <= t < e for s, e in b.give)
if own:
return CHARGE, False, False
if charging:
return REST, False, b.solar_first or not b.priced or in_give
if b.solar_first or not b.priced:
return HOUSE, True, True
if in_give:
return GIVE, True, True
return HOLD, True, False
def _spread(amount, caps, weights):
"""Split `amount` over the keys of `caps` by `weights`, none over its
cap. Returns `(taken, left)`: what each took and what no one could."""
got = {k: 0.0 for k in caps}
left = float(amount)
live = {k for k, c in caps.items() if c > EPS and weights.get(k, 0.0) > 0}
while left > EPS and live:
total = sum(weights[k] for k in live)
used, full = 0.0, set()
for k in live:
part = left * weights[k] / total
room = caps[k] - got[k]
if part >= room - EPS:
part = room
full.add(k)
got[k] += part
used += part
left -= used
if not full:
break
live -= full
return got, max(0.0, left)
def _give_caps(roles, level, by, floor, hrs):
"""`(first, rest, held)`: what each battery that may give can give in a
step of `hrs` hours (its rating, or what it holds above the floor), those
in a give run first, and what each holds above the floor (as given)."""
held = {n: max(0.0, level[n] - floor) / 100.0 * by[n].capacity_kwh * EFF
for n, r in roles.items() if r[2]}
caps = {n: min(by[n].rating_kw * hrs, h) for n, h in held.items()}
first = {n: c for n, c in caps.items() if roles[n][0] == GIVE}
return first, {n: c for n, c in caps.items() if n not in first}, held
def _floor_cut(a, z, net, roles, level, by, floor, hrs):
"""Where in the step `[a, z)` the first battery that gives reaches the
floor, or None. The shortfall comes at a steady rate, `-net / hrs` kW.
It is shared as control shares it (`packshare.weights()`: by the energy
each holds above its cut-out, here the floor), each at most its rating,
what one cannot take passed to the others, those in a give run first.
A battery giving `v` kW that holds `h` kWh is empty at `h / v` hours; a
cut there makes the step come out exact. A battery already at the floor
holds nothing, gives nothing and never sets a cut."""
rate, cut = -net / hrs, None
*groups, held = _give_caps(roles, level, by, floor, hrs)
for grp in groups:
rates, rate = _spread(rate, {n: by[n].rating_kw for n in grp},
{n: held[n] for n in grp})
for n, v in rates.items():
if held[n] > EPS and v > EPS:
t = a + timedelta(hours=held[n] / v)
if a + MIN_CUT < t < z - MIN_CUT and (cut is None
or t < cut):
cut = t
return cut
def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct,
trust=1.0, stats=None):
"""`{name: [(t, pct), ...]}` from `start` (each battery's level now) to
`end`, a point at every hour edge, every block edge and where a battery
reaches its grid level or, giving, the floor. A battery's list
stops where `sun_kwh(a, b)` is None (the sun is not known) or its
`stop_at` comes. `sun_kwh` and `use_kwh` give kWh for the span `[a, b)`.
Spare solar is `trust x max(0, sun - use)`, and what is stored loses
`EFF`: `dayplan.plan()`'s own `trust x EFF x max(0, sun - house)`. A
shortfall is the house's use less the whole sun. `stats`, a dict or
None: gets `floor_cuts` (the floor points placed) and `capped` (True when
`MAX_FLOOR_CUTS` stopped one)."""
floor, ceil = float(floor_pct), float(ceiling_pct)
cuts = {end}
for a, _b, _h in _hour_slices(start, end):
cuts.add(a)
for b in batts:
for s, e, *_ in b.grid + b.give:
cuts.update(x for x in (s, e) if start < x < end)
cuts.add(start)
ts = sorted(cuts)
level = {b.name: float(b.soc) for b in batts}
out = {b.name: [(start, level[b.name])] for b in batts}
alive = {b.name for b in batts}
floor_cuts, capped = 0, False
i = 0
while i < len(ts) - 1:
a, z = ts[i], ts[i + 1]
if not alive:
break
sun = sun_kwh(a, z)
if sun is None:
break
for b in batts:
if b.name in alive and b.stop_at is not None and a >= b.stop_at:
alive.discard(b.name)
net = sun - use_kwh(a, z)
if net > 0.0:
net *= trust
hrs = (z - a).total_seconds() / 3600.0
want = {} # name -> grid target %
for b in batts:
if b.name in alive:
for s, e, to in b.grid:
if s <= a < e and level[b.name] < to - EPS:
want[b.name] = float(to)
by = {b.name: b for b in batts}
# A battery that reaches its level inside the step gets a point
# there: the step is cut and read again, so the line is exact.
cut = None
for n, to in want.items():
b = by[n]
room = (to - level[n]) / 100.0 * b.capacity_kwh
rate = b.rating_kw * hrs * EFF
if EPS < room < rate * (1.0 - 1e-9):
t_r = a + (z - a) * (room / rate)
if a < t_r < z and (cut is None or t_r < cut):
cut = t_r
if cut is not None:
ts.insert(i + 1, cut)
continue
roles = {b.name: _role(b, a, bool(want), b.name in want)
for b in batts if b.name in alive}
# A battery that gives all it holds inside the step gets a point
# where it reaches the floor, the same way.
cut = _floor_cut(a, z, net, roles, level, by, floor, hrs) \
if net < -EPS else None
if cut is not None and floor_cuts >= MAX_FLOOR_CUTS:
capped, cut = True, None
if cut is not None:
floor_cuts += 1
ts.insert(i + 1, cut)
continue
for n, to in want.items(): # charging from the grid
b = by[n]
room = (to - level[n]) / 100.0 * b.capacity_kwh
level[n] += min(b.rating_kw * hrs * EFF, room) \
/ b.capacity_kwh * 100.0
if net > EPS:
caps = {n: min(by[n].rating_kw * hrs,
max(0.0, ceil - level[n]) / 100.0
* by[n].capacity_kwh / EFF)
for n, r in roles.items() if r[1]}
first = {n: c for n, c in caps.items() if by[n].solar_first}
rest = {n: c for n, c in caps.items() if n not in first}
left = net
for grp in (first, rest):
got, left = _spread(left, grp, dict(grp))
for n, v in got.items():
level[n] += v * EFF / by[n].capacity_kwh * 100.0
elif net < -EPS:
first, rest, held = _give_caps(roles, level, by, floor, hrs)
left = -net
for grp in (first, rest):
got, left = _spread(left, grp, {n: held[n] for n in grp})
for n, v in got.items():
level[n] -= v / EFF / by[n].capacity_kwh * 100.0
for b in batts:
if b.name in alive:
out[b.name].append((z, level[b.name]))
i += 1
if stats is not None:
stats["floor_cuts"] = stats.get("floor_cuts", 0) + floor_cuts
stats["capped"] = stats.get("capped", False) or capped
return out
# --------------------------------------------------------------------------- #
# Measured solar, per local hour
# --------------------------------------------------------------------------- #
class SolarToday:
"""The site's solar power, averaged per local hour of one local day. In
memory only: a restart starts it again, and `first` says from when it
has counted. Display only."""
def __init__(self):
self.day = None
self.first = None
self._hours = {} # hour start (UTC) -> [W x s, seconds]
self._last = None # (time, W)
def add(self, now: datetime, w) -> None:
"""One reading (`None`: no reading, which ends the interval)."""
day = _local(now).date()
if day != self.day:
self.day, self.first, self._hours, self._last = day, None, {}, None
try:
ok = w is not None and math.isfinite(float(w))
except (TypeError, ValueError):
ok = False
if not ok:
self._last = None
return
w = max(0.0, float(w))
if self.first is None:
self.first = now
if self._last is not None and now > self._last[0]:
dt = min(GAP_S, (now - self._last[0]).total_seconds())
key = _hour_start(self._last[0])
row = self._hours.setdefault(key, [0.0, 0.0])
row[0] += (self._last[1] + w) / 2.0 * dt
row[1] += dt
self._last = (now, w)
def kwh(self, start: datetime, end: datetime, now: datetime):
"""kWh for the hour `[start, end)` up to `now`: the mean power it was
read at over the part of the hour that has passed, or None when the
readings cover under half of that part (or none has passed)."""
if self.day != _local(now).date():
return None
over = (min(end, now) - start).total_seconds()
row = self._hours.get(start)
if over <= 0 or row is None or row[1] < MIN_COVER * over:
return None
return row[0] / row[1] * over / 3.6e6
def _hour_start(t: datetime) -> datetime:
"""The start of `t`'s local hour, in UTC. Taken back from the UTC instant,
never from the wall clock, so each of the two 02:00 hours of the autumn
change has its own (`dayplan._hour_slices()`, SAFETY-123)."""
lt = _local(t)
return t.astimezone(timezone.utc) - timedelta(
minutes=lt.minute, seconds=lt.second, microseconds=lt.microsecond)
+69 -17
View File
@@ -7,20 +7,31 @@ turns the level into permission to charge from the grid; main.py rebuilds the
plan with the price runs (at most every 15 minutes) and keeps a level unless
it moves by `HYST_KWH` or `HYST_FRAC` (`hold()`).
The sums (OPT-11-design section 1, "Excess solar", with the owner's answers):
The sums (OPT-11-design section 1, "Excess solar", with the owner's answers;
OPT-28 for the second):
* `sun`: trust x the solar the house will not use, between now and the
expensive run the cheap run is for, x the charge efficiency:
`trust x sum_h max(0, forecast_h - house_h) x EFF`.
* Solar first batteries take it first, up to the ceiling. What is left is
shared by the Leftover solar batteries by their room.
* `short` (OPT-28): what the house draws in the expensive run that the sun
will not give, `sum_h max(0, house_h - trust x forecast_h)` over every
hour of the run, early morning and evening. The Leftover solar batteries
hold it together: `short / EFF` above the floor, shared by capacity, and
a battery that reaches the ceiling passes the rest to the others. What
all of them cannot hold (`Plan.uncovered_kwh`) is not bought: another
battery (Cheap hours, Solar first) or the grid carries it.
* Cheap hours only ignores the plan and fills to the ceiling, as in P3 and
the design (section 1). Skipping the sun's share cost money in the replay
(PR #156 review: 29.72 against 29.41 EUR on real 2026 prices).
* A Leftover solar battery charges only up to what the house will draw from
it in the expensive runs before the next cheap run, less its share:
`min(ceiling, floor + house use / EFF) - share`. So it buys only the
* A Leftover solar battery charges from the grid up to the level it needs
for its part of `short`, less its share of the sun before the run:
`min(ceiling, floor + its part / EFF) - share`. So it buys only the
shortfall, not up to the ceiling.
* Solar first never charges from the grid (owner, 2026-09-29).
* A state-only forecast is read from now for every span (`Forecast.between`
`since=`, OPT-23), so the hours add up to the day's figure.
DECISIONS/OPT-23-28-the-night-charge-covers-the-dear-hours.md has the record.
The house profile is the add-on's own measurement, grid + battery power, per
local hour, over the last `PROFILE_DAYS` days; the estimate for an hour is the
@@ -31,7 +42,7 @@ control decision.
"""
import statistics
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
@@ -154,6 +165,11 @@ class Plan:
targets: dict # name -> level % (Leftover solar only)
need_kwh: dict # name -> kWh from the grid (0: none)
share_kwh: dict # name -> kWh of solar counted for it
# OPT-28: the expensive hours' shortfall, the part of it each battery
# covers, and what no battery can hold. All kWh on the house's side.
short_kwh: float = 0.0
cover_kwh: dict = field(default_factory=dict)
uncovered_kwh: float = 0.0
def _hour_slices(t0, t1):
@@ -171,6 +187,31 @@ def _hour_slices(t0, t1):
return out
def _spread(amount, room, weight):
"""Share `amount` over the keys of `room` by `weight`, none past its
room, what one cannot take passed to the others. Returns `(taken,
left)`: what each took and what none could."""
got = {k: 0.0 for k in room}
left = float(amount)
live = {k for k, r in room.items() if r > 1e-9 and weight.get(k, 0) > 0}
while left > 1e-9 and live:
total = sum(weight[k] for k in live)
used, full = 0.0, set()
for k in sorted(live):
part = left * weight[k] / total
free = room[k] - got[k]
if part >= free - 1e-9:
part = free
full.add(k)
got[k] += part
used += part
left -= used
if not full:
break
live -= full
return got, max(0.0, left)
def _room(p, to_pct):
if p.capacity_kwh is None or p.soc is None:
return 0.0
@@ -194,7 +235,7 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
trust = max(0.0, min(1.0, float(trust_pct) / 100.0))
sun = fc_sum = 0.0
for s, e, h in _hour_slices(now, until.start):
pv = forecast.between(s, e)
pv = forecast.between(s, e, since=now)
if pv is None:
return None # the span reaches a day with no forecast
hrs = (e - s).total_seconds() / 3600.0
@@ -212,30 +253,40 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
rooms = {p.name: _room(p, ceiling_pct) for p in price}
total_room = sum(rooms.values())
# What the house draws in the expensive runs from the one this plan is
# for until the next cheap run (or a day), from the profile.
# OPT-28: what the house draws in the expensive runs, from the one this
# plan is for until the next cheap run (or a day), that the sun will not
# give. An hour with sun is read at no less than the night base, as the
# sun sum above reads it (the profile is net of the sun the house used);
# an hour without is read as measured. A run hour with no forecast counts
# no sun.
horizon = until.start + timedelta(hours=24)
use_kwh = 0.0
short = 0.0
for g in runs.give:
if g.start < until.start or g.start >= horizon:
continue
for s, e, h in _hour_slices(g.start, g.end):
use_kwh += max(0.0, house_w(h)) * (
e - s).total_seconds() / 3600.0 / 1000.0
pv = forecast.between(s, e, since=now) or 0.0
w = max(0.0, house_w(h))
if pv > 0.0:
w = max(w, base_w)
short += max(0.0, w * (e - s).total_seconds() / 3.6e6
- trust * pv)
caps = {p.name: float(p.capacity_kwh) for p in price}
cap_sum = sum(caps.values()) or 1.0
span = max(0.0, float(ceiling_pct) - float(floor_pct))
held, left = _spread(short / EFF,
{n: span / 100.0 * c for n, c in caps.items()},
caps)
targets, need, share = {}, {}, {}
targets, need, share, cover = {}, {}, {}, {}
for p in price:
sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0
sh_pct = sh / caps[p.name] * 100.0
want = use_kwh * caps[p.name] / cap_sum / EFF
top = min(float(ceiling_pct),
float(floor_pct) + want / caps[p.name] * 100.0)
top = float(floor_pct) + held[p.name] / caps[p.name] * 100.0
level = max(0.0, top - sh_pct)
targets[p.name] = round(level, 2)
need[p.name] = round(_room(p, level), 3)
share[p.name] = round(sh, 3)
cover[p.name] = round(held[p.name] * EFF, 3)
if sun < sf_room:
verdict = DARK
elif any(v > 0.05 for v in need.values()):
@@ -243,7 +294,8 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
else:
verdict = SUNNY
return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start,
targets, need, share)
targets, need, share, round(short, 3), cover,
round(left * EFF if price else 0.0, 3))
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
+448
View File
@@ -0,0 +1,448 @@
"""UX-54: the Forecast tab, built on request (`GET forecast`). Reads only.
Two days, today and tomorrow, per local 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:
* the blocks are Home's own: today's are the `runs` of `/status`'s plan, and
tomorrow's come from the same builders (`homeview.runs_for()`), so a block
is on the tab exactly where the plan has one, with the plan's words;
* the sun is `solarfc.Forecast.between()` and the house's use is
`dayplan.HouseProfile.estimator()` read as `dayplan.plan()` reads it;
* the level is `daycurve.curve()`, fed the blocks, the give runs, the packs
and the plan's levels (`modetrade.charge_to()`), floor and ceiling.
A part that is not known yet is a plain note (`home_words.day`), never an
empty chart. DECISIONS/UX-54-forecast-tab.md has the record.
"""
import dataclasses
from datetime import datetime, timedelta
from . import arbitrage, dayplan, homeview as hv, modetrade, priceruns
from . import solarfc
from .daycurve import Batt, curve
from .packmode import SOLAR_FIRST
from .price import Uncertainty, brussels, required_spread, uncertainty_of
BLOCKS = ("grid", "give", "cycle") # the kinds a battery's blocks carry
LEVEL_DP = 1 # the tab shows a level to 0.1 %
def _hours_between(a, b) -> float:
return (b - a).total_seconds() / 3600.0
def build(c, status: dict, now: datetime, lang: str = "en") -> dict:
"""The tab's JSON. `status` is the `/status` payload just built, whose
plan holds today's blocks."""
with hv.in_lang(lang):
return _build(c, status, now)
def sun_kwh(fc, now, a, b):
"""kWh the forecast gives in `[a, b)`, `a` at or after `now`, or None.
Every span is measured from `now` (`Forecast.between(since=)`, OPT-23),
as `dayplan.plan()` measures them: a state-only "left today" figure is
the sun from the moment it was taken, so a span that starts later must
not be spread over what is left of the bell from its own start
(DECISIONS/UX-54-forecast-tab.md)."""
return fc.between(a, b, since=now)
def _times(run):
return (datetime.fromisoformat(run["start"]),
datetime.fromisoformat(run["end"]))
def _build(c, status, now):
plan, rows = status.get("plan"), status.get("batteries")
if not plan or rows is None:
return {"ok": False}
d0, d1 = hv.local_day(now)
n0, n1 = hv.local_day(d1 + timedelta(hours=2))
shadow = bool(c.shadow)
key = plan.get("verdict_key")
running = key not in ("held", "stopped")
modes_on = bool(getattr(c, "modes_on", False))
floor, ceil = arbitrage.soc_bounds(c.o)
fc = getattr(c, "solar_fc", None)
hp = getattr(c, "house_profile", None)
est = hp.estimator() if hp is not None and fc is not None else None
trust = max(0.0, min(1.0, float(getattr(c, "solar_trust_pct", 70))
/ 100.0))
# Why a part is not known (None: nothing is missing).
missing = ("modes_off" if not modes_on
else "not_set" if c.fc_entity is None
else "no_forecast" if fc is None
else "learning" if est is None else None)
# The blocks of each day: today's from Home, tomorrow's by its builders.
# Tomorrow has not started, so the level now says nothing about it: the
# builders read each block at the level it starts at (`ahead`), with
# their own room and energy filter. With modes on that level is the
# curve's (`_levels()`); with modes off there is no curve, and it is
# Home's own count (`_counted()`).
runs = [plan["runs"], []]
if running and not modes_on:
runs[1] = _off_tomorrow(c, runs[0], now, shadow, n0, n1)
elif running:
runs[1] = hv.runs_for(c, n0, n1, now, shadow, ahead=True)
# The level curve of every battery that is on and known. It also makes
# tomorrow's blocks again at the level each has when its run starts, and
# draws a grid block as long as control keeps charging (`_levels()`).
notes_b = {}
levels = {}
if running and missing is None:
levels, runs = _levels(c, est, fc, trust, now, n1, runs, floor, ceil,
notes_b, shadow, n0, n1)
blocks = [[[r for r in rs if r["battery"] == i and r["kind"] in BLOCKS]
for i in range(len(c.inverters))] for rs in runs]
# Prices are planned about a day ahead (`priceruns.HORIZON`), so tomorrow's
# blocks may be missing now and appear later.
priced = bool(plan.get("has_prices")) and (
any(c.pack_mode(i).use and c.pack_mode(i).mode in hv.PRICE_MODES
for i in c.inverters) if modes_on else arbitrage.enabled(c.o))
days = []
for k, (a, z) in enumerate(((d0, d1), (n0, n1))):
days.append(_day(c, k, a, z, now, fc, est, trust, levels, blocks[k],
rows, shadow, running, key, missing, notes_b, status,
priced))
return {"ok": True, "running": running, "shadow": shadow,
"now_h": round(_hours_between(d0, now), 3),
"updated": None if fc is None else hv.hhmm(fc.at),
"days": days}
def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow,
n0, n1):
"""`({name: [(t, pct), ...]}, runs)`: `daycurve.curve()` for the batteries
that are on, have a level and are not in their monthly cycle, and `runs`
with tomorrow's blocks made as control will meet them.
Tomorrow's blocks are `homeview.mode_runs()`'s, read at the level the
curve has when each block starts (its `ahead` function), so its own room
and energy filter and `MIN_KWH` decide: no give block where the battery
is at the floor when the run starts, no grid block where it is at its
planned level. The curve in turn follows the blocks, so the two are taken
to a fixed point.
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 then
plans the next cheap slots (`Controller._price_runs()`). That is asked of
the same functions, `modetrade.energy()` and `priceruns.plan()`, with the
packs at the curve's level, and the block is drawn to where control
stops charging. Today's blocks are Home's, as they are."""
packs = {p.name: p for p in c._mode_packs()}
targets = c.plan_targets()
rp = getattr(c, "mode_runs", None)
plan_on = rp is not None and rp.on
house_w, base_w = est
def make(rs):
batts = []
for i, inv in enumerate(c.inverters):
pm = c.pack_mode(inv)
if not pm.use:
continue
if inv.soc is None or not inv.capacity_kwh:
notes_b[inv.name] = hv.day_note("no_level", name=inv.name)
continue
mine = [r for day in rs for r in day
if r["battery"] == i and r["kind"] in BLOCKS]
cyc = [_times(r)[0] for r in mine if r["kind"] == "cycle"]
if cyc and min(cyc) <= now:
notes_b[inv.name] = hv.day_note("cycle", name=inv.name)
continue
if cyc:
notes_b[inv.name] = hv.day_note("cycle", name=inv.name)
to = modetrade.charge_to(packs[inv.name], ceil, targets)
grid = tuple((*_times(r), to) for r in mine
if r["kind"] == "grid" and to is not None)
priced = plan_on and pm.mode != SOLAR_FIRST
give = tuple((g.start, g.end) for g in rp.give) if priced else ()
batts.append(Batt(inv.name, float(inv.capacity_kwh),
inv.max_w / 1000.0, float(inv.soc),
pm.mode == SOLAR_FIRST, priced, grid, give,
min(cyc) if cyc else None))
return batts
def sun(a, z):
return sun_kwh(fc, now, a, z)
def use(a, z):
return sum(max(house_w(h), base_w) * _hours_between(s, e) / 1000.0
for s, e, h in dayplan._hour_slices(a, z))
def run(rs):
batts = make(rs)
return curve(batts, now, end, sun, use, floor_pct=floor,
ceiling_pct=ceil, trust=trust) if batts else {}
def settle(ext, lv):
"""Tomorrow's blocks read at the levels of `lv`, and the curve they
make, again until the blocks stop changing."""
seen = None
for _ in range(4):
def at(name, _kind, start, lv=lv):
return _level_at(lv.get(name, ()), start)
rt = hv.runs_for(c, n0, n1, now, shadow, ahead=at, rp=ext)
lv = run([runs[0], rt])
sig = [(r["battery"], r["kind"], r["start"], r["end"])
for r in rt]
if sig == seen:
break
seen = sig
return rt, lv
lv0 = run([runs[0], []])
ext = rp
plan = c.fresh_tariff_plan(now)
cheap = None if rp is None else rp.cheap
end_t = None
if plan_on and cheap is not None and rp.charge_useful() \
and n0 <= cheap.start < n1 and plan is not None:
required = required_spread(
arbitrage.threshold_eur_kwh(c.o),
uncertainty_of(plan.read.product) | {Uncertainty.NO_WEATHER})
end_t = cheap.end
for _ in range(24):
ext = dataclasses.replace(rp, cheap=priceruns.Run(
cheap.start, end_t, cheap.mean_eur_kwh))
rt, lv = settle(ext, lv0)
at_end = [dataclasses.replace(packs[n], soc=v)
for n, v in ((n, _level_at(lv.get(n, ()), end_t))
for n in lv) if v is not None]
e = modetrade.energy(at_end, floor, ceil, targets=targets)
if e[0] <= 1e-6:
break
new = priceruns.plan(
plan.read.points, end_t, required_eur_kwh=required,
charge_kwh=e[0], charge_kw=e[1], sell_kwh=0.0, sell_kw=0.0)
nxt = new.cheap
if nxt is None or not new.charge_useful() or nxt.start > end_t \
or nxt.end <= end_t:
break
end_t = nxt.end
ext = dataclasses.replace(rp, cheap=priceruns.Run(
cheap.start, end_t, cheap.mean_eur_kwh))
rt, lv = settle(ext, lv0)
# A run is for all the batteries charging together; each stops when it is
# at its own level (control's `chargers()`), so its block ends there.
names = [inv.name for inv in c.inverters]
rt = [_stops(r, names, lv, packs, targets, floor, ceil, n0) for r in rt]
return lv, [runs[0], rt]
def _off_tomorrow(c, today, now, shadow, n0, n1):
"""Modes off: tomorrow's blocks by `homeview.trade_runs()`, each read at
the level `_counted()` gives when it starts. A charge may come before a
give, so this repeats until the blocks stop changing."""
sig, rt = None, []
for _ in range(4):
rt = hv.runs_for(c, n0, n1, now, shadow,
ahead=_counted(c, [today, rt]))
new = [(r["battery"], r["kind"], r["start"], r["end"]) for r in rt]
if new == sig:
break
sig = new
return rt
def _counted(c, rs):
"""Modes off reads no forecast and keeps no house profile, so there is no
curve. A give is read at Home's own count (`trade_runs()`): the level now,
plus what the blocks in `rs` that start before it put in, less what they
take out. The house can only take from that and no sun is read, so a
battery at the floor by the count is at the floor then. A charge is read
from the floor (None): the house uses some of a full battery before the
cheap hour, and control charges it back then."""
_floor, ceil = arbitrage.soc_bounds(c.o)
def at(name, kind, start):
i = next(k for k, inv in enumerate(c.inverters) if inv.name == name)
inv = c.inverters[i]
if kind == "grid" or inv.soc is None or not inv.capacity_kwh:
return None
v = float(inv.soc)
mine = sorted((r for day in rs for r in day if r["battery"] == i
and r["kind"] in ("grid", "give")
and r.get("kwh") is not None
and _times(r)[0] < start),
key=lambda r: _times(r)[0])
for r in mine:
d = float(r["kwh"]) / float(inv.capacity_kwh) * 100.0
v = min(max(v, ceil), v + d) if r["kind"] == "grid" else v - d
return v
return at
def _stops(r, names, lv, packs, targets, floor, ceil, day0):
"""`r` ended where its battery stops, if that is before the run ends: a
grid block where the curve has it at the level `modetrade.charge_to()`
allows (the curve has a point there), a give block at the first point
where the level the tab shows (`LEVEL_DP`) is at the floor. A run is for
all the batteries together; each leaves it on its own."""
if r["kind"] not in ("grid", "give"):
return r
name = names[r["battery"]]
start, end = _times(r)
if r["kind"] == "grid":
to = modetrade.charge_to(packs[name], ceil, targets)
if to is None:
return r
hit = lambda v: v >= to - 1e-6 # noqa: E731
else:
hit = lambda v: round(v, LEVEL_DP) <= floor # noqa: E731
done = next((t for t, v in lv.get(name, ()) if start < t <= end
and hit(v)), None)
if done is None or done >= end:
return r
return dict(r, end=done.isoformat(), to=hv.hhmm(done),
end_h=round(_hours_between(day0, done), 3))
def _level_at(pts, t):
"""The curve's level at `t` (between two points, straight), or None when
the curve does not reach `t`."""
for (t0, v0), (t1, v1) in zip(pts, pts[1:]):
if t0 <= t <= t1:
span = (t1 - t0).total_seconds()
return v0 if span <= 0 else v0 + (v1 - v0) * (
(t - t0).total_seconds() / span)
return None
def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
running, key, missing, notes_b, status, priced):
day = (fc.today, fc.tomorrow)[k] if fc is not None else None
solar = getattr(c, "solar_today", None)
names = [inv.name for inv in c.inverters]
hours = []
for s, e, h in dayplan._hour_slices(d0, d1):
f = None
if fc is not None and e > now and (k == 0 or fc.tomorrow is not None):
f = sun_kwh(fc, now, max(s, now), e)
got = solar.kwh(s, e, now) if k == 0 and solar is not None \
and s < now else None
use = None if house is None else max(house[0](h), house[1]) \
* _hours_between(s, e) / 1000.0
row = {"start_h": round(_hours_between(d0, s), 3),
"end_h": round(_hours_between(d0, e), 3), "label": hv.hhmm(s),
"forecast_kwh": None if f is None else round(f, 3),
"measured_kwh": None if got is None else round(got, 3),
"house_kwh": None if use is None else round(use, 3),
"levels": []}
hours.append((s, row))
batts = []
for i, inv in enumerate(c.inverters):
row = rows[i] if i < len(rows) else {}
pts = [(t, v) for t, v in levels.get(inv.name, ()) if d0 <= t <= d1]
mine = blocks[i]
batts.append({
"name": inv.name, "capacity_kwh": inv.capacity_kwh,
"off": row.get("use") is False,
"levels": [[round(_hours_between(d0, t), 3), round(v, LEVEL_DP)]
for t, v in pts],
"blocks": [{x: r[x] for x in ("kind", "from", "to", "start_h",
"end_h", "what", "why", "amount")}
for r in mine],
"line": _line(c, inv, row, mine, running, key, shadow, d0, d1,
now)})
at = {t: v for t, v in pts}
for s, hr in hours:
hr["levels"].append(None if s not in at
else round(at[s], LEVEL_DP))
has_levels = any(b["levels"] for b in batts)
fcs = [h["forecast_kwh"] for _s, h in hours]
notes = []
if missing is not None:
notes.append(hv.day_note(missing))
if not running:
notes.append(hv.why_words(key))
if shadow:
notes.append(hv.why_words("shadow"))
line = ""
if fc is not None:
if k == 0:
line = hv.line_solar(fc.today.kwh, fc.today.kwh * trust,
hv.hhmm(fc.at))
elif fc.tomorrow is not None:
line = hv.day_note("tomorrow_total",
kwh=hv._kwh(fc.tomorrow.kwh),
count=hv._kwh(fc.tomorrow.kwh * trust))
if day is not None and not day.periods:
notes.append(hv.day_note("estimate"))
if k == 1 and fc.tomorrow is None:
notes.append(hv.day_note("no_tomorrow", at=_sun_from()))
if k == 0 and solar is not None and solar.first is not None \
and solar.day == brussels(now).date() \
and _hours_between(d0, solar.first) > 0.5:
notes.append(hv.day_note("measured_from", at=hv.hhmm(solar.first)))
if has_levels:
notes.append(hv.day_note("trust", pct=round(trust * 100)))
if k == 1 and _tomorrow_line(status):
notes.append(_tomorrow_line(status))
if k == 1 and running and priced:
notes.append(hv.day_note("ahead"))
notes += [notes_b[n] for n in names if n in notes_b]
return {"key": ("today", "tomorrow")[k], "date": brussels(d0).date()
.isoformat(),
"length_h": int(round(_hours_between(d0, d1))),
"line": line, "notes": list(dict.fromkeys(notes)),
"sun": {"forecast": any(x is not None for x in fcs),
"estimate": day is not None and not day.periods,
"measured": any(h["measured_kwh"] is not None
for _s, h in hours),
"house": house is not None},
"hours": [h for _s, h in hours], "batteries": batts}
def _sun_from() -> str:
"""When the sun of a day starts for `solarfc` (its bell's first hour):
with no forecast for tomorrow the sun is unknown from then, and so the
curve stops there (`Forecast.between()` answers None past it)."""
return f"{int(solarfc.BELL_FROM_H):02d}:00"
def _tomorrow_line(status) -> str:
"""Home's own line about tomorrow's prices, for the tomorrow tab."""
return (status.get("plan") or {}).get("tomorrow_line") or ""
def _give_planned(c, d0, d1, now) -> bool:
"""Does the add-on plan a give in `[d0, d1)` not yet over? Modes on: a
give run of the run plan. Modes off: a give leg of the verdict that the
gate takes (`homeview.trade_legs()`), at a price that is not negative."""
if not getattr(c, "modes_on", False):
plan = c.fresh_tariff_plan(now)
verdict, _why = hv.trade_verdict(c, plan, now)
return verdict is not None and any(
k == "give" and pt.eur_per_kwh >= 0 and pt.start < d1
and hv._point_end(plan, pt) > max(now, d0)
for k, pt in hv.trade_legs(c, plan, verdict, now))
rp = getattr(c, "mode_runs", None)
return rp is not None and rp.on and any(
g.end > max(now, d0) and g.start < d1 for g in rp.give)
def _line(c, inv, row, mine, running, key, shadow, d0, d1, now) -> str:
"""What to say for a battery with no block: the plan's own words. Home
draws no give block for a battery with no energy to give, so a give that
the add-on plans with no block gets its own sentence."""
if not running:
return hv.why_words(key)
if row.get("use") is False:
return hv.why_words("off")
modes_on = getattr(c, "modes_on", False)
gives = not modes_on or row.get("mode_choice") in hv.PRICE_MODES
if gives and not any(r["kind"] == "give" for r in mine) \
and _give_planned(c, d0, d1, now):
return hv.day_note("give_later")
if mine:
return ""
if modes_on and gives:
return hv.day_note("no_blocks")
return hv.run_house_what(shadow) + " " + hv.run_house_why(shadow)
+147 -23
View File
@@ -16,9 +16,11 @@ them, so nothing here invents a plan. Every sentence is in the words
section at the bottom and is held to STYLE.md by test_messages.py.
"""
import contextlib
from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard, uitext
from .dayplan import EFF
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE, next_slot_start
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
@@ -332,18 +334,30 @@ def trade_legs(c, plan, verdict, now):
MIN_KWH = 0.05
def trade_runs(c, plan, verdict, day0, day1, now, shadow):
def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
"""A charge block and a give block on each battery, where the verdict put
them. The site trades as one; each battery's share is capped by its own
rating and by the room its level leaves (arbitrage's own floor/ceiling).
Only what `arbitrage.decide()` would still do is drawn: no block on a
battery with no room, no sale at a negative price, and no sale while the
emptiest battery is at the floor (the cycle's `soc` is the emptiest).
With a level unknown the cycle does not trade, so nothing is drawn."""
With a level unknown the cycle does not trade, so nothing is drawn.
UX-54 `ahead`: the window has not started, so the level now says nothing
about it. The same rules then read each battery at the level a leg starts
at: `ahead(name, kind, start)`, a level in % or None, as in `mode_runs()`;
for None (and for `ahead=True`) a charge is read from the floor and a give
from the ceiling (the most it could be). The leg itself is still the
gate's (`trade_legs()`). Home never passes it."""
floor, ceiling = arbitrage.soc_bounds(c.o)
if any(inv.soc is None for inv in c.inverters):
if not ahead and any(inv.soc is None for inv in c.inverters):
return [], [], "no_level"
level = [float(inv.soc) for inv in c.inverters]
level = [] if ahead else [float(inv.soc) for inv in c.inverters]
def start_pct(kind, start):
got = [ahead(inv.name, kind, start) if callable(ahead) else None
for inv in c.inverters]
return [(floor if kind == "grid" else ceiling) if v is None
else float(v) for v in got]
runs, later = [], []
for kind, pt in trade_legs(c, plan, verdict, now):
end = _point_end(plan, pt)
@@ -352,6 +366,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
if not (day0 <= pt.start < day1):
later.append((kind, pt))
continue
if ahead:
level = start_pct(kind, pt.start)
if kind == "give" and (pt.eur_per_kwh < 0 or min(level) <= floor):
continue
hrs = (end - pt.start).total_seconds() / 3600.0
@@ -366,7 +382,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
kwh = min(kwh, max(0.0, room) / 100.0 * cap)
# A later give may use what this charge put in: an upper
# bound, like every amount here ("up to").
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
if not ahead:
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
if kwh < MIN_KWH:
continue
eur = kwh * pt.eur_per_kwh
@@ -386,15 +403,22 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
def mode_runs(c, day0, day1, now, shadow):
def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
"""OPT-11 P3: each battery's price runs today, as the control cycle keeps
them (`c.mode_runs`): the cheap run on the batteries that may charge
from the grid, the give runs on Leftover solar and Cheap hours, and the
sell run on those with selling on. Amounts are upper bounds ("up to"),
from the battery's own room, rating and the run's length. UX-53: no grid
charge is drawn while the trade gate refuses one (`c.mode_gate`, what the
control cycle found). Returns `(runs, no_trade_key)`."""
rp = getattr(c, "mode_runs", None)
control cycle found). Returns `(runs, no_trade_key)`.
UX-54 `ahead`: the window has not started, so the level now says nothing
about it. The room and energy filter (`room()` and `plan_need()` below)
then reads the level a block starts at: `ahead(name, kind, start)`, a
function giving the expected level in % or None, or for None (and for
`ahead=True`) a charge from the floor and a give from the ceiling. The
same filter, the same `MIN_KWH`. `rp`: a run plan to read instead of
`c.mode_runs`. Home never passes either."""
rp = rp if rp is not None else getattr(c, "mode_runs", None)
if getattr(c, "mode_tick", None) is None:
return [], None # no cycle yet: nothing is known
if rp is None:
@@ -410,15 +434,23 @@ def mode_runs(c, day0, day1, now, shadow):
continue
soc, cap = inv.soc, inv.capacity_kwh
def room(kind, hrs):
def start_pct(kind, start):
"""The level a block of `kind` is read at when `ahead`."""
got = ahead(inv.name, kind, start) if callable(ahead) else None
return got if got is not None else (
floor if kind == "grid" else ceiling)
def room(kind, hrs, start):
kwh = inv.max_w / 1000.0 * hrs
if cap and soc is not None:
kwh = min(kwh, max(0.0, (ceiling - soc) if kind == "grid"
else (soc - floor)) / 100.0 * cap)
lvl = start_pct(kind, start) if ahead else soc
if cap and lvl is not None:
kwh = min(kwh, max(0.0, (ceiling - lvl) if kind == "grid"
else (lvl - floor)) / 100.0 * cap)
return kwh
blocks = []
# P4: with a day plan, the grid block is the planned shortfall.
need = plan_need(c, inv.name)
need = plan_need(c, inv.name, start_pct("grid", rp.cheap.start)
if ahead and rp.cheap is not None else None)
if gate is None and rp.charge_useful() and (
need > 0.0 if need is not None
else pm.mode in GRID_CHARGE_MODES):
@@ -433,16 +465,22 @@ def mode_runs(c, day0, day1, now, shadow):
if run.end <= now or not (run.start < day1 and run.end > day0):
continue
hrs = (run.end - run.start).total_seconds() / 3600.0
kwh = room(kind, hrs)
kwh = room(kind, hrs, run.start)
if kind == "grid" and need is not None:
kwh = min(kwh, need)
if kwh < MIN_KWH:
continue
price = run.mean_eur_kwh
if kind == "grid":
cov = plan_cover(c, inv.name)
what, why, amount = (run_grid_what(kwh, shadow),
run_cheap_why(price),
run_grid_amount(kwh * price))
if cov is not None:
# OPT-28: with a day plan the block says how far it
# charges and what that covers.
what = run_grid_what_to(kwh, cov[0], shadow)
why += " " + run_covers(cov[1])
elif kind == "sell":
# No money line: the price is what the house pays, and a sale
# earns the lower injection price (OPT-11 P3 review).
@@ -460,19 +498,39 @@ def mode_runs(c, day0, day1, now, shadow):
return out, None
def plan_need(c, name):
def plan_need(c, name, soc=None):
"""P4: kWh this battery buys from the grid by the day plan, or None when
there is no plan for it (then P3 option A applies)."""
there is no plan for it (then P3 option A applies). `soc`: UX-54, a level
to read it from instead of the one now (`mode_runs(ahead=True)`)."""
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
if not targets or name not in targets:
return None
inv = next((i for i in c.inverters if i.name == name), None)
if inv is None or inv.soc is None or not inv.capacity_kwh:
at = None if inv is None else (inv.soc if soc is None else soc)
if inv is None or at is None or not inv.capacity_kwh:
return None
return max(0.0, targets[name] - float(inv.soc)) / 100.0 * float(
return max(0.0, targets[name] - float(at)) / 100.0 * float(
inv.capacity_kwh)
def plan_cover(c, name):
"""OPT-28: `(level %, kWh)` for a battery the day plan charges from the
grid: the level it charges to, and the expensive-hour use of the house
that charge covers and the sun will not. Read from the level control
holds (`plan_targets()`), so Home says what control does. None when the
plan buys nothing for it."""
dp = getattr(c, "day_plan", None)
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
inv = next((i for i in c.inverters if i.name == name), None)
if dp is None or not targets or name not in targets or inv is None \
or not inv.capacity_kwh or targets[name] <= 0.0:
return None
floor, _ceil = arbitrage.soc_bounds(c.o)
cap = float(inv.capacity_kwh)
top = targets[name] + dp.share_kwh.get(name, 0.0) / cap * 100.0
return targets[name], max(0.0, top - floor) / 100.0 * cap * EFF
def plan_state(c):
"""P4: `"plan"` (a day plan is made), `"learning"` (no house profile
yet), `"no_forecast"` (set but missing or stale), or None (not set, or
@@ -507,6 +565,9 @@ def solar_block(c, now):
elif fc is not None:
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
out["updated"])
dp = getattr(c, "day_plan", None)
if st == "plan" and dp is not None and dp.uncovered_kwh >= MIN_KWH:
out["line"] += " " + line_uncovered(dp.uncovered_kwh)
return out
@@ -612,6 +673,20 @@ def region_of(opts):
if rid in REGION_PRESETS else None}
def runs_for(c, day0, day1, now, shadow, ahead=False, rp=None):
"""UX-54: the price blocks of the window `[day0, day1)`, by the same
builders `build_plan()` uses for today: the batteries' price runs with
modes on, the verdict's legs with them off. Nothing is decided here.
`ahead`: see `mode_runs()`; for a day that has not started."""
if getattr(c, "modes_on", False):
return mode_runs(c, day0, day1, now, shadow, ahead, rp)[0]
fresh = c.fresh_tariff_plan(now)
verdict, _why = trade_verdict(c, fresh, now)
if verdict is None:
return []
return trade_runs(c, fresh, verdict, day0, day1, now, shadow, ahead)[0]
def build_plan(c, now, shadow, running, held=False, settled=True):
day0, day1 = local_day(now)
fresh = c.fresh_tariff_plan(now)
@@ -816,7 +891,8 @@ def battery_rows(c, shadow, running, held=False):
# knows this cycle. Price and forecast reasons are OPT-11 P3/P4.
"why_key": why,
"why": why_words(why, name=cycling if why == "other_cycle"
else other, at=at),
else other, at=at,
**plan_words(c, inv.name, why)),
})
if modes_on:
# OPT-11 P1: the battery's switch and mode, for the Batteries
@@ -1001,6 +1077,16 @@ def state_of(c, level, shadow, waiting=False):
return "running"
@contextlib.contextmanager
def in_lang(lang):
"""UX-54: build sentences in `lang` for the length of a `with` block."""
_LANG[0] = uitext.lang_of(lang)
try:
yield
finally:
_LANG[0] = "en"
def build(c, *, level: str, checks: list, now: datetime | None = None,
lang: str = "en", held: bool | None = None,
waiting: bool = False) -> dict:
@@ -1103,6 +1189,10 @@ def _kwh(v) -> str:
return _dec(f"{float(v):.1f}") + " kWh"
def _pct(v) -> str:
return _dec(f"{float(v):.0f}") + " %"
# Row id -> the title on Home. The row's own sentence is the body, in the
# page's language (UX-46, `messages.pick`). Five titles are the notification's
# own title, which test_ui_text.py checks.
@@ -1235,11 +1325,18 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
return _t("now.resting")
def why_words(key, name=None, at="") -> str:
def why_words(key, name=None, at="", pct="", kwh="") -> str:
"""One battery's "why" line (UX-13 P3), from `battery_why()`'s key.
`name`: the battery that refuses, for `other_refuses`; `at`: the
give run's time, for `saves`."""
return _t("why." + key, name=name or "", at=at)
give run's time, for `saves`; `pct` and `kwh`: the planned level and
what it covers, for `tops_up` (OPT-28)."""
return _t("why." + key, name=name or "", at=at, pct=pct, kwh=kwh)
def plan_words(c, name, why) -> dict:
"""OPT-28: the figures of a `tops_up` line, or nothing."""
cov = plan_cover(c, name) if why == "tops_up" else None
return {} if cov is None else {"pct": _pct(cov[0]), "kwh": _kwh(cov[1])}
def today_words(key) -> str:
@@ -1261,6 +1358,16 @@ def run_grid_what(kwh, shadow) -> str:
return _w(shadow, "run.grid_what", kwh=_kwh(kwh))
def run_grid_what_to(kwh, pct, shadow) -> str:
"""OPT-28: a grid block of the day plan, with the level it charges to."""
return _w(shadow, "run.grid_what_to", kwh=_kwh(kwh), pct=_pct(pct))
def run_covers(kwh) -> str:
"""OPT-28: what a planned grid charge covers."""
return _t("run.covers", kwh=_kwh(kwh))
# The trade legs and the price line use the feed's own points (a 15-minute
# period on a quarter-hourly feed), so both name the same price and time.
def run_grid_why(price) -> str:
@@ -1329,10 +1436,21 @@ def run_house_amount() -> str:
return _t("run.house_amount")
def day_note(key, **values) -> str:
"""UX-54: one sentence of the Forecast tab (`home_words.day`)."""
return _t("day." + key, **values)
def line_solar(fc_kwh, count_kwh, at) -> str:
return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at)
def line_uncovered(kwh) -> str:
"""OPT-28: the Leftover solar batteries cannot hold the expensive hours'
shortfall. The rest comes from another battery or the grid."""
return _t("solar.uncovered", kwh=_kwh(kwh))
def line_no_trade(key) -> str:
return "" if key == "off" else _t("no_trade." + key)
@@ -1454,6 +1572,7 @@ def _samples() -> list:
for k in VERDICT:
out.append(("plan", plan_head(k, sh)))
out += [("plan", run_grid_what(12.34, sh)),
("plan", run_grid_what_to(12.34, 82, sh)),
("plan", run_give_what(9.6, sh)),
("plan", run_cycle_what(M_IDLE, sh)),
("plan", run_cycle_what(M_DRAIN, sh)),
@@ -1468,6 +1587,7 @@ def _samples() -> list:
("plan", control_text(True, sh)),
("plan", control_text(False, sh))]
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
("plan", run_covers(9.4)), ("plan", line_uncovered(2.5)),
("plan", run_give_why(0.3612)), ("plan", run_give_amount(3.47)),
("plan", run_cycle_why()), ("plan", run_cycle_why(True)),
("plan", run_cycle_amount(None)),
@@ -1498,10 +1618,14 @@ def _samples() -> list:
out.append(("plan", battery_now(62, w, phase=M_IDLE,
shadow=False, running=True,
off=off, peak=peak)))
out += [("plan", why_words(k, name="Battery A")) for k in words["why"]]
out += [("plan", why_words(k, name="Battery A", pct="82 %",
kwh="9.4 kWh")) for k in words["why"]]
out += [("plan", today_words(k)) for k in words["today"]]
out += [("plan", line_solar(9.2, 6.4, "10:15")),
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning"))]
out += [("plan", day_note(k, pct=70, name="Battery A", at="08:15",
kwh="9.2 kWh", count="6.4 kWh"))
for k in words["day"]]
out += [("plan", line_no_trade(k)) for k in
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
"no_level")]
+25 -1
View File
@@ -64,7 +64,8 @@ from .clamp import (
shortfall_note, write_clamp_note,
)
from . import freshness
from . import homeview
from . import dayview, homeview
from .daycurve import SolarToday
from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
@@ -1261,6 +1262,9 @@ class Controller:
# UX-13 P1: today's prices as the Home chart has seen them. Display
# only; nothing reads it back into a decision.
self.home_prices = homeview.DayPrices()
# UX-54: the site's measured solar per hour, for the Forecast tab.
# In memory, display only; nothing reads it back into a decision.
self.solar_today = SolarToday()
# TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at`
# is the exact, derived statement of "the latest cycle did not get as
# far as the arbitrage step", which is the fact the *Arbitrage* row
@@ -3381,6 +3385,11 @@ class Controller:
*(self.hass.state(entity) for entity in roster))
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
list(zip(roster, states)), pv_read.inverted(self.o))
# UX-54: kept for the Forecast tab only. It must never cost a cycle.
try:
self.solar_today.add(datetime.now(timezone.utc), self.pv_w)
except Exception: # noqa: BLE001
_LOG.debug("measured solar not kept", exc_info=True)
def _peak_fc_unit_log(self, fault) -> None:
"""SAFETY-118. One WARNING when the peak forecast's unit cannot be read
@@ -7635,6 +7644,21 @@ class Controller:
out["home_error"] = True
return out
def forecast_payload(self, lang: str = "en") -> dict:
"""UX-54: the Forecast tab's data. Built on request, from what the
control cycle already holds; no Home Assistant call. Today's blocks are
Home's own, so `status_payload()` is built first and read."""
try:
return dayview.build(self, self.status_payload(lang=lang),
datetime.now(timezone.utc), lang)
except Exception: # noqa: BLE001
if not getattr(self, "_forecast_failed", False):
self._forecast_failed = True
_LOG.exception("The Forecast tab could not be built")
else:
_LOG.debug("The Forecast tab failed again", exc_info=True)
return {"ok": False}
async def handle_action(self, what: str, *, unit=None, value=None):
now = datetime.now(timezone.utc)
if what in ("pack_use", "pack_mode", "pack_sell"):
+17 -5
View File
@@ -76,12 +76,20 @@ class Forecast:
tomorrow: Day | None
at: datetime # when the sensor last reported
def between(self, t0: datetime, t1: datetime) -> float | None:
def between(self, t0: datetime, t1: datetime,
since: datetime | None = None) -> float | None:
"""kWh the sun should give between `t0` (now or later) and `t1`.
None when the span reaches the sunny hours of a day with no
forecast (tomorrow, when its sensor is not set)."""
forecast (tomorrow, when its sensor is not set).
`since` is the moment a state-only "left today" figure counts from
(OPT-23): `t0` itself when not given, so a span that starts later
than now would be spread over what is left of the bell from its own
start. Pass now for every span of a plan, and the spans add up to
the figure. An hourly split ignores it."""
if t1 <= t0:
return 0.0
since = t0 if since is None else since
last = self.tomorrow or self.today
if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H):
return None
@@ -92,7 +100,7 @@ class Forecast:
a = max(t0, day.day0)
b = min(t1, day.day0 + timedelta(days=1))
if b > a:
total += _day_between(day, a, b, t0)
total += _day_between(day, a, b, since)
return total
@@ -124,8 +132,12 @@ def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float:
out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds()
return out
# State only: `kwh` is what is left from `max(now, day0)` to the day's
# end, spread over the bell.
h_from = _hours_since(day.day0, max(now, day.day0))
# end, spread over the bell. Nothing of it is before that moment.
start = max(now, day.day0)
a = max(a, start)
if b <= a:
return 0.0
h_from = _hours_since(day.day0, start)
left = 1.0 - _bell(h_from)
if left <= 1e-9:
return 0.0
+26
View File
@@ -246,3 +246,29 @@ select.sel:disabled{opacity:.6}
.simple .card .lbl{font-weight:600;font-size:15px}
.simple .inv .hd{border-bottom:0}
.simple .inv .f{border-top:0;padding-top:0}
/* UX-54: the Forecast screen */
.fcline{text-align:right}
.fcnote{background:var(--info-bg);border-radius:10px;padding:8px 10px;margin:0 0 8px}
.fcbars{display:flex;align-items:flex-end;gap:2px;height:110px}
.fcol{flex:1;position:relative;height:100%;min-width:0}
.fcol i{position:absolute;display:block}
.fcol .sun{bottom:0;left:0;right:0;background:var(--solar);border-radius:3px 3px 0 0;opacity:.85}
.fcol .sun.est{opacity:.5}
.fcol .got{bottom:0;left:30%;right:30%;background:repeating-linear-gradient(135deg,var(--ink) 0 3px,var(--card) 3px 5px);border:1px solid var(--ink);border-bottom:0}
.fcol .use{left:-1px;right:-1px;height:3px;background:var(--house)}
.fcols{position:absolute;inset:0;display:flex;gap:0}
.fcols>i{flex:1;min-width:0;display:block}
.lvlchart{position:relative;height:110px;margin:18px 0 4px}
.lvlchart svg{position:absolute;inset:0;width:100%;height:100%;display:block}
.lvlchart .yl{position:absolute;right:2px;font-size:10px;color:var(--faint);pointer-events:none}
.fcnow{position:absolute;top:-4px;bottom:0;width:2px;background:var(--ink);opacity:.7;pointer-events:none}
.fcnow span{position:absolute;top:-15px;left:-10px;font-size:10px}
.legend i.sun{background:var(--solar)}
.legend i.got{background:repeating-linear-gradient(135deg,var(--ink) 0 2px,var(--card) 2px 4px);border:1px solid var(--ink)}
.legend i.use{height:3px;background:var(--house)}
.legend i.lv{height:3px;background:var(--batt)}
table.fct{width:100%;border-collapse:collapse;font-size:12px;font-variant-numeric:tabular-nums}
table.fct th,table.fct td{padding:4px 3px;text-align:right;border-bottom:1px solid var(--line)}
table.fct th:first-child,table.fct td:first-child{text-align:left}
table.fct th{color:var(--muted);font-weight:600}
.ymax{position:absolute;top:-2px;left:2px;font-size:10px;color:var(--faint);pointer-events:none}
+173 -2
View File
@@ -448,6 +448,175 @@
});
}
/* ---------------- Forecast (UX-54) ---------------- */
// Today and tomorrow, per hour: the solar forecast and what was measured,
// the house's expected use, and each battery's expected level with its
// blocks. The data is built by app/dayview.py when the tab asks (GET
// forecast), the sentences in the page's language. A part that is not
// known yet comes as a note, so no chart is ever drawn empty.
// The SVG namespace, read off the page's own icons: the page names no URL.
const SVGNS = document.querySelector("svg").namespaceURI;
function sv(tag, attrs) {
const e = document.createElementNS(SVGNS, tag);
for (const k in attrs || {}) e.setAttribute(k, attrs[k]);
return e;
}
let FC = null;
let fcDay = 0;
let fcBusy = false;
let fcTimer = null;
const fmtKwh = (x) => dec(Number(x).toFixed(1));
const hoursOf = (d) => d.length_h || d.hours.length || 24;
function endLabel(d, i) { return i + 1 < d.hours.length ? d.hours[i + 1].label : "24:00"; }
function hourTip(d, i) {
const x = d.hours[i];
const lines = [];
if (x.forecast_kwh != null) lines.push(t("forecast.forecast") + ": " + fmtKwh(x.forecast_kwh) + " kWh");
if (x.measured_kwh != null) lines.push(t("forecast.measured") + ": " + fmtKwh(x.measured_kwh) + " kWh");
if (x.house_kwh != null) lines.push(t("forecast.house") + ": " + fmtKwh(x.house_kwh) + " kWh");
d.batteries.forEach((b, j) => { if (x.levels[j] != null) lines.push(b.name + ": " + dec(Math.round(x.levels[j]).toString()) + " %"); });
return [x.label + " " + t("plan.time_to") + " " + endLabel(d, i)].concat(lines);
}
function hourItem(tag, cls, d, i, kids) {
const tip = hourTip(d, i);
const e = h(tag, { class: cls, tabindex: "-1", role: "img", "aria-label": tip.join(". "), "data-tip": JSON.stringify(tip) }, kids);
return e;
}
function renderSun(d) {
const card = $("#fc-sun");
const any = d.sun.forecast || d.sun.measured || d.sun.house;
card.hidden = !any;
if (!any) return;
const mx = Math.max(0.5, ...d.hours.map((x) => Math.max(x.forecast_kwh || 0, x.measured_kwh || 0, x.house_kwh || 0)));
const pct = (v) => Math.max(0, Math.min(100, v / mx * 100)) + "%";
const bars = $("#fc-bars");
bars.setAttribute("aria-label", t("forecast.sun"));
bars.replaceChildren(...d.hours.map((x, i) => hourItem("div", "fcol", d, i, [
x.forecast_kwh != null ? h("i", { class: "sun" + (d.sun.estimate ? " est" : ""), style: "height:" + pct(x.forecast_kwh) }) : null,
x.measured_kwh != null ? h("i", { class: "got", style: "height:" + pct(x.measured_kwh) }) : null,
x.house_kwh != null ? h("i", { class: "use", style: "bottom:calc(" + pct(x.house_kwh) + " - 1px)" }) : null])));
roving(bars);
const chart = $("#fc-sunchart");
chart.querySelectorAll(".fcnow, .ymax").forEach((e) => e.remove());
chart.append(h("span", { class: "ymax", "aria-hidden": "true" }, fmtKwh(mx)));
if (fcDay === 0) chart.append(nowMark(d));
const used = [["sun", d.sun.forecast, "forecast"], ["got", d.sun.measured, "measured"], ["use", d.sun.house, "house"]];
$("#fc-legend").replaceChildren(...used.filter((u) => u[1]).map((u) => h("span", {}, h("i", { class: u[0], "aria-hidden": "true" }), t("forecast." + u[2]))));
}
function nowMark(d) {
return h("div", { class: "fcnow", "aria-hidden": "true", style: "left:" + Math.max(0, Math.min(100, FC.now_h / hoursOf(d) * 100)) + "%" }, h("span", {}, t("plan.now")));
}
function levelChart(d, b, bi) {
const L = hoursOf(d), W = L * 10;
const x = (hr) => hr * 10;
const box = h("div", { class: "chart" });
const area = h("div", { class: "lvlchart" });
const svg = sv("svg", { viewBox: "0 0 " + W + " 100", preserveAspectRatio: "none", "aria-hidden": "true", focusable: "false" });
[0, 50, 100].forEach((p) => svg.append(sv("line", { x1: 0, x2: W, y1: 100 - p, y2: 100 - p, stroke: "var(--line)", "stroke-width": 1, "vector-effect": "non-scaling-stroke" })));
const fill = { grid: "var(--accent)", give: "var(--house)", cycle: "var(--warn)" };
for (const r of b.blocks) {
if (r.end_h <= r.start_h) continue;
svg.append(sv("rect", { x: x(r.start_h), y: 0, width: x(r.end_h) - x(r.start_h), height: 100, fill: fill[r.kind] || "var(--mid)", opacity: 0.18 }));
}
const pts = b.levels.map((p) => x(p[0]) + "," + (100 - p[1]));
svg.append(sv("polygon", { points: x(b.levels[0][0]) + ",100 " + pts.join(" ") + " " + x(b.levels[b.levels.length - 1][0]) + ",100", fill: "var(--batt)", opacity: 0.12 }));
svg.append(sv("polyline", { points: pts.join(" "), fill: "none", stroke: "var(--batt)", "stroke-width": 2.5, "stroke-linejoin": "round", "vector-effect": "non-scaling-stroke" }));
area.append(svg);
[["100", 0], ["50", 50], ["0", 100]].forEach((y) => area.append(h("span", { class: "yl", "aria-hidden": "true", style: "top:" + y[1] + "%;transform:translateY(" + (y[1] === 100 ? "-100%" : y[1] === 0 ? "0" : "-50%") + ")" }, y[0] + " %")));
area.append(h("div", { class: "fcols", role: "group", "aria-label": b.name }, ...d.hours.map((hx, i) => hourItem("i", "", d, i, null))));
roving(area.querySelector(".fcols"));
if (fcDay === 0) area.append(nowMark(d));
box.append(area);
// The blocks under the chart, drawn as Home draws them.
if (b.blocks.length) {
const lane = h("div", { class: "lane", role: "group", "aria-label": b.name });
for (const r of b.blocks) {
if (r.end_h <= r.start_h) continue;
lane.append(chartItem("b", r.kind, "left:" + (r.start_h / L * 100) + "%;width:" + ((r.end_h - r.start_h) / L * 100) + "%",
b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount]));
}
box.append(lane);
roving(lane);
}
box.append(h("div", { class: "axis", "aria-hidden": "true" }, h("span", {}, "00:00"), h("span", {}, "06:00"), h("span", {}, "12:00"), h("span", {}, "18:00"), h("span", {}, "24:00")));
return box;
}
function batteryCardFc(d, b, bi) {
const drawn = b.levels.length > 1;
const list = b.blocks.map((r) => h("li", {}, h("strong", {}, r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount])));
return h("div", { class: "card" },
h("div", { class: "row spread" }, h("h2", {}, b.name),
h("span", { class: "tiny muted" }, (b.capacity_kwh ? fmtKwh(b.capacity_kwh) + " kWh · " : "") + t("forecast.levels"))),
drawn ? levelChart(d, b, bi) : null,
list.length ? h("ul", { class: "plain small" }, ...list) : null,
b.line ? h("p", { class: "small muted", style: "margin:6px 0 0" }, b.line) : null);
}
function renderTable(d) {
const more = $("#fc-more");
const any = d.sun.forecast || d.sun.measured || d.sun.house;
more.hidden = !any;
if (!any) return;
const lv = d.batteries.filter((b) => b.levels.length > 1);
const head = h("tr", {}, h("th", { scope: "col" }, t("forecast.hour")),
d.sun.forecast ? h("th", { scope: "col" }, t("forecast.solar")) : null,
d.sun.measured ? h("th", { scope: "col" }, t("forecast.got")) : null,
d.sun.house ? h("th", { scope: "col" }, t("forecast.use")) : null,
...lv.map((b) => h("th", { scope: "col" }, b.name + " %")));
const cell = (v, f) => h("td", {}, v == null ? "–" : f(v));
const rows = d.hours.map((x) => h("tr", {},
h("td", {}, x.label),
d.sun.forecast ? cell(x.forecast_kwh, fmtKwh) : null,
d.sun.measured ? cell(x.measured_kwh, fmtKwh) : null,
d.sun.house ? cell(x.house_kwh, fmtKwh) : null,
...lv.map((b) => cell(x.levels[d.batteries.indexOf(b)], (v) => dec(Math.round(v).toString())))));
$("#fc-table").replaceChildren(h("thead", {}, head), h("tbody", {}, ...rows));
}
function renderForecast() {
const loading = $("#fc-loading");
if (!FC || FC.ok !== true) {
loading.hidden = false; loading.textContent = FC ? t("offline.name") : t("forecast.loading");
$("#fc-notes").replaceChildren(); $("#fc-sun").hidden = true; $("#fc-batts").replaceChildren(); $("#fc-more").hidden = true; $("#fc-line").textContent = "";
return;
}
loading.hidden = true;
const d = FC.days[fcDay];
$("#fc-line").textContent = d.line || "";
$("#fc-notes").replaceChildren(...d.notes.map((n) => h("p", { class: "fcnote small" }, n)));
renderSun(d);
$("#fc-batts").replaceChildren(...d.batteries.map((b, i) => batteryCardFc(d, b, i)));
renderTable(d);
document.querySelectorAll("#fc-day button").forEach((b) => {
const on = Number(b.dataset.d) === fcDay;
b.setAttribute("aria-checked", String(on)); b.tabIndex = on ? 0 : -1;
});
}
async function loadForecast() {
if (fcBusy) return;
fcBusy = true;
try {
const r = await fetch("forecast?lang=" + LANG, { cache: "no-store" });
if (!r.ok) throw new Error("forecast " + r.status);
FC = await r.json();
} catch (e) { FC = { ok: false }; }
finally { fcBusy = false; }
renderForecast();
}
document.querySelectorAll("#fc-day button").forEach((b, i, all) => {
b.addEventListener("click", () => { fcDay = Number(b.dataset.d); renderForecast(); });
b.addEventListener("keydown", (e) => {
const j = { ArrowRight: i + 1, ArrowLeft: i - 1 + all.length }[e.key];
if (j === undefined) return;
e.preventDefault(); fcDay = j % all.length; renderForecast(); all[fcDay].focus();
});
});
// Every 30 s while the screen shows: the data moves slowly, and each load
// is built on request.
function scheduleForecast() {
const on = !document.hidden && $("#t-fc").getAttribute("aria-selected") === "true";
if (on && !fcTimer) { loadForecast(); fcTimer = setInterval(loadForecast, 30000); }
if (!on && fcTimer) { clearInterval(fcTimer); fcTimer = null; }
}
/* ---------------- control buttons ---------------- */
// The action is a toggle, so the page reads the live state first and sends
// nothing when control is already where the person asked it to be.
@@ -511,11 +680,13 @@
}
addEventListener("hashchange", fromHash);
/* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */
/* ---------------- polling: every 3 s while Home, Batteries or Forecast shows ---------------- */
let timer = null;
function schedule() {
scheduleForecast();
const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" ||
$("#t-batt").getAttribute("aria-selected") === "true");
$("#t-batt").getAttribute("aria-selected") === "true" ||
$("#t-fc").getAttribute("aria-selected") === "true");
if (live && !timer) { refresh(); timer = setInterval(refresh, 3000); }
if (!live && timer) { clearInterval(timer); timer = null; }
}
+27
View File
@@ -18,6 +18,7 @@
<nav class="tabs" role="tablist" id="tabs" data-t-label="tabs.label">
<button class="tab" role="tab" id="t-home" aria-controls="s-home" aria-selected="true" data-hash="home"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg><span data-t="tabs.home"></span><span class="badge" id="attn-badge" hidden></span></button>
<button class="tab" role="tab" id="t-batt" aria-controls="s-batt" aria-selected="false" data-hash="batteries" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2"/><rect x="5.5" y="9.5" width="7" height="5" fill="currentColor" stroke="none"/></svg><span data-t="tabs.batteries"></span></button>
<button class="tab" role="tab" id="t-fc" aria-controls="s-fc" aria-selected="false" data-hash="forecast" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 20h18M4 16l5-6 4 4 7-9"/></svg><span data-t="tabs.forecast"></span></button>
<button class="tab" role="tab" id="t-set" aria-controls="s-set" aria-selected="false" data-hash="settings" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg><span data-t="tabs.settings"></span></button>
<button class="tab" role="tab" id="t-wiz" aria-controls="s-wiz" aria-selected="false" data-hash="setup" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 7l3 3M5 19l9.5-9.5 3 3L8 22H5z"/><path d="M19 2v4M17 4h4"/></svg><span data-t="tabs.setup"></span></button>
</nav>
@@ -114,6 +115,32 @@
</details>
</section>
<section class="screen" id="s-fc" role="tabpanel" aria-labelledby="t-fc" hidden>
<div class="row spread" style="margin-bottom:8px">
<div class="seg" role="radiogroup" id="fc-day" data-t-label="forecast.day">
<button type="button" role="radio" aria-checked="true" data-d="0" data-t="forecast.today"></button>
<button type="button" role="radio" aria-checked="false" data-d="1" tabindex="-1" data-t="forecast.tomorrow"></button>
</div>
<span class="tiny muted grow fcline" id="fc-line"></span>
</div>
<div id="fc-notes" aria-live="polite"></div>
<p class="small muted" id="fc-loading" data-t="forecast.loading"></p>
<div class="card" id="fc-sun" hidden>
<div class="row spread"><h2 data-t="forecast.sun"></h2><span class="tiny muted" data-t="forecast.unit"></span></div>
<div class="chart" id="fc-sunchart">
<div class="fcbars" id="fc-bars" role="group"></div>
<div class="axis" aria-hidden="true"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
</div>
<div class="legend" id="fc-legend"></div>
<p class="tiny muted" style="margin:6px 0 0" data-t="plan.description"></p>
</div>
<div id="fc-batts"></div>
<details class="grp" id="fc-more" hidden>
<summary><span class="grow"><strong data-t="forecast.table"></strong></span><span class="chev" aria-hidden="true">&#8250;</span></summary>
<div class="body"><table class="fct" id="fc-table"></table></div>
</details>
</section>
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
<div class="row spread" style="margin-bottom:6px">
<h2 style="margin:0" data-s="name"></h2>
+46 -9
View File
@@ -45,7 +45,7 @@
},
"home": {
"app": {"name": "Battery add-on"},
"tabs": {"home": "Home", "batteries": "Batteries", "settings": "Settings", "setup": "Setup", "label": "Screens"},
"tabs": {"home": "Home", "batteries": "Batteries", "forecast": "Forecast", "settings": "Settings", "setup": "Setup", "label": "Screens"},
"now": {"name": "Now", "description": "Updated every 3 seconds.", "solar": "Solar", "house": "House", "grid": "Grid", "batteries": "Batteries"},
"plan": {
"name": "Today's plan",
@@ -68,6 +68,24 @@
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
"list": "The plan in words"
},
"forecast": {
"name": "Forecast",
"day": "Day",
"today": "Today",
"tomorrow": "Tomorrow",
"sun": "Solar and house",
"unit": "kWh per hour",
"forecast": "Solar forecast",
"measured": "Measured solar",
"house": "House use",
"levels": "Battery level",
"table": "Hour by hour",
"hour": "Hour",
"solar": "Solar",
"got": "Measured",
"use": "House",
"loading": "Loading the forecast."
},
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
"control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"},
@@ -85,18 +103,18 @@
"batteries": {"name": "Batteries", "description": "To choose a mode for each battery, turn on battery modes in the settings.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings",
"on": "On", "off": "Off",
"choice": {
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."},
"leftover_solar": {"name": "Leftover solar", "description": "Gets solar the others cannot take, and gives power in expensive hours."},
"cheap_hours": {"name": "Cheap hours only", "description": "Charges in the cheapest hours and gives power in the expensive ones."},
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries, covers the house and never charges from the grid."},
"leftover_solar": {"name": "Leftover solar", "description": "Gets the solar the others cannot take, buys from the grid only what the sun will not give, and gives power in expensive hours."},
"cheap_hours": {"name": "Cheap hours only", "description": "Fills up from the grid in the cheapest hours and gives power in the expensive ones."},
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
},
"today": "Today", "why": "Why",
"cycle": {"start": "Start cycle", "abort": "Stop cycle", "start_dialog": {"name": "Start a cycle on {name}?", "description": "{name} empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"}, "abort_dialog": {"name": "Stop the cycle on {name}?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}, "toast": {"started": "Cycle started on {name}.", "stopped": "Cycle stopped on {name}."}, "unit": {"description": "There is no battery with that name."}, "busy": {"description": "Only one battery can cycle at a time, and {name} is cycling now."}, "off": {"description": "{name} is switched off, so it cannot start a cycle."}, "idle": {"description": "{name} is not running a cycle."}},
"modes_off": {"name": "Battery modes are off"},
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
"leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, and saves its power for expensive hours."},
"cheap_hours": {"description": "Cheap hours only charges in the cheapest hours and gives power in the expensive ones."},
"solar_first": {"description": "Solar first gets spare solar before the other batteries, covers the house at any hour and never charges from the grid."},
"leftover_solar": {"description": "Leftover solar gets the solar Solar first cannot take, buys only what the sun will not give, and saves its power for expensive hours."},
"cheap_hours": {"description": "Cheap hours only fills up from the grid in the cheapest hours and gives power in the expensive ones."},
"off": {"description": "Off rests the battery and starts no monthly cycle, but the peak limit may still use it."},
"rules": {"description": "The peak limit and the safety stops come first, and while one battery charges from the grid the others rest."}},
"toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}."}},
@@ -339,10 +357,10 @@
"no_prices": "No prices, so it follows the house.",
"no_spread": "Prices differ too little, so it follows the house.",
"sun_fills": "Enough sun is forecast, so it does not charge from the grid.",
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
"tops_up": "It charges from the grid to {pct}. That covers {kwh} of expensive hours the sun will not.",
"no_forecast": "No solar forecast, so it charges from solar only."
},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"today": {
"not_planned": "Not planned yet.",
"no_runs": "No cheap or expensive hours left today.",
@@ -367,6 +385,9 @@
"run": {
"grid_what": "Charges up to {kwh} from the grid.",
"grid_what_would": "Would charge up to {kwh} from the grid.",
"grid_what_to": "Charges up to {kwh} from the grid, to {pct}.",
"grid_what_to_would": "Would charge up to {kwh} from the grid, to {pct}.",
"covers": "It covers {kwh} of expensive hours the sun will not.",
"grid_why": "Lowest price left, {eur} per kWh.",
"grid_amount": "Costs up to {eur}.",
"give_what": "Gives up to {kwh}, to the house first.",
@@ -425,6 +446,22 @@
"known": "Tomorrow's prices are in.",
"unknown": "Tomorrow's prices usually arrive after 13:00."
},
"day": {
"modes_off": "Battery modes are off. No forecast is read and no level is planned.",
"not_set": "No solar forecast is set up. Without one, no battery level is planned.",
"no_forecast": "The solar forecast is missing or too old. The levels return when it is back.",
"learning": "The add-on is learning your house's use. The levels start after one full day.",
"no_tomorrow": "There is no solar forecast for tomorrow yet. The levels stop at {at}.",
"estimate": "The forecast gives one total for the day. The add-on spreads it over the hours.",
"trust": "The levels count on {pct} % of the forecast.",
"cycle": "{name} does its monthly cycle. Its level is not shown once the cycle starts.",
"no_level": "{name} has no level reading yet. Its level is not shown.",
"measured_from": "Measured solar is counted from {at}, when the add-on started.",
"tomorrow_total": "Solar expected tomorrow: {kwh}, we count on {count}.",
"no_blocks": "No charge or give blocks planned.",
"give_later": "The add-on plans to give power. No block shows while a battery has no energy to give.",
"ahead": "The add-on plans about a day ahead. Blocks for later hours appear as they come near."
},
"region": {
"preset": "Levies and VAT: {region}, {year} figures.",
"own": "Levies and VAT: your own figure."
+46 -9
View File
@@ -42,7 +42,7 @@
"applies": {"restart": {"name": "Geldt na een herstart"}, "now": {"name": "Geldt meteen"}},
"home": {
"app": {"name": "Batterij-add-on"},
"tabs": {"home": "Start", "batteries": "Batterijen", "settings": "Instellingen", "setup": "Installatie", "label": "Schermen"},
"tabs": {"home": "Start", "batteries": "Batterijen", "forecast": "Prognose", "settings": "Instellingen", "setup": "Installatie", "label": "Schermen"},
"now": {"name": "Nu", "description": "Elke 3 seconden bijgewerkt.", "solar": "Zonnepanelen", "house": "Huis", "grid": "Net", "batteries": "Batterijen"},
"plan": {
"name": "Plan voor vandaag",
@@ -65,6 +65,24 @@
"no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."},
"list": "Het plan in woorden"
},
"forecast": {
"name": "Prognose",
"day": "Dag",
"today": "Vandaag",
"tomorrow": "Morgen",
"sun": "Zon en huis",
"unit": "kWh per uur",
"forecast": "Zonneprognose",
"measured": "Gemeten zon",
"house": "Huisverbruik",
"levels": "Laadniveau",
"table": "Uur per uur",
"hour": "Uur",
"solar": "Zon",
"got": "Gemeten",
"use": "Huis",
"loading": "De prognose wordt geladen."
},
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
"control": {"name": "Regeling", "stop": "Regeling stoppen", "start": "Regeling starten", "maint_start": "Nu een batterijcyclus starten", "maint_abort": "Batterijcyclus stoppen", "docs": "Documentatie van de add-on", "more": "Meer acties"},
@@ -82,18 +100,18 @@
"batteries": {"name": "Batterijen", "description": "Zet batterijmodi aan in de instellingen om per batterij een modus te kiezen.", "level": "Laadniveau", "now": "Nu", "stored": "Opgeslagen", "mode": "Modus", "settings": "Instellingen van omvormers wijzigen",
"on": "Aan", "off": "Uit",
"choice": {
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."},
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Krijgt zonne-energie die de andere niet opnemen, en levert in dure uren."},
"cheap_hours": {"name": "Alleen goedkope uren", "description": "Laadt in de goedkoopste uren en levert in de dure uren."},
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen, dekt het huis en laadt nooit van het net."},
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Krijgt zonne-energie die de andere niet opnemen, koopt van het net alleen wat de zon niet geeft, en levert in dure uren."},
"cheap_hours": {"name": "Alleen goedkope uren", "description": "Vult zich vol van het net in de goedkoopste uren en levert in de dure uren."},
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
},
"today": "Vandaag", "why": "Waarom",
"cycle": {"start": "Cyclus starten", "abort": "Cyclus stoppen", "start_dialog": {"name": "Cyclus starten op {name}?", "description": "{name} loopt leeg, laadt weer op en blijft vol, wat enkele uren duurt.", "yes": "Cyclus starten"}, "abort_dialog": {"name": "Cyclus stoppen op {name}?", "description": "Elke omvormer gaat even naar 0 W, daarna gaat de regeling verder.", "yes": "Cyclus stoppen"}, "toast": {"started": "Cyclus gestart op {name}.", "stopped": "Cyclus gestopt op {name}."}, "unit": {"description": "Er is geen batterij met die naam."}, "busy": {"description": "Er kan maar één batterij tegelijk een cyclus doen, en {name} is nu bezig."}, "off": {"description": "{name} staat uit en kan dus geen cyclus starten."}, "idle": {"description": "{name} doet nu geen cyclus."}},
"modes_off": {"name": "Batterijmodi staan uit"},
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat de batterijen met Eerst zonne-energie niet opnemen, en spaart haar stroom voor dure uren."},
"cheap_hours": {"description": "Alleen goedkope uren laadt in de goedkoopste uren en levert in de dure uren."},
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, dekt het huis op elk uur en laadt nooit van het net."},
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat Eerst zonne-energie niet opneemt, koopt alleen wat de zon niet geeft, en spaart haar stroom voor dure uren."},
"cheap_hours": {"description": "Alleen goedkope uren vult zich vol van het net in de goedkoopste uren en levert in de dure uren."},
"off": {"description": "Uit laat de batterij rusten en start geen maandelijkse cyclus, maar de piekgrens mag haar nog gebruiken."},
"rules": {"description": "De piekgrens en de veiligheidsstops gaan voor, en terwijl een batterij van het net laadt rusten de andere."}},
"toast": {"on": "{name} staat aan.", "off": "{name} staat uit.", "mode": "{name}: {mode}."}},
@@ -279,10 +297,10 @@
"no_prices": "Geen prijzen, dus ze volgt het huis.",
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis.",
"sun_fills": "Er is genoeg zon voorspeld, dus ze laadt niet van het net.",
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
"tops_up": "Ze laadt van het net tot {pct}. Dat dekt {kwh} dat de zon in dure uren niet levert.",
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"today": {
"not_planned": "Nog niet gepland.",
"no_runs": "Vandaag geen goedkope of dure uren meer.",
@@ -307,6 +325,9 @@
"run": {
"grid_what": "Laadt tot {kwh} van het net.",
"grid_what_would": "Zou tot {kwh} van het net laden.",
"grid_what_to": "Laadt maximaal {kwh} van het net, tot {pct}.",
"grid_what_to_would": "Zou maximaal {kwh} van het net laden, tot {pct}.",
"covers": "Dat dekt {kwh} dat de zon in dure uren niet levert.",
"grid_why": "Laagste prijs die nog over is, {eur} per kWh.",
"grid_amount": "Kost tot {eur}.",
"give_what": "Levert tot {kwh}, eerst aan het huis.",
@@ -353,6 +374,22 @@
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
"day": {
"modes_off": "Batterijmodi staan uit. Er wordt geen prognose gelezen en geen niveau gepland.",
"not_set": "Er is geen zonneprognose ingesteld. Zonder prognose wordt geen laadniveau gepland.",
"no_forecast": "De zonneprognose ontbreekt of is te oud. De niveaus komen terug als ze er weer is.",
"learning": "De add-on leert het verbruik van uw huis. De niveaus starten na één volle dag.",
"no_tomorrow": "Er is nog geen zonneprognose voor morgen. De niveaus stoppen om {at}.",
"estimate": "De prognose geeft één totaal voor de dag. De add-on verdeelt het over de uren.",
"trust": "De niveaus rekenen op {pct} % van de prognose.",
"cycle": "{name} doet de maandelijkse cyclus. Het niveau wordt niet getoond zodra de cyclus start.",
"no_level": "{name} geeft nog geen laadniveau door. Het niveau wordt niet getoond.",
"measured_from": "Gemeten zon telt vanaf {at}, toen de add-on startte.",
"tomorrow_total": "Verwachte zon morgen: {kwh}, we rekenen op {count}.",
"no_blocks": "Geen laad- of leverblokken gepland.",
"give_later": "De add-on plant stroom te leveren. Er verschijnt geen blok zolang een batterij niets kan leveren.",
"ahead": "De add-on plant ongeveer een dag vooruit. Blokken voor latere uren verschijnen als ze naderen."
},
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
+7
View File
@@ -150,6 +150,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
return web.json_response(controller.status_payload())
return web.json_response(controller.status_payload(lang=lang))
async def forecast(request):
"""UX-54: the Forecast tab's data, built when the tab asks. Reads only."""
build = getattr(controller, "forecast_payload", None)
lang = uitext.lang_of(request.query.get("lang"))
return web.json_response(build(lang=lang) if build else {"ok": False})
async def action(request):
try:
body = await request.json()
@@ -471,6 +477,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
app.add_routes([
web.get("/", index),
web.get("/status", status),
web.get("/forecast", forecast),
web.post("/action", action),
web.get("/config", config_index),
web.get("/config/model", config_model),
+5 -3
View File
@@ -831,10 +831,12 @@ check("the page is assembled: no placeholder is left and the words parse, "
"/*APP_CSS*/" not in PAGE and "/*APP_JS*/" not in PAGE
and "/*UI_TEXT*/" not in PAGE
and _WORDS["en"]["home"] == UI and set(_WORDS) == {"en", "nl"})
check("four tabs with the four hash routes, in a keyboard tablist",
check("five tabs with the five hash routes, in a keyboard tablist (UX-54 "
"adds Forecast beside Home and Batteries)",
re.findall(r'data-hash="(\w+)"', PAGE) == ["home", "batteries",
"settings", "setup"]
and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 4
"forecast", "settings",
"setup"]
and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 5
and "ArrowRight" in JS and "ArrowLeft" in JS)
_ext = re.findall(r"""(?:https?:)?//[A-Za-z0-9.-]+\.[a-z]{2,}""", PAGE)
check(f"no external file and no web font: no URL leaves the add-on "
+6 -4
View File
@@ -719,8 +719,10 @@ check("the forecast line names the forecast, what is counted and when",
and "we count on" in sb["line"] and sb["state"] == "plan")
why = homeview.day_key(c, B)
check("B's Why key (plan, dark) is tops_up", why == "tops_up")
check("...and says so in plain words", homeview.why_words(why)
== "Too little sun is forecast, so it charges some from the grid.")
check("...and says so in plain words, with the level and what it covers "
"(OPT-28)", homeview.why_words(why, pct="82 %", kwh="9.4 kWh")
== "It charges from the grid to 82 %. That covers 9.4 kWh of "
"expensive hours the sun will not.")
_d0, _d1 = homeview.local_day(c.solar_fc.at)
_blocks, _ = homeview.mode_runs(c, _d0, _d1, c.solar_fc.at, False)
_grid = [b for b in _blocks if b["kind"] == "grid" and b["battery"] == 1]
@@ -754,8 +756,8 @@ check("the Why line wins over 'saves its power' only while holding",
== "grid_charge")
for lang in ("en", "nl"):
homeview._LANG[0] = lang
t = [homeview.why_words(k) for k in ("sun_fills", "tops_up",
"no_forecast")]
t = [homeview.why_words(k, pct="82 %", kwh="9.4 kWh")
for k in ("sun_fills", "tops_up", "no_forecast")]
t += [homeview._t("solar.no_forecast"), homeview._t("solar.learning"),
homeview.line_solar(9.2, 6.4, "10:15")]
check(f"{lang}: every P4 sentence is there and short (<= 2 x 12 words)",
+556
View File
@@ -0,0 +1,556 @@
"""Runnable check for OPT-23 and OPT-28. `python3 test_opt23_28.py`
Both change how `dayplan.plan()` counts the sun.
1. OPT-23: a state-only forecast ("left today" or "tomorrow", no hourly
split) is read from now for every span, so the plan's per-hour sun adds up
to the day's figure. Hourly splits are unchanged. The Forecast tab and the
plan count the same sun.
2. OPT-28, the owner's shape at 20:00 with tomorrow 2, 6 and 12 kWh: the
night level follows what the sun will not give in the expensive hours.
3. Several Leftover solar batteries: one total, shared by capacity, the rest
passed on; more than all can hold is said, not bought. Solar first never
charges from the grid, Cheap hours fills up.
4. A dynamic shape with the expensive run after sunset is unchanged.
5. The screens: the Batteries line, the Home block and the mode help, in
English and Dutch, say the same as control.
Mutations (MUTATION.md, purged copies): listed in the pull request.
"""
import json
import os
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
import logging # noqa: E402
logging.disable(logging.WARNING)
from named_absence import nth, present # noqa: E402
import ux54_scenarios as S # noqa: E402
from app import dayplan, dayview, homeview, modetrade, packmode # noqa: E402
from app import priceruns, solarfc, uitext # noqa: E402
from app.tariff import BILLING_TZ # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
UTC = timezone.utc
EFF = dayplan.EFF
A, B = "inverter-a", "inverter-b"
def loc(day, hour, minute=0):
"""Local time on 2026-10-`day` (CET from the 25th), as UTC."""
return datetime(2026, 10, day, hour, minute,
tzinfo=BILLING_TZ).astimezone(UTC)
def span_sum(fc, now, end):
"""What the plan sums: every hour slice from `now`, each read from now."""
return sum(fc.between(s, e, since=now)
for s, e, _h in dayplan._hour_slices(now, end))
# ============================================================================ #
print("1. OPT-23: a state-only forecast adds up to the day's figure")
# ============================================================================ #
for hh, mm in ((7, 0), (10, 7), (13, 0), (16, 0), (19, 30)):
now = loc(29, hh, mm)
fc = S.forecast(now, 18.0, 16.0)
d1 = solarfc._bounds(now)[1]
per = span_sum(fc, now, d1)
old = sum(fc.between(s, e) for s, e, _h in dayplan._hour_slices(now, d1))
print(f" {hh:02d}:{mm:02d} left {fc.today.kwh:6.2f}, per-hour sum "
f"{per:6.2f} (without `since`: {old:6.2f})")
check(f"{hh:02d}:{mm:02d}: today's hours add up to what is left "
f"({fc.today.kwh:.2f} kWh)", abs(per - fc.today.kwh) < 1e-6)
check(f"{hh:02d}:{mm:02d}: today and tomorrow add up to both figures",
abs(span_sum(fc, now, d1 + timedelta(days=1))
- (fc.today.kwh + fc.tomorrow.kwh)) < 1e-6)
now = loc(29, 7, 0)
fc = S.forecast(now, 18.0, 16.0)
check("...and the old per-hour call, with no `since`, still reads each span "
"from its own start (74 kWh for 18 at 07:00): the plan must pass it",
sum(fc.between(s, e) for s, e, _h in dayplan._hour_slices(
now, solarfc._bounds(now)[1])) > 3 * fc.today.kwh)
now = loc(29, 23, 30)
fc = S.forecast(now, 18.0, 16.0)
d1 = solarfc._bounds(now)[1]
check("tomorrow, planned at 23:30: its 24 hours add up to tomorrow's figure",
abs(span_sum(fc, now, d1 + timedelta(days=1)) - fc.tomorrow.kwh) < 1e-6
and fc.today.kwh < 0.5)
now = loc(29, 20, 0)
fc = S.forecast(now, 18.0, 16.0)
check("tomorrow, planned at 20:00: 07:00 to 22:00 is tomorrow's figure less "
"its first hour (the bell until 07:00)",
abs(fc.between(loc(30, 7), loc(30, 22), since=now)
- 16.0 * (1.0 - solarfc._bell(7.0))) < 1e-6)
check("a span before the figure's moment is not counted (nothing of a "
"'left today' figure is before now)",
fc.between(loc(29, 8), loc(29, 12), since=loc(29, 13)) == 0.0)
now = loc(29, 10, 7)
fp = S.forecast(now, 18.0, 16.0, split=True)
d1 = solarfc._bounds(now)[1]
same = all(abs(fp.between(s, e, since=now) - fp.between(s, e)) < 1e-12
for s, e, _h in dayplan._hour_slices(now, d1 + timedelta(days=1)))
check("hourly periods are unchanged: `since` changes no span", same
and fp.today.periods != ())
check("...and they add up to the figure as before",
abs(span_sum(fp, now, d1) - fp.today.kwh) < 1e-6)
now = loc(29, 10, 7)
fc = S.forecast(now, 18.0, 16.0)
ok = True
for a, z in ((loc(29, 11), loc(29, 14)), (loc(29, 17), loc(30, 9)),
(loc(30, 8), loc(30, 12))):
hi, lo = fc.between(now, z), fc.between(now, a)
ok = ok and abs(dayview.sun_kwh(fc, now, a, z) - (hi - lo)) < 1e-9 \
and dayview.sun_kwh(fc, now, a, z) == fc.between(a, z, since=now)
check("the Forecast tab and the plan read a span the same way "
"(`dayview.sun_kwh()` = `between(since=now)`)", ok)
check("a day with no forecast is still None, with or without `since`",
fc.between(loc(29, 12), loc(31, 12), since=now) is None
and fc.between(loc(29, 12), loc(31, 12)) is None)
# ---- the UX-54 review's three Leftover solar cases (check 3) ---------------
print(" the UX-54 review's cases: 'dynamic' at 00:18, A Leftover solar, "
"20 % to 95 %, sun counted until the dear run at 17:00")
DAY0 = datetime(2026, 10, 26, 23, 0, tzinfo=UTC)
now = DAY0 + timedelta(minutes=18, seconds=5)
cases = {}
for day_kwh in (6.0, 12.0, 20.0):
c = S.make("dynamic", now)
c.solar_fc = S.forecast(now, day_kwh, day_kwh)
rp = c.mode_runs
pl = dayplan.plan(c.solar_fc, c.house_profile.estimator(), rp, now,
c._mode_packs(), trust_pct=70, floor_pct=20.0,
ceiling_pct=95.0)
cases[day_kwh] = (c, rp, pl)
SHIPPED = {6.0: 11.39, 12.0: 22.77, 20.0: 37.95} # the review, as shipped
for day_kwh, want_fc, want_sun, want_lvl, want_gc in (
(6.0, 5.34, 0.14, 68.5, 7.44), (12.0, 10.69, 3.05, 49.5, 4.53),
(20.0, 17.82, 7.40, 21.2, 0.18)):
c, rp, pl = cases[day_kwh]
cap = 15.36
use = 4 * 1.8 # the dear run 17:00-21:00, 1.8 kW
share = pl.share_kwh[A] if pl is not None else 0.0
lvl = 20.0 + use / EFF / cap * 100.0 - share / cap * 100.0
gc = max(0.0, lvl - 20.0) / 100.0 * cap
check(f"{day_kwh:.0f} kWh day: the forecast summed is {want_fc} kWh "
f"(shipped: {SHIPPED[day_kwh]}) and the sun counted {want_sun}",
pl is not None and abs(pl.fc_kwh - want_fc) < 0.01
and abs(pl.sun_kwh - want_sun) < 0.01)
check(f"{day_kwh:.0f} kWh day: the corrected sun gives the review's "
f"level {want_lvl} % and grid charge {want_gc} kWh (the "
"sun-before-the-run part, with the old use sum)",
abs(lvl - want_lvl) < 0.05 and abs(gc - want_gc) < 0.01)
# ============================================================================ #
print("2. OPT-28: the owner's shape at 20:00 (tomorrow 2, 6 and 12 kWh)")
# ============================================================================ #
# Example house (the brief: about 1 kW, evenings more): 0.6 kW at night,
# 1.2 kW 06-10, 0.9 kW by day, 1.8 kW 17-22. 24.9 kWh a day.
SHAPE = {h: (600.0 if h < 6 or h >= 22 else 1200.0 if h < 10
else 900.0 if h < 17 else 1800.0) for h in range(24)}
FLOOR, CEIL = 10.0, 95.0
CAP_A, CAP_B = 15.36, 20.48
NOW = loc(29, 20, 0)
def profile_of(shape):
day = NOW.astimezone(BILLING_TZ).date()
return dayplan.HouseProfile(
{(day - timedelta(days=k)).isoformat():
{str(h): w for h, w in shape.items()} for k in (1, 2, 3)})
def owner_runs():
"""Day 07:00-22:00 at 0.2801, night at 0.2333 (the brief's tariff)."""
return priceruns.RunPlan(
priceruns.Run(loc(29, 22), loc(30, 7), 0.2333),
(priceruns.Run(loc(29, 7), loc(29, 22), 0.2801),
priceruns.Run(loc(30, 7), loc(30, 22), 0.2801)),
None, 0.2801, None, True)
def pk(name, mode, soc=FLOOR, cap=CAP_A, rating=5000.0):
return modetrade.Pack(name, True, mode, False, rating, soc, cap)
def forecast_tomorrow(kwh, now=NOW, left=0.0):
"""A state-only forecast: `left` still to come today, `kwh` tomorrow."""
d0, _d1 = solarfc._bounds(now)
day1 = d0 + timedelta(days=1)
return solarfc.Forecast(solarfc.Day(d0, left), solarfc.Day(day1, kwh),
now)
def owner_plan(kwh, packs, shape=SHAPE, runs=None, now=NOW, fc=None):
return dayplan.plan(fc or forecast_tomorrow(kwh, now),
profile_of(shape).estimator(), runs or owner_runs(),
now, packs, trust_pct=70, floor_pct=FLOOR,
ceiling_pct=CEIL)
def expect_short(kwh, shape=SHAPE, hours=range(7, 22), trust=0.7, lift=True):
"""The brief's sum, written out: per expensive hour max(0, house -
trust x sun). The sun of an hour is the bell's share of tomorrow's
figure; an hour with sun is read at no less than the night base."""
base = sorted(shape[h] for h in (0, 1, 2, 3, 4, 5, 22, 23))
base = (base[3] + base[4]) / 2.0
tot = 0.0
for h in hours:
pv = kwh * (solarfc._bell(h + 1.0) - solarfc._bell(float(h)))
use = (max(shape[h], base) if pv > 0 and lift else shape[h]) / 1000.0
tot += max(0.0, use - trust * pv)
return tot
def expect_level(short, cap, share_pct=0.0):
return max(0.0, min(CEIL, FLOOR + short / EFF / cap * 100.0) - share_pct)
res = {}
for kwh in (2.0, 6.0, 12.0):
pl = owner_plan(kwh, [pk(A, packmode.LEFTOVER_SOLAR),
pk(B, packmode.SOLAR_FIRST, cap=CAP_B,
rating=4600.0)])
res[kwh] = pl
sh = expect_short(kwh)
lv = pl.targets.get(A) if pl is not None else None
print(f" tomorrow {kwh:4.1f} kWh: dear-hour shortfall "
f"{pl.short_kwh:5.2f} kWh (check {sh:5.2f}), A to {lv} %, grid "
f"{pl.need_kwh[A]:5.2f} kWh, unheld {pl.uncovered_kwh:4.2f} kWh")
check(f"{kwh:g} kWh: the shortfall is the sum of max(0, house - 0.7 x "
f"sun) over 07:00-22:00 ({sh:.2f} kWh)",
pl is not None and abs(pl.short_kwh - sh) < 0.005)
check(f"{kwh:g} kWh: A's level is floor + shortfall / 0.95, from the "
"floor, capped at the ceiling",
present(lv) and abs(lv - expect_level(sh, CAP_A)) < 0.05)
check("the target follows the shortfall: more sun, a lower level",
res[2.0].targets[A] >= res[6.0].targets[A] > res[12.0].targets[A])
check("12 kWh: below 95 % because the sun covers part (was 95 % for any "
"forecast)", res[12.0].targets[A] < 90.0
and res[12.0].need_kwh[A] < res[2.0].need_kwh[A])
check("2 kWh: more than A holds is not bought: A to 95 %, the rest said "
"(`uncovered_kwh`)", res[2.0].targets[A] == CEIL
and res[2.0].uncovered_kwh > 1.0)
check("12 kWh: nothing is left over", res[12.0].uncovered_kwh == 0.0)
check("B on Solar first never gets a level (never charges from the grid)",
B not in res[6.0].targets and modetrade.charge_to(
pk(B, packmode.SOLAR_FIRST, cap=CAP_B), CEIL, res[6.0].targets)
is None)
more = owner_plan(6.0, [pk(A, packmode.LEFTOVER_SOLAR),
pk(B, packmode.CHEAP_HOURS, cap=CAP_B,
rating=4600.0)])
check("B on Cheap hours fills up and is no part of A's share: A's level is "
"the same, B is not in the plan",
more.targets == res[6.0].targets and B not in more.targets
and modetrade.charge_to(pk(B, packmode.CHEAP_HOURS, cap=CAP_B), CEIL,
more.targets) == CEIL)
# The profile is net of the sun the house used, so an hour with sun is read
# at no less than the night base (0.6 kW), as the sun sum reads it.
NET = dict(SHAPE)
for h in range(7, 18):
NET[h] = 100.0
pnet = owner_plan(6.0, [pk(A, packmode.LEFTOVER_SOLAR)], shape=NET)
print(f" a profile net of the sun (0.1 kW by day): shortfall "
f"{pnet.short_kwh:.2f} kWh, read at the night base; read as measured "
f"it would be {expect_short(6.0, NET, lift=False):.2f}")
check("an hour with sun is read at no less than the night base, an hour "
"without as measured",
abs(pnet.short_kwh - expect_short(6.0, NET)) < 0.005
and expect_short(6.0, NET) > expect_short(6.0, NET, lift=False) + 1.0)
# The sun before the run still fills first, as before: at 12:00 the rest of
# today's sun beyond the house (0.6 kW) is counted, x trust x 0.95.
N12 = loc(29, 12, 0)
flat = {h: 600.0 for h in range(24)}
fc12 = forecast_tomorrow(6.0, N12, left=9.0)
runs12 = owner_runs()
pl12 = dayplan.plan(fc12, profile_of(flat).estimator(), runs12, N12,
[pk(A, packmode.LEFTOVER_SOLAR)], trust_pct=70,
floor_pct=FLOOR, ceiling_pct=CEIL)
left_share = 1.0 - solarfc._bell(12.0)
excess = sum(max(0.0, 9.0 * (solarfc._bell(h + 1.0) - solarfc._bell(float(h)))
/ left_share - 0.6) for h in range(12, 20))
sun_share = 0.7 * EFF * excess
print(f" 12:00, 9 kWh left today, house 0.6 kW: the sun before the run "
f"is {sun_share:.2f} kWh, the plan counts {pl12.sun_kwh:.2f}")
check("the sun before the run keeps filling: trust x 0.95 x the sun the "
"house will not use, read from now for every hour (OPT-23)",
sun_share > 0.5 and abs(pl12.sun_kwh - sun_share) < 0.01
and abs(pl12.share_kwh[A] - sun_share) < 0.01)
check("...and it is taken off the level the grid charges to",
abs(pl12.targets[A] - expect_level(
expect_short(6.0, flat), CAP_A, sun_share / CAP_A * 100.0)) < 0.05
and pl12.targets[A] < expect_level(expect_short(6.0, flat), CAP_A))
# ============================================================================ #
print("3. several batteries: one total, shared by capacity")
# ============================================================================ #
two = [pk(A, packmode.LEFTOVER_SOLAR),
pk(B, packmode.LEFTOVER_SOLAR, cap=CAP_B, rating=4600.0)]
p2 = owner_plan(6.0, two)
held = sum((p2.targets[n] - FLOOR) / 100.0 * c
for n, c in ((A, CAP_A), (B, CAP_B)))
sh = expect_short(6.0)
print(f" two Leftover solar, 6 kWh: {p2.targets}, together "
f"{held:.2f} kWh above the floor (shortfall / 0.95 = "
f"{sh / EFF:.2f})")
check("the total is what counts: A and B hold the shortfall / 0.95 "
"together", abs(held - sh / EFF) < 0.01)
check("...shared by capacity: the same level, so B holds more kWh than A",
abs(p2.targets[A] - p2.targets[B]) < 0.01
and p2.cover_kwh[B] > p2.cover_kwh[A]
and abs(p2.cover_kwh[B] / p2.cover_kwh[A] - CAP_B / CAP_A) < 0.01)
check("...and covers it between them (cover_kwh)",
abs(sum(p2.cover_kwh.values()) - sh) < 0.01 and p2.uncovered_kwh == 0)
# A shortfall more than ONE holds: a house of 1.8 kW all day.
big = {h: 1800.0 for h in range(24)}
p3 = owner_plan(2.0, two, shape=big)
sh3 = expect_short(2.0, big)
print(f" 1.8 kW house, 2 kWh: shortfall {sh3:.1f} kWh = {sh3 / EFF:.1f} "
f"kWh stored; A holds {(CEIL - FLOOR) / 100 * CAP_A:.1f}, B "
f"{(CEIL - FLOOR) / 100 * CAP_B:.1f}; levels {p3.targets}")
check("a shortfall of more than one battery holds, less than both: both "
"take their share by capacity, the total is met",
sh3 / EFF > (CEIL - FLOOR) / 100 * CAP_B
and sh3 / EFF < (CEIL - FLOOR) / 100 * (CAP_A + CAP_B)
and p3.targets[A] < CEIL and p3.targets[B] < CEIL
and abs(sum((p3.targets[n] - FLOOR) / 100.0 * c
for n, c in ((A, CAP_A), (B, CAP_B))) - sh3 / EFF) < 0.01
and p3.uncovered_kwh == 0.0)
# More than both hold.
huge = {h: 4000.0 for h in range(24)}
p4 = owner_plan(2.0, two, shape=huge)
sh4 = expect_short(2.0, huge)
rooms = (CEIL - FLOOR) / 100 * (CAP_A + CAP_B)
print(f" 4 kW house, 2 kWh: shortfall {sh4:.1f} kWh; both hold "
f"{rooms * EFF:.1f}; unheld {p4.uncovered_kwh:.2f} kWh; "
f"levels {p4.targets}")
check("more than all of them hold: both go to the ceiling, never above",
p4.targets[A] == CEIL and p4.targets[B] == CEIL)
check("...and the rest is said, not bought: another battery or the grid "
"carries it (`uncovered_kwh` = shortfall less what they cover)",
abs(p4.uncovered_kwh - (sh4 - rooms * EFF)) < 0.01
and abs(sum(p4.cover_kwh.values()) - rooms * EFF) < 0.01)
# The pass-through itself: a battery that reaches its ceiling passes the rest.
got, left = dayplan._spread(10.0, {"x": 2.0, "y": 20.0}, {"x": 1.0, "y": 1.0})
check("a battery that reaches its ceiling passes the rest on: one fills, "
"the other takes the rest",
abs(got["x"] - 2.0) < 1e-9 and abs(got["y"] - 8.0) < 1e-9
and left == 0.0)
got, left = dayplan._spread(30.0, {"x": 2.0, "y": 20.0}, {"x": 1.0, "y": 3.0})
check("...by weight, and what none can take is returned",
abs(got["x"] - 2.0) < 1e-9 and abs(got["y"] - 20.0) < 1e-9
and abs(left - 8.0) < 1e-9)
got, left = dayplan._spread(6.0, {"x": 10.0, "y": 10.0}, {"x": 1.0, "y": 3.0})
check("...and shares by weight when none is full (1:3)",
abs(got["x"] - 1.5) < 1e-9 and abs(got["y"] - 4.5) < 1e-9)
check("a weight of 0 or a room of 0 takes nothing",
dayplan._spread(5.0, {"x": 0.0, "y": 9.0}, {"x": 1.0, "y": 0.0})
== ({"x": 0.0, "y": 0.0}, 5.0))
# Off, in maintenance, or with no level: no part in it.
off = [pk(A, packmode.LEFTOVER_SOLAR),
modetrade.Pack(B, False, packmode.LEFTOVER_SOLAR, False, 4600.0,
FLOOR, CAP_B)]
check("a battery that is off takes no part: A carries it alone",
owner_plan(6.0, off).targets == res[6.0].targets)
# ============================================================================ #
print("4. a dear run after sunset is unchanged")
# ============================================================================ #
# Cheap 01:00-05:00, dear 20:00-23:00 (no sun after 20:00). The house is
# below the night base in the dear hours (0.4 kW against 0.6): measured
# as the profile says, as before. An hourly split, so OPT-23 changes nothing.
T0 = loc(29, 0, 30)
DYN = priceruns.RunPlan(
priceruns.Run(loc(29, 1), loc(29, 5), 0.08),
(priceruns.Run(loc(29, 20), loc(29, 23), 0.34),), None, 0.34, None, True)
LOW = dict(SHAPE)
for h in range(20, 23):
LOW[h] = 400.0
def old_plan_level(pl, runs, shape, caps, now):
"""The old sum, written out: the house's use over the dear runs of a
day from the one the plan is for, shared by capacity, from the floor."""
until = nth([g for g in runs.give if g.start >= runs.cheap.end], 0)
use = 0.0
for g in runs.give:
if until.start <= g.start < until.start + timedelta(hours=24):
for s, e, h in dayplan._hour_slices(g.start, g.end):
use += max(0.0, shape[h]) * (e - s).total_seconds() / 3.6e6
out = {}
for n, cap in caps.items():
top = min(CEIL, FLOOR + use * cap / sum(caps.values()) / EFF
/ cap * 100.0)
out[n] = round(max(0.0, top - pl.share_kwh[n] / cap * 100.0), 2)
return out
for kwh in (2.0, 12.0, 40.0):
fcs = S.forecast(T0, kwh, kwh, split=True, members=1)
for label, packs, caps in (
("A alone", [pk(A, packmode.LEFTOVER_SOLAR)], {A: CAP_A}),
("A + B", two, {A: CAP_A, B: CAP_B})):
pl = dayplan.plan(fcs, profile_of(LOW).estimator(), DYN, T0, packs,
trust_pct=70, floor_pct=FLOOR, ceiling_pct=CEIL)
want = old_plan_level(pl, DYN, LOW, caps, T0)
check(f"{kwh:g} kWh, {label}: the levels are the old sum's "
f"{want}", pl is not None and pl.targets == want
and pl.short_kwh == round(0.4 + 0.4 + 0.4, 3))
fcs = S.forecast(T0, 12.0, 12.0, split=True, members=1)
pl = dayplan.plan(fcs, profile_of(LOW).estimator(), DYN, T0,
[pk(A, packmode.LEFTOVER_SOLAR)], trust_pct=70,
floor_pct=FLOOR, ceiling_pct=CEIL)
pr = dayplan.plan(
fcs, profile_of(LOW).estimator(), DYN, T0,
[pk(A, packmode.LEFTOVER_SOLAR)], trust_pct=70, floor_pct=FLOOR,
ceiling_pct=CEIL)
check("the same call twice gives the same plan (pure)", pl == pr)
# The same shape with the dear run at 17:00 (sun until 20:00): lower.
DYN17 = priceruns.RunPlan(
priceruns.Run(loc(29, 1), loc(29, 5), 0.08),
(priceruns.Run(loc(29, 17), loc(29, 21), 0.34),), None, 0.34, None, True)
fcs = S.forecast(T0, 12.0, 12.0, split=True, members=1)
p17 = dayplan.plan(fcs, profile_of(SHAPE).estimator(), DYN17, T0,
[pk(A, packmode.LEFTOVER_SOLAR)], trust_pct=70,
floor_pct=FLOOR, ceiling_pct=CEIL)
o17 = old_plan_level(p17, DYN17, SHAPE, {A: CAP_A}, T0)
check("a dear run with sun in it (17:00-21:00, 12 kWh): the level is lower "
"than the old sum's, by the sun the house will not use",
p17.targets[A] < o17[A] - 3.0)
# ============================================================================ #
print("5. the screens say what control does (EN and NL)")
# ============================================================================ #
now = DAY0 + timedelta(minutes=18, seconds=5)
c = S.make("dynamic", now, soc=(20.0, 20.0))
c.solar_fc = S.forecast(now, 3.0, 3.0)
c._runs_key = None
c._mode_roles(now, None)
tg = c.plan_targets()
dp = c.day_plan
cover = homeview.plan_cover(c, A)
print(f" plan: {tg}, cover {cover}, shortfall {dp.short_kwh}")
check("the controller holds a level for A (Leftover solar) and a cover "
"figure that matches the plan", present(tg) and A in tg
and cover is not None and abs(cover[0] - tg[A]) < 1e-9
and abs(cover[1] - dp.cover_kwh[A]) < 0.01)
out = {}
for lang in ("en", "nl"):
st = homeview.build(c, level="ok", checks=[], now=now, lang=lang)
blk = nth([r for r in st["plan"]["runs"]
if r["battery"] == 0 and r["kind"] == "grid"], 0)
blk_b = nth([r for r in st["plan"]["runs"]
if r["battery"] == 1 and r["kind"] == "grid"], 0)
row = nth(st["batteries"], 0)
out[lang] = (st, blk, blk_b, row)
print(f" {lang}: {blk['what']} | {blk['why']}")
print(f" {lang}: {row['why']}")
with homeview.in_lang(lang):
pct = homeview._pct(tg[A])
kwh = homeview._kwh(cover[1])
need = homeview._kwh(homeview.plan_need(c, A))
check(f"{lang}: Home's grid block names the level and what it covers",
present(blk) and pct in blk["what"] and kwh in blk["why"])
check(f"{lang}: the Batteries line names the same level and figure",
present(row) and pct in row["why"] and kwh in row["why"])
check(f"{lang}: Home's block is the control's grid charge "
"(plan_need)", present(blk) and need in blk["what"])
check(f"{lang}: B on Cheap hours has no plan, so its block keeps the "
"old words", present(blk_b) and "%" not in blk_b["what"]
and kwh not in blk_b["why"])
en, nl = out["en"], out["nl"]
check("EN: the Batteries line says it plainly",
"charges from the grid to" in en[3]["why"]
and "expensive hours the sun will not" in en[3]["why"])
check("NL: and the same in Dutch (a natural sentence)",
"laadt van het net tot" in nl[3]["why"]
and "dure uren" in nl[3]["why"] and "{" not in nl[3]["why"])
check("Dutch uses a decimal comma", "," in nl[1]["what"]
and "," in nl[1]["why"] and "." not in nl[1]["what"].rstrip("."))
# The words of the plan lines are STYLE.md plan lines: two short sentences.
for lang in ("en", "nl"):
w = uitext.words(lang)["home_words"]
for key in ("run.covers", "run.grid_what_to", "why.tops_up",
"solar.uncovered"):
node = w
for k in key.split("."):
node = node[k]
sentences = [s for s in node.replace("%", "").split(".") if s.strip()]
check(f"{lang}: '{key}' is at most 2 sentences of at most 12 words",
len(sentences) <= 2 and all(
len(s.split()) <= 12 for s in sentences))
# More than all hold: the Batteries screen says another battery or the grid
# carries the rest (here B is on Cheap hours: "the grid" alone is not true).
c2 = S.make("dynamic", now, soc=(20.0, 20.0))
c2.solar_fc = S.forecast(now, 1.0, 1.0)
c2.house_profile = profile_of({h: 3500.0 for h in range(24)})
c2._runs_key = None
c2._mode_roles(now, None)
dp2 = c2.day_plan
sol = homeview.solar_block(c2, now)
print(f" a 3.5 kW house: shortfall {dp2.short_kwh:.1f} kWh, unheld "
f"{dp2.uncovered_kwh:.1f} kWh")
check("the plan finds more than the battery holds", dp2.uncovered_kwh > 1.0
and c2.plan_targets()[A] == 95.0)
check("EN: the line under the Batteries cards says Leftover solar cannot "
"hold it all and the rest comes from another battery or the grid",
"more than Leftover solar can hold" in sol["line"]
and "another battery or the grid" in sol["line"]
and homeview._kwh(dp2.uncovered_kwh) in sol["line"])
with homeview.in_lang("nl"):
sol_nl = homeview.solar_block(c2, now)
check("NL: and in Dutch", "meer dan Overschot zonne-energie kan opslaan"
in sol_nl["line"] and "andere batterij of het net" in sol_nl["line"]
and "{" not in sol_nl["line"])
check("a covered plan has no such sentence",
"can hold" not in homeview.solar_block(c, now)["line"])
# The mode help.
en_w, nl_w = uitext.words("en"), uitext.words("nl")
def help_of(w, mode):
b = w["home"]["batteries"]
return (b["choice"][mode]["description"],
b["how"][mode]["description"])
for lang, w, want in (
("EN", en_w, {"leftover_solar": "only what the sun will not give",
"cheap_hours": "Fills up",
"solar_first": "never charges from the grid"}),
("NL", nl_w, {"leftover_solar": "alleen wat de zon niet geeft",
"cheap_hours": "vult zich vol",
"solar_first": "nooit van het net"})):
for mode, phrase in want.items():
d1_, d2_ = help_of(w, mode)
check(f"{lang}: the {mode} help says '{phrase}' (list and "
"'How the modes work')",
phrase.lower() in d1_.lower() and phrase.lower() in d2_.lower())
for lang, w in (("EN", en_w), ("NL", nl_w)):
for mode in ("solar_first", "leftover_solar", "cheap_hours"):
for d in help_of(w, mode):
check(f"{lang}: {mode} help is under 30 words", len(d.split()) <= 30)
print()
if fails:
print(f"FAILED: {len(fails)}")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")
+5 -3
View File
@@ -636,10 +636,12 @@ _app = web.build_app(FakeController(), FakeSupervisor())
_routes = sorted({(r.method, r.get_info().get("path"))
for r in _app.router.routes() if r.method != "HEAD"})
print(" " + " ".join(f"{m} {p}" for m, p in _routes))
check("the configuration surface adds five routes and UX-13 one GET, and "
"none touches the three that were there",
check("the configuration surface adds five routes, UX-13 one GET and "
"UX-54 one more GET (the Forecast tab), and none touches the three "
"that were there",
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
("GET", "/ha/entities"), ("GET", "/status"),
("GET", "/forecast"), ("GET", "/ha/entities"),
("GET", "/status"),
("POST", "/action"), ("POST", "/config/restart"),
("POST", "/config/save"), ("POST", "/config/validate")])
_ents_src = io.open(os.path.join(HERE, "app", "haentities.py"),
File diff suppressed because it is too large Load Diff
+296
View File
@@ -0,0 +1,296 @@
"""UX-54: the Forecast tab on the two clock-change days.
`python3 test_ux54_dst.py`
The tab walks local hours (`dayplan._hour_slices()` in `dayview` and
`daycurve`, `SolarToday`'s hour keys). On the spring change a loop that steps
on the wall clock never ends (SAFETY-123), and a tab request runs on the
event loop control runs on. Graded here, every call under a hard time limit
that the add-on's own `except Exception` cannot swallow (`Hang` is a
`BaseException`), and a memory limit:
1. The limit itself: a tab build that never returns is stopped and reported,
through `forecast_payload()`'s `except Exception`.
2. `Controller.forecast_payload()` at every quarter of 27-29 March 2027 and
30 October-1 November 2027, modes on (Leftover solar and Cheap hours,
with a forecast and a house profile: the level curve) and modes off, in
the no-tzdata zone (`tariff._EUTimezone`) and, where the host has tzdata,
in ZoneInfo Europe/Brussels: it returns, `ok`, each day has 23 / 24 / 25
hours with the local hour labels of that day, every level inside floor
and ceiling, every block inside its day.
3. `SolarToday` keeps the two 02:00 hours of the autumn change apart.
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
"""
import logging
import os
import resource
import signal
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import ux54_scenarios as S # noqa: E402
import app.main as M # noqa: E402
from app import arbitrage, dayplan, dayview, homeview, solarfc # noqa: E402
from app import tariff # noqa: E402
from app.daycurve import SolarToday # noqa: E402
UTC = timezone.utc
fails = []
LIMIT_S = 10.0 # one call; a build takes well under a second
try: # a loop that appends without end must not take the host
resource.setrlimit(resource.RLIMIT_AS, (3 << 29, resource.getrlimit(
resource.RLIMIT_AS)[1]))
except (ValueError, OSError):
pass
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
class Hang(BaseException):
"""A call ran over LIMIT_S. Not an `Exception`: `forecast_payload()`
catches every `Exception` and would turn it into `{"ok": False}`."""
def _alarm(*_a):
raise Hang()
def limited(fn, *a, **k):
"""`fn(*a, **k)`, or `Hang` when it takes more than LIMIT_S seconds or
runs out of memory."""
signal.signal(signal.SIGALRM, _alarm)
signal.setitimer(signal.ITIMER_REAL, LIMIT_S)
try:
return fn(*a, **k)
except MemoryError:
raise Hang() from None
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
# --------------------------------------------------------------------------- #
# The clock and the zone
# --------------------------------------------------------------------------- #
NOW = [datetime(2027, 1, 1, tzinfo=UTC)]
class FakeDT(datetime):
@classmethod
def now(cls, tz=None):
return NOW[0].astimezone(tz) if tz else NOW[0].replace(tzinfo=None)
for _mod in (M, homeview, dayview): # what reads the wall clock
_mod.datetime = FakeDT
TZ_MODULES = (tariff, dayplan, homeview, solarfc)
class zone:
"""`BILLING_TZ` is `tz` in every module that holds it, in the block."""
def __init__(self, tz):
self.tz = tz
def __enter__(self):
self.was = [m.BILLING_TZ for m in TZ_MODULES]
for m in TZ_MODULES:
m.BILLING_TZ = self.tz
def __exit__(self, *_a):
for m, tz in zip(TZ_MODULES, self.was):
m.BILLING_TZ = tz
ZONES = [("no tzdata (_EUTimezone)", tariff._EUTimezone())]
try:
from zoneinfo import ZoneInfo
_bru = ZoneInfo("Europe/Brussels")
if datetime(2026, 7, 1, tzinfo=UTC).astimezone(_bru).hour == 2:
ZONES.append(("ZoneInfo Europe/Brussels", _bru))
except Exception: # noqa: BLE001
pass
if len(ZONES) == 1:
print(" no tzdata on this host: ZoneInfo is not graded here")
# The local days (UTC midnight-to-midnight instants are not used: the days
# are known by their dates) and their hour labels.
SPRING, AUTUMN = (2027, 3, 28), (2027, 10, 31)
WINDOWS = (((2027, 3, 27), SPRING, (2027, 3, 29)),
((2027, 10, 30), AUTUMN, (2027, 11, 1)))
def labels_of(ymd):
if ymd == SPRING:
return ["00:00", "01:00"] + [f"{h:02d}:00" for h in range(3, 24)]
if ymd == AUTUMN:
return ["00:00", "01:00", "02:00", "02:00"] + [
f"{h:02d}:00" for h in range(3, 24)]
return [f"{h:02d}:00" for h in range(24)]
def next_ymd(ymd):
d = datetime(*ymd) + timedelta(days=1)
return (d.year, d.month, d.day)
def day_start_utc(ymd):
"""Local midnight of `ymd` in UTC (CET before the change, CEST after it;
midnight is never inside a change)."""
summer = (2027, 3, 28) < ymd < (2027, 10, 31) or ymd == (2027, 10, 31)
if ymd == SPRING:
summer = False
return datetime(*ymd, tzinfo=UTC) - timedelta(hours=2 if summer else 1)
# --------------------------------------------------------------------------- #
print("1. the time limit cannot be swallowed")
# --------------------------------------------------------------------------- #
check("`Hang` is a BaseException and not an Exception",
issubclass(Hang, BaseException) and not issubclass(Hang, Exception))
c0 = limited(S.make, "off",
datetime(2027, 1, 12, 11, 0, 5, tzinfo=UTC))
_real_build = dayview.build
def _spin(*_a, **_k):
while True:
pass
dayview.build = _spin
try:
NOW[0] = datetime(2027, 1, 12, 11, 0, 5, tzinfo=UTC)
got = limited(c0.forecast_payload, "en")
except Hang:
got = "stopped"
finally:
dayview.build = _real_build
check("a tab build that never returns is stopped by the limit and reported, "
f"not turned into a page answer by forecast_payload() ({got!r})",
got == "stopped")
# --------------------------------------------------------------------------- #
print("\n2. the tab at every quarter of the change days")
# --------------------------------------------------------------------------- #
Q = timedelta(minutes=15)
SCENARIOS = (("modes on", "dynamic", (55.0, 40.0)),
("modes off", "off", (55.0, 40.0)))
def wrong(out, ymd, floor_, ceil_):
"""What is wrong with one build, or None."""
if not out.get("ok") or len(out.get("days") or []) != 2:
return "not ok"
for k, day in enumerate(out["days"]):
exp = labels_of((ymd, next_ymd(ymd))[k])
hrs = day["hours"]
if day["length_h"] != len(exp):
return f"day {k}: length {day['length_h']}"
if [h["label"] for h in hrs] != exp:
return f"day {k}: labels"
if any(abs(h["end_h"] - h["start_h"] - 1.0) > 1e-6
or abs(h["start_h"] - j) > 1e-6 for j, h in enumerate(hrs)):
return f"day {k}: hour spans"
for b in day["batteries"]:
if any(not floor_ - 1e-6 <= v <= ceil_ + 1e-6
or not -1e-6 <= h <= len(exp) + 1e-6
for h, v in b["levels"]):
return f"day {k}: level {b['name']}"
if any(not 0.0 <= r["start_h"] <= r["end_h"] <= len(exp) + 1e-6
for r in b["blocks"]):
return f"day {k}: block {b['name']}"
return None
def walk(scn, soc):
"""`(builds, with a curve, stopped, wrong)` over every quarter of the
six local days; the first call stopped by the limit ends the walk."""
builds, curves, bad = 0, 0, []
for window in WINDOWS:
for ymd in window:
a = day_start_utc(ymd)
z = day_start_utc(next_ymd(ymd))
t = a + timedelta(seconds=5)
try:
NOW[0] = t
c = limited(S.make, scn, t, soc=soc)
floor_, ceil_ = arbitrage.soc_bounds(c.o)
while t < z:
NOW[0] = t
c.tariff_at = t
if c.modes_on:
limited(c._mode_roles, t, None)
limited(c.solar_today.add, t, 1500.0)
out = limited(c.forecast_payload, "en")
builds += 1
curves += any(len(b["levels"]) > 1
for d in out.get("days") or []
for b in d["batteries"])
why = wrong(out, ymd, floor_, ceil_)
if why:
bad.append((t.isoformat(), why))
t += Q
except Hang:
return builds, curves, t.isoformat(), bad
return builds, curves, None, bad
for zname, tz in ZONES:
with zone(tz):
for label, scn, soc in SCENARIOS:
builds, curves, stopped, bad = walk(scn, soc)
print(f" {zname}, {label}: {builds} builds ({curves} with a "
f"level curve), stopped at {stopped}, {len(bad)} wrong "
f"{bad[:3]}")
check(f"{zname}, {label}: the tab returns at every quarter of "
"27-29 March and 30 October-1 November 2027 (under "
f"{LIMIT_S:g} s each), and each day has its 23, 24 or 25 "
"hours, labelled, levels inside floor and ceiling, blocks "
"inside the day",
stopped is None and not bad
and builds == (96 + 92 + 96) + (96 + 100 + 96)
and (curves == builds if scn == "dynamic" else not curves))
# --------------------------------------------------------------------------- #
print("\n3. measured solar keeps the two autumn 02:00 hours apart")
# --------------------------------------------------------------------------- #
for zname, tz in ZONES:
with zone(tz):
s = SolarToday()
a = day_start_utc(AUTUMN) # 00:00 CEST = 22:00 UTC
t = a
while t <= a + timedelta(hours=4):
# 1 kW in the first 02:00 hour (00:00-01:00 UTC), 3 kW in the
# second (01:00-02:00 UTC), nothing else
w = 1000.0 if a + timedelta(hours=2) <= t < a + timedelta(
hours=3) else 3000.0 if a + timedelta(hours=3) <= t < a + \
timedelta(hours=4) else 0.0
limited(s.add, t, w)
t += timedelta(minutes=1)
now = a + timedelta(hours=4, minutes=30)
sl = [x for x in limited(dayplan._hour_slices, a, now)
if x[2] == 2]
got = [None if x is None else round(x, 2) for x in
(limited(s.kwh, x[0], x[1], now) for x in sl)]
print(f" {zname}: the two 02:00 hours {got}")
check(f"{zname}: two 02:00 slices, about 1 kWh and 3 kWh, each in "
"its own hour", len(sl) == 2 and got[0] is not None
and got[1] is not None and abs(got[0] - 1.0) < 0.06
and abs(got[1] - 3.0) < 0.06)
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+275
View File
@@ -0,0 +1,275 @@
"""UX-54: controllers in a known situation, for test_ux54.py and the browser
proof (`scratch/UX-54/dev/demo54.py`). Real `Controller`, readings set by hand,
no Home Assistant. `make(name, now)` returns one; the names are in `SCENARIOS`.
owner modes on, both batteries Solar first, a day/night tariff, a forecast
from three state-only sensors (Forecast.Solar's shape), a house
profile (the owner's site 2026-10-26)
dynamic modes on, A Leftover solar and B Cheap hours, 15-minute prices with a
cheap night and a dear evening, a dark day (a grid charge is due)
sunny as dynamic, a sunny day (the sun fills A, no grid charge)
periods as dynamic with a Solcast-shaped forecast (an hourly split)
off modes off, arbitrage on, the same prices
learning modes on, a forecast, and no house profile yet
nofc modes on, no forecast sensor set
stale modes on, a sensor set that gives no forecast now
notomorrow as owner, today's forecast only
cycle as dynamic, battery A waiting for its monthly cycle
"""
import asyncio
import json
import math
import os
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, HERE)
from app import dayplan, packmode, solarfc # noqa: E402
from app.main import Controller # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
from app.static_tariff import TariffPlan, static_plan # noqa: E402
from app.store import unit_key # noqa: E402
UTC = timezone.utc
SCENARIOS = ("owner", "dynamic", "sunny", "periods", "off", "learning",
"nofc", "stale", "notomorrow", "cycle")
INV = [{"name": "inverter-a", "setpoint_entity": "number.a",
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
"max_w": 5000, "capacity_kwh": 15.36},
{"name": "inverter-b", "setpoint_entity": "number.b",
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
"max_w": 4600, "capacity_kwh": 20.48}]
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"meter_source": "off", "max_w": 9600, "shadow_mode": "off",
"maintenance_enabled": False, "arbitrage_enabled": True,
"cycle_cost_threshold_eur": 0.10, "pv_entity": "sensor.pv",
"inverters": INV}
FC1 = "sensor.energy_production_today_remaining"
FCT1 = "sensor.energy_production_tomorrow"
DN = {"periods": [{"name": "day", "from": "07:00", "to": "22:00",
"days": "all", "eur_per_kwh": 0.2801},
{"name": "night", "from": "22:00", "to": "07:00",
"days": "all", "eur_per_kwh": 0.2333}]}
class Store:
fresh = False
def __init__(self):
self.data = {}
def set(self, k, v):
self.data[k] = v
def get(self, k, d=None):
return self.data.get(k, d)
def set_time(self, k, v):
self.data[k] = v.isoformat()
def get_time(self, k):
v = self.data.get(k)
return None if v is None else datetime.fromisoformat(v)
class Pub:
enabled = False
def publish(self, _v):
pass
def close(self):
pass
class Hass:
session = None
token = ""
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def number(self, _e, invert=False):
return None
async def state(self, _e):
return None
async def set_number(self, _e, _v):
return True
async def call(self, *_a):
return True
async def states(self):
return []
def day0_of(now):
return solarfc._bounds(now)[0]
def quarters(now, price_of, days=2):
"""`days` days of 15-minute prices from local midnight; `price_of(h)`."""
d0 = day0_of(now)
pts = []
for i in range(96 * days):
t = d0 + timedelta(minutes=15 * i)
pts.append(PricePoint(brussels(t), price_of((i % 96) / 4.0), False))
return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
"generic_dayahead", now), None,
"provider")
def dyn_price(h):
if 1.0 <= h < 5.0:
return 0.08
if 11.0 <= h < 15.0:
return 0.14
if 17.0 <= h < 21.0:
return 0.34
return 0.20
def profile(now, *, day=600.0, evening=1800.0, night=350.0):
"""A house profile with three full days: the net use per local hour."""
shape = {h: (night if h < 6 or h >= 22 else 900.0 if h < 9
else day if h < 17 else evening) for h in range(24)}
d = brussels(now).date()
return dayplan.HouseProfile(
{(d - timedelta(days=k)).isoformat():
{str(h): w for h, w in shape.items()} for k in (1, 2, 3)})
def sensor(entity, kwh, now, *, periods=None, unit="kWh"):
"""A forecast sensor's state, as Home Assistant gives it."""
attrs = {"unit_of_measurement": unit}
if periods:
attrs["wh_period"] = periods
return {"entity_id": entity, "state": f"{kwh:.3f}", "attributes": attrs,
"last_updated": now.isoformat(),
"last_reported": now.isoformat()}
def bell_periods(now, day_kwh, *, tomorrow=False):
"""An hourly split (Open-Meteo's `wh_period`) of a bell, for `now`'s day."""
d0 = day0_of(now) + (timedelta(days=1) if tomorrow else timedelta(0))
out = {}
for h in range(24):
a = solarfc._bell(h + 1.0) - solarfc._bell(float(h))
out[(d0 + timedelta(hours=h)).isoformat()] = round(
a * day_kwh * 1000.0, 1)
return out
def left_today(now, day_kwh):
"""What the integration says is left today at `now` (state only)."""
d0 = day0_of(now)
h = (now - d0).total_seconds() / 3600.0
return day_kwh * (1.0 - solarfc._bell(h))
def forecast(now, today_kwh, tomorrow_kwh, *, split=False, members=1,
tomorrow=True):
"""A `solarfc.Forecast` through `read_sum()`, as the controller reads it.
`today_kwh` is the whole day's sun; the sensors say what is left."""
left = left_today(now, today_kwh)
ts = [(f"{FC1}_{i}", sensor(
f"{FC1}_{i}", left / members, now,
periods=(bell_periods(now, today_kwh / members) if split else None)))
for i in range(members)]
tm = [(f"{FCT1}_{i}", sensor(
f"{FCT1}_{i}", tomorrow_kwh / members, now,
periods=(bell_periods(now, tomorrow_kwh / members, tomorrow=True)
if split else None)))
for i in range(members)] if tomorrow else []
fc, why, _gaps = solarfc.read_sum(ts, tm, now)
return fc
def measure(c, now, peak_w):
"""The solar this site made today until `now`, one reading a minute."""
d0 = day0_of(now)
t = d0
while t <= now:
h = (t - d0).total_seconds() / 3600.0
c.solar_today.add(t, peak_w * math.sin(math.pi * (h - 6.0) / 14.0)
if 6.0 <= h < 20.0 else 0.0)
t += timedelta(minutes=1)
def make(name, now, *, soc=(55.0, 40.0), modes=("solar_first",
"solar_first")):
"""The controller of scenario `name` at `now` (UTC), its cycle run once."""
assert name in SCENARIOS, name
opts = dict(BASE)
plan = quarters(now, dyn_price)
sun_today, sun_tomorrow = 18.0, 16.0
members, split, tomorrow = 1, False, True
if name == "owner":
opts.update(battery_modes_enabled=True,
pv_forecast_entities=[f"{FC1}_{i}" for i in range(3)],
pv_forecast_tomorrow_entities=[f"{FCT1}_{i}"
for i in range(3)],
static_profile=json.dumps(DN), arbitrage_enabled=False)
plan = static_plan(opts, day0_of(now), day0_of(now) + timedelta(
hours=48), now=now)
members = 3
elif name in ("dynamic", "sunny", "periods", "learning", "stale",
"cycle"):
opts.update(battery_modes_enabled=True, pv_forecast_entity=FC1,
pv_forecast_tomorrow_entity=FCT1)
modes = ("leftover_solar", "cheap_hours")
if name == "dynamic":
sun_today, sun_tomorrow = 3.0, 3.0
if name == "periods":
split = True
sun_today, sun_tomorrow = 6.0, 6.0
if name == "cycle":
opts.update(maintenance_enabled=True)
elif name == "off":
opts.update(battery_modes_enabled=False, arbitrage_enabled=True)
elif name == "nofc":
opts.update(battery_modes_enabled=True)
modes = ("leftover_solar", "cheap_hours")
elif name == "notomorrow":
opts.update(battery_modes_enabled=True, pv_forecast_entity=FC1,
static_profile=json.dumps(DN), arbitrage_enabled=False)
plan = static_plan(opts, day0_of(now), day0_of(now) + timedelta(
hours=48), now=now)
tomorrow = False
if name == "sunny":
sun_today, sun_tomorrow = 40.0, 40.0
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
c.auto = True
for i, inv in enumerate(c.inverters):
inv.soc, inv.batt, inv.last_write = soc[i], 0.0, 0.0
inv.dev_min, inv.dev_max = -5000.0, 5000.0
if opts.get("battery_modes_enabled"):
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes[i]
c.grid, c.pv_w = 300.0, 0.0
c.tariff_plan, c.tariff_at = plan, now
c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=7000.0,
ceiling_from="cap", projected_w=800.0)
c.arb_headroom_w = 6000.0
if name not in ("nofc", "off", "stale") and \
opts.get("battery_modes_enabled"):
c.solar_fc = forecast(now, sun_today, sun_tomorrow, split=split,
members=members, tomorrow=tomorrow)
c.solar_fc_why = None
if name != "learning":
c.house_profile = profile(now)
if name in ("owner", "dynamic", "sunny", "periods", "learning",
"notomorrow", "cycle"):
measure(c, now, 7000.0 if name != "dynamic" else 2500.0)
if opts.get("battery_modes_enabled"):
c.tariff_at = now
c._mode_roles(now, None)
return c