Compare commits
12
Commits
SAFETY-123
...
OPT-23-28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae0cd542c0 | ||
|
|
995028bc5d | ||
|
|
4a1f6cc2d9 | ||
|
|
a0ace389a4 | ||
|
|
a4b769f257 | ||
|
|
8dbf831854 | ||
|
|
00c8ae354c | ||
|
|
f5afb420cf | ||
|
|
4256bd3eda | ||
|
|
5ab8622265 | ||
|
|
75195f7073 | ||
|
|
b6b2d1b184 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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")]
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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">›</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>
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user