Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a1f6cc2d9 | ||
|
|
a0ace389a4 | ||
|
|
a4b769f257 | ||
|
|
8dbf831854 | ||
|
|
00c8ae354c | ||
|
|
f5afb420cf | ||
|
|
d2820e8b9b | ||
|
|
4256bd3eda | ||
|
|
5ab8622265 | ||
|
|
75195f7073 | ||
|
|
b6b2d1b184 | ||
|
|
a1d07030f8 | ||
|
|
5dfe9720c5 | ||
|
|
242cf434f4 | ||
|
|
514256923b |
@@ -0,0 +1,35 @@
|
||||
# SAFETY-123 - the day plan's hour loop steps in UTC
|
||||
|
||||
**Status: for review.** Tests: `test_safety123.py`. No option, no text change.
|
||||
|
||||
`dayplan._hour_slices()` stepped to the next local hour by adding an hour to
|
||||
the local wall clock and converting back. Where the zone has no tzdata
|
||||
(`tariff._EUTimezone`: the CI image, a dev host, an image that loses tzdata)
|
||||
the missing 02:00 of the spring change reads as summer time, which is
|
||||
00:00 UTC again. At 28 March 2027 00:00 UTC `t` did not move and the list
|
||||
grew until the process died. Control and the web page share one event loop,
|
||||
so the whole add-on froze. `dayplan.plan()` calls it on every plan, before it
|
||||
looks at a battery's mode, so a site with all batteries on Solar first reaches
|
||||
it as well, if the prices give a plan.
|
||||
|
||||
The shipped image has tzdata (`amd64-base:3.20`), where this zone gives the
|
||||
right answer, so a running add-on is not known to be hit. The fix is for any
|
||||
host without it. (AUDIT-01 F4, card SAFETY-120, is the same defect.)
|
||||
|
||||
**Decision.** Each step adds the time left in the local hour to the UTC
|
||||
instant. It is at least a microsecond, so `t` always advances and the loop
|
||||
ends, in any zone, with or without tzdata. A day of 23 hours gives 23 slices
|
||||
and a day of 25 hours gives 25, each at most an hour, none across a local
|
||||
hour. On ordinary days the slices are the old ones.
|
||||
|
||||
**One change on the autumn day.** The old function (with tzdata) made one
|
||||
two-hour slice for the doubled hour 02:00-03:00; it now makes two of an hour,
|
||||
both labelled 2. `plan()` sums per slice, so only a forecast with sun in that
|
||||
hour could tell the difference. There is none.
|
||||
|
||||
**Other loops.** The other code that walks local hours or days steps in UTC
|
||||
or on the calendar date (`static_tariff`, `price_forecast`, `price_history`,
|
||||
`price`, `maintenance`, `homeview.local_day`, `solarfc._bounds`), or has a
|
||||
fixed bound. None can stall. One known inexactness stays, outside this fix:
|
||||
`solarfc` spreads a state-only forecast over hours since local midnight, so on
|
||||
the two change days its sun curve is an hour off the clock.
|
||||
@@ -0,0 +1,40 @@
|
||||
# UX-53 - Home draws a grid charge only where the add-on will take it
|
||||
|
||||
**Status: DONE 2026-10-26 (owner go 2026-10-25, from the PRICE-02 rig test F-1 and F-2).**
|
||||
|
||||
## Decision
|
||||
|
||||
Home asks the add-on's own gate, and does not decide for itself.
|
||||
|
||||
- **Modes on.** `Controller._mode_roles()` files the gate it just asked
|
||||
(`TariffPlan.arbitrage()` with the measured peak guard) as `mode_gate`: None
|
||||
when it allowed a charge, else its reason (`peak`, `not_worth`,
|
||||
`not_covered`). While it is set, `homeview.mode_runs()` draws no grid charge
|
||||
and `price_windows()` marks no cheap quarter. Control never reads it. A
|
||||
refusal that is not the gate (no measured headroom, a battery cycling) is
|
||||
not filed, so those pages are as before.
|
||||
- **Modes off.** A charge the verdict puts after its last dear period is
|
||||
refused when its time comes: the quarters left are all equal. `leg_taken()`
|
||||
asks `trade_verdict()` (the control cycle's own call) at the leg's start and
|
||||
keeps the leg only if the gate allows it there. A leg in progress is the
|
||||
verdict's own answer now. The sale, "Tomorrow:" lines and chart marks use
|
||||
the same answer.
|
||||
- **Words.** "Lowest/Highest published price" becomes "Lowest/Highest price
|
||||
left" (the verdict reads only the periods not yet over). The flat-tariff
|
||||
refusal calls a site "a fully fixed product" only when its product is fixed.
|
||||
|
||||
## What does not change
|
||||
|
||||
No control decision, command or option. Day/night and hourly sites with a
|
||||
second day of prices keep both legs at every half hour of a day
|
||||
(`test_ux53.py` part 4), except where every price left is equal: with an
|
||||
all-night weekend, Friday's 22:00 charge is no longer drawn, as the add-on
|
||||
does not take it. Price-off sites draw nothing, as before.
|
||||
|
||||
## Limits
|
||||
|
||||
The leg's gate is asked on today's plan, as the control cycle will find it at
|
||||
that time. A plan that gets new prices in between is drawn again at the next
|
||||
refresh. While the peak guard holds, a modes-on page shows no grid charge at
|
||||
all, also not the rest of a run the add-on charges in once the guard clears,
|
||||
as the modes-off page already did.
|
||||
@@ -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.
|
||||
@@ -3530,6 +3530,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)
|
||||
@@ -157,13 +157,15 @@ class Plan:
|
||||
|
||||
|
||||
def _hour_slices(t0, t1):
|
||||
"""Local-hour slices of [t0, t1): (start, end, local hour)."""
|
||||
"""Local-hour slices of [t0, t1): (start, end, local hour).
|
||||
|
||||
Steps in UTC, so `t` always advances (SAFETY-123, DECISIONS)."""
|
||||
out, t = [], t0
|
||||
while t < t1:
|
||||
lt = _local(t)
|
||||
nxt = (lt.replace(minute=0, second=0, microsecond=0)
|
||||
+ timedelta(hours=1)).astimezone(timezone.utc)
|
||||
e = min(nxt, t1)
|
||||
left = timedelta(minutes=60 - lt.minute) - timedelta(
|
||||
seconds=lt.second, microseconds=lt.microsecond)
|
||||
e = min(t.astimezone(timezone.utc) + left, t1)
|
||||
out.append((t, e, lt.hour))
|
||||
t = e
|
||||
return out
|
||||
|
||||
@@ -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.
|
||||
Both ends are measured from `now`: `Forecast.between()` reads its start
|
||||
as the moment the "left today" figure was taken, so a span that starts
|
||||
later would be spread over what is left of the bell, not over its own
|
||||
hours (state-only forecasts, DECISIONS/UX-54-forecast-tab.md)."""
|
||||
hi, lo = fc.between(now, b), fc.between(now, a)
|
||||
return None if hi is None or lo is None else max(0.0, hi - lo)
|
||||
|
||||
|
||||
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,6 +16,7 @@ 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
|
||||
@@ -212,25 +213,26 @@ def _price_mode_in_use(c):
|
||||
return False
|
||||
|
||||
|
||||
def price_windows(c, plan, verdict):
|
||||
def price_windows(c, plan, verdict, now=None):
|
||||
"""`[(kind, start, end)]`: where the add-on itself buys ("cheap") and
|
||||
gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan`
|
||||
(`c.mode_runs`): the cheap run when charging in it pays, and every give
|
||||
run. With them off it is the arbitrage verdict's two points, the ones
|
||||
`trade_runs()` draws. Nothing is classified here."""
|
||||
(`c.mode_runs`): the cheap run when charging in it pays and the trade gate
|
||||
allows it (UX-53), and every give run. With them off it is the arbitrage
|
||||
verdict's legs the add-on will take, the ones `trade_runs()` draws
|
||||
(`trade_legs()`, given `now`). Nothing is classified here."""
|
||||
if getattr(c, "modes_on", False):
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
if rp is None or not rp.on or not _price_mode_in_use(c):
|
||||
return []
|
||||
out = [("cheap", rp.cheap.start, rp.cheap.end)] \
|
||||
if rp.charge_useful() else []
|
||||
if rp.charge_useful() and getattr(c, "mode_gate", None) is None \
|
||||
else []
|
||||
return out + [("dear", g.start, g.end) for g in rp.give]
|
||||
if verdict is None:
|
||||
return []
|
||||
return [("cheap", verdict.cheapest.start,
|
||||
_point_end(plan, verdict.cheapest)),
|
||||
("dear", verdict.dearest.start,
|
||||
_point_end(plan, verdict.dearest))]
|
||||
return [("cheap" if kind == "grid" else "dear", pt.start,
|
||||
_point_end(plan, pt))
|
||||
for kind, pt in trade_legs(c, plan, verdict, now)]
|
||||
|
||||
|
||||
def day_range(points):
|
||||
@@ -306,32 +308,65 @@ def _point_end(plan, point):
|
||||
return point.start + timedelta(hours=1)
|
||||
|
||||
|
||||
def leg_taken(c, plan, pt, now):
|
||||
"""UX-53: will the add-on act on this point of the verdict? One in
|
||||
progress is the verdict's own answer now. One still ahead is asked of the
|
||||
same gate (`trade_verdict()`, the call the control cycle makes) at the
|
||||
point's own start. A charge that comes after the last dear period meets a
|
||||
flat remainder there and is refused, so it is not announced. When the gate
|
||||
allows it, the point is still the cheapest (dearest) one: the points
|
||||
before it are gone and it was the first extreme of the points from now."""
|
||||
if now is None or pt.start <= now:
|
||||
return True
|
||||
return trade_verdict(c, plan, pt.start)[0] is not None
|
||||
|
||||
|
||||
def trade_legs(c, plan, verdict, now):
|
||||
"""`[(kind, point)]` by start: the verdict's charge ("grid") and give
|
||||
("give") legs that the add-on will take (`leg_taken()`)."""
|
||||
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
|
||||
key=lambda kp: kp[1].start)
|
||||
return [(k, pt) for k, pt in legs if leg_taken(c, plan, pt, now)]
|
||||
|
||||
|
||||
# Under this many kWh a block is not drawn: the cycle would not move it.
|
||||
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]
|
||||
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
|
||||
key=lambda kp: kp[1].start)
|
||||
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 legs:
|
||||
for kind, pt in trade_legs(c, plan, verdict, now):
|
||||
end = _point_end(plan, pt)
|
||||
if end <= now:
|
||||
continue
|
||||
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
|
||||
@@ -346,7 +381,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
|
||||
@@ -366,14 +402,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. Returns
|
||||
`(runs, no_trade_key)`."""
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
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)`.
|
||||
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:
|
||||
@@ -381,6 +425,7 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
if not rp.on:
|
||||
return [], "not_worth"
|
||||
floor, ceiling = arbitrage.soc_bounds(c.o)
|
||||
gate = getattr(c, "mode_gate", None)
|
||||
out = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
pm = c.pack_mode(inv)
|
||||
@@ -388,17 +433,26 @@ 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)
|
||||
if rp.charge_useful() and (need > 0.0 if need is not None
|
||||
else pm.mode in GRID_CHARGE_MODES):
|
||||
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):
|
||||
blocks.append(("grid", rp.cheap))
|
||||
sell = rp.sell if pm.sell and pm.can_sell else None
|
||||
for g in rp.give:
|
||||
@@ -410,7 +464,7 @@ 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:
|
||||
@@ -437,16 +491,18 @@ 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)
|
||||
|
||||
|
||||
@@ -589,6 +645,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)
|
||||
@@ -608,7 +678,7 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
runs, lines = [], []
|
||||
# The control cycle trades on the fresh plan only, so this does too.
|
||||
verdict, trade_why = trade_verdict(c, fresh, now)
|
||||
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict),
|
||||
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict, now),
|
||||
now, days.kinds)
|
||||
modes_on = bool(getattr(c, "modes_on", False))
|
||||
if running:
|
||||
@@ -978,6 +1048,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:
|
||||
@@ -1306,6 +1386,11 @@ 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)
|
||||
|
||||
@@ -1479,6 +1564,9 @@ def _samples() -> list:
|
||||
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,
|
||||
@@ -1001,6 +1002,10 @@ class Controller:
|
||||
self.mode_runs = None
|
||||
self._runs_key = None
|
||||
self.mode_tick = None
|
||||
# UX-53: why the trade gate refused a grid charge this tick (`peak`,
|
||||
# `not_worth`, `not_covered`), or None when it allowed one. Home reads
|
||||
# it; nothing in control does.
|
||||
self.mode_gate = None
|
||||
self._run_done = {}
|
||||
self._mode_trade_was = False
|
||||
self._charge_blocked_q = None
|
||||
@@ -1257,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
|
||||
@@ -2380,6 +2388,7 @@ class Controller:
|
||||
charge_ok = sell_ok and allowed(guard)
|
||||
trade_why = (None if charge_ok else "peak" if sell_ok
|
||||
else "not_worth")
|
||||
self.mode_gate = None if charge_ok else trade_why
|
||||
price_now = None
|
||||
if plan is not None:
|
||||
try:
|
||||
@@ -3376,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
|
||||
@@ -7630,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"):
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1103,8 +1103,12 @@ class TariffPlan:
|
||||
f"the tariff is flat - every one of the {len(firm)} firm "
|
||||
f"points, spanning {_whole_hours(firm)} hours, costs "
|
||||
f"{firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
|
||||
f"selling high is arithmetically impossible. §9.2: a fully "
|
||||
f"fixed product is self-consumption only")
|
||||
f"selling high is arithmetically impossible"
|
||||
# UX-53: only a real fixed product is "fully fixed". A
|
||||
# dynamic or day/night site whose remaining periods happen to
|
||||
# be equal trades again when they differ.
|
||||
+ (". §9.2: a fully fixed product is self-consumption only"
|
||||
if self.product is PriceProduct.FIXED else ""))
|
||||
|
||||
# REFUSAL 5, NEW IN FCST-03 (AC3). One widening path, driven by the set
|
||||
# of active uncertainty sources: the product's own (§9.2 row 2, derived
|
||||
|
||||
@@ -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"},
|
||||
@@ -367,11 +385,11 @@
|
||||
"run": {
|
||||
"grid_what": "Charges up to {kwh} from the grid.",
|
||||
"grid_what_would": "Would charge up to {kwh} from the grid.",
|
||||
"grid_why": "Lowest published price, {eur} per kWh.",
|
||||
"grid_why": "Lowest price left, {eur} per kWh.",
|
||||
"grid_amount": "Costs up to {eur}.",
|
||||
"give_what": "Gives up to {kwh}, to the house first.",
|
||||
"give_what_would": "Would give up to {kwh}, to the house first.",
|
||||
"give_why": "Highest published price, {eur} per kWh.",
|
||||
"give_why": "Highest price left, {eur} per kWh.",
|
||||
"sell_what": "Sells up to {kwh}, to the house first.",
|
||||
"sell_what_would": "Would sell up to {kwh}, to the house first.",
|
||||
"cheap_why": "Cheapest hours, about {eur} per kWh.",
|
||||
@@ -425,6 +443,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"},
|
||||
@@ -307,11 +325,11 @@
|
||||
"run": {
|
||||
"grid_what": "Laadt tot {kwh} van het net.",
|
||||
"grid_what_would": "Zou tot {kwh} van het net laden.",
|
||||
"grid_why": "Laagste gepubliceerde prijs, {eur} per kWh.",
|
||||
"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.",
|
||||
"give_what_would": "Zou tot {kwh} leveren, eerst aan het huis.",
|
||||
"give_why": "Hoogste gepubliceerde prijs, {eur} per kWh.",
|
||||
"give_why": "Hoogste prijs die nog over is, {eur} per kWh.",
|
||||
"sell_what": "Verkoopt tot {kwh}, eerst aan het huis.",
|
||||
"sell_what_would": "Zou tot {kwh} verkopen, eerst aan het huis.",
|
||||
"cheap_why": "Goedkoopste uren, ongeveer {eur} per kWh.",
|
||||
@@ -353,6 +371,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),
|
||||
|
||||
@@ -523,7 +523,7 @@ _lo = min(_q[:96], key=lambda p: p.eur_per_kwh)
|
||||
check("quarter-hour prices: the charge block, its price and the price line's "
|
||||
"lowest are the same 15-minute period",
|
||||
_grid and _grid[0]["from"] == homeview.hhmm(_lo.start)
|
||||
and _grid[0]["why"] == f"Lowest published price, €{_lo.eur_per_kwh:.2f} "
|
||||
and _grid[0]["why"] == f"Lowest price left, €{_lo.eur_per_kwh:.2f} "
|
||||
"per kWh."
|
||||
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
|
||||
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
|
||||
@@ -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 "
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
"""SAFETY-123: the day plan's local-hour loop ends on the clock-change days.
|
||||
`python3 test_safety123.py`
|
||||
|
||||
On a host with no tzdata (`tariff._EUTimezone`) `dayplan._hour_slices()` never
|
||||
returned on the spring clock change (28 March 2027, 00:00 UTC): the control
|
||||
tick froze. Graded here, each call under a hard time limit (SIGALRM):
|
||||
|
||||
1. `_hour_slices()` over 27-29 March and 30 Oct - 1 Nov 2027, and the US
|
||||
change days, at every quarter, in the no-tzdata zone, a US-rule zone built
|
||||
the same way, and (where the host has tzdata) Europe/Brussels,
|
||||
America/New_York and zones with half-hour changes: it ends, covers the
|
||||
span, never crosses a local hour, labels the local hour, and gives 23
|
||||
slices on the spring day and 25 on the autumn day.
|
||||
2. Normal days: the same slices as release/1.0 a1d0703 (a copy of its
|
||||
function is kept below as the reference).
|
||||
3. `dayplan.plan()` at every quarter of those days with each pack mode, and
|
||||
its answer on normal days equal to the old function's.
|
||||
4. `Controller._mode_roles()` at every quarter of those days with modes on,
|
||||
for Solar first, Leftover solar and Cheap hours, and one full control
|
||||
cycle through the forecast read at the hang's instants.
|
||||
See DECISIONS/SAFETY-123-hour-slices-step-in-utc.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import resource
|
||||
import signal
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone, tzinfo
|
||||
|
||||
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 opt11_identity as rig # noqa: E402
|
||||
import app.main as M # noqa: E402
|
||||
from app import dayplan, modetrade, packmode, priceruns, solarfc # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
from app.tariff import BILLING_TZ, _EUTimezone # noqa: E402
|
||||
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
LIMIT_S = 3.0 # one call; the old loop never returned
|
||||
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 Slow(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _alarm(*_a):
|
||||
raise Slow()
|
||||
|
||||
|
||||
def limited(fn, *a, **k):
|
||||
"""`fn(*a, **k)` or `Slow` when it takes more than LIMIT_S seconds."""
|
||||
signal.signal(signal.SIGALRM, _alarm)
|
||||
signal.setitimer(signal.ITIMER_REAL, LIMIT_S)
|
||||
try:
|
||||
return fn(*a, **k)
|
||||
except MemoryError:
|
||||
raise Slow() from None
|
||||
finally:
|
||||
signal.setitimer(signal.ITIMER_REAL, 0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The reference: release/1.0 a1d0703's function, verbatim, for normal days
|
||||
# --------------------------------------------------------------------------- #
|
||||
def old_slices(t0, t1):
|
||||
out, t = [], t0
|
||||
while t < t1:
|
||||
lt = dayplan._local(t)
|
||||
nxt = (lt.replace(minute=0, second=0, microsecond=0)
|
||||
+ timedelta(hours=1)).astimezone(timezone.utc)
|
||||
e = min(nxt, t1)
|
||||
out.append((t, e, lt.hour))
|
||||
t = e
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Zones
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _nth_sunday(year, month, n):
|
||||
d = date(year, month, 1)
|
||||
d += timedelta(days=(6 - d.weekday()) % 7 + 7 * (n - 1))
|
||||
return datetime(d.year, d.month, d.day)
|
||||
|
||||
|
||||
class USZone(tzinfo):
|
||||
"""America/New_York by the US rule (DST from the second Sunday of March
|
||||
02:00 to the first Sunday of November 02:00, local), built like
|
||||
tariff._EUTimezone: the offset is read from the local wall clock, so the
|
||||
missing 02:00 reads as DST. Its change days are not the EU's."""
|
||||
|
||||
def _dst_local(self, naive):
|
||||
y = naive.year
|
||||
return (_nth_sunday(y, 3, 2).replace(hour=2) <= naive
|
||||
< _nth_sunday(y, 11, 1).replace(hour=2))
|
||||
|
||||
def _dst_utc(self, naive_utc):
|
||||
y = naive_utc.year
|
||||
return (_nth_sunday(y, 3, 2).replace(hour=7) <= naive_utc
|
||||
< _nth_sunday(y, 11, 1).replace(hour=6))
|
||||
|
||||
def fromutc(self, dt):
|
||||
return dt + timedelta(
|
||||
hours=-4 if self._dst_utc(dt.replace(tzinfo=None)) else -5)
|
||||
|
||||
def utcoffset(self, dt):
|
||||
if dt is None:
|
||||
return timedelta(hours=-5)
|
||||
return timedelta(hours=-4 if self._dst_local(
|
||||
dt.replace(tzinfo=None)) else -5)
|
||||
|
||||
def dst(self, dt):
|
||||
return timedelta(hours=1) if dt is not None and self._dst_local(
|
||||
dt.replace(tzinfo=None)) else timedelta(0)
|
||||
|
||||
|
||||
def _q(y, mo, d, h=0):
|
||||
return datetime(y, mo, d, h, tzinfo=UTC)
|
||||
|
||||
|
||||
# (name, tzinfo, whole-hour offsets?, windows [from, to) at every quarter)
|
||||
BRU_SPRING = (_q(2027, 3, 27), _q(2027, 3, 30))
|
||||
BRU_AUTUMN = (_q(2027, 10, 30), _q(2027, 11, 2))
|
||||
US_SPRING = (_q(2027, 3, 13), _q(2027, 3, 16))
|
||||
US_AUTUMN = (_q(2027, 11, 6), _q(2027, 11, 9))
|
||||
COMMON = [BRU_SPRING, BRU_AUTUMN, US_SPRING, US_AUTUMN]
|
||||
ZONES = [("no-tzdata EU rule", _EUTimezone(), True, COMMON),
|
||||
("US rule", USZone(), True, COMMON)]
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
for _n, _w, _extra in (
|
||||
("Europe/Brussels", True, []),
|
||||
("America/New_York", True, []),
|
||||
# DST of half an hour, and clocks that jump at midnight
|
||||
("Australia/Lord_Howe", False,
|
||||
[(_q(2027, 4, 3), _q(2027, 4, 6)),
|
||||
(_q(2027, 10, 2), _q(2027, 10, 5))]),
|
||||
("America/Santiago", True,
|
||||
[(_q(2027, 4, 2), _q(2027, 4, 5)),
|
||||
(_q(2027, 9, 4), _q(2027, 9, 7))]),
|
||||
("Asia/Kathmandu", False, [])):
|
||||
try:
|
||||
ZONES.append((_n, ZoneInfo(_n), _w, COMMON + _extra))
|
||||
except Exception: # noqa: BLE001
|
||||
print(f" no tzdata for {_n} here: not graded")
|
||||
except ImportError:
|
||||
print(" no zoneinfo here")
|
||||
print(" zones graded:", ", ".join(z[0] for z in ZONES))
|
||||
|
||||
|
||||
class In:
|
||||
"""`dayplan.BILLING_TZ` is `tz` inside the with-block."""
|
||||
|
||||
def __init__(self, tz):
|
||||
self.tz = tz
|
||||
|
||||
def __enter__(self):
|
||||
self.was = dayplan.BILLING_TZ
|
||||
dayplan.BILLING_TZ = self.tz
|
||||
|
||||
def __exit__(self, *_a):
|
||||
dayplan.BILLING_TZ = self.was
|
||||
|
||||
|
||||
def quarters(a, b):
|
||||
t = a
|
||||
while t < b:
|
||||
yield t
|
||||
t += timedelta(minutes=15)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. _hour_slices(): ends, covers, local hours, 23 and 25 hours")
|
||||
HOUR = timedelta(hours=1)
|
||||
for zname, tz, whole, windows in ZONES:
|
||||
bad, slow, n = [], None, 0
|
||||
with In(tz):
|
||||
for a, b in windows:
|
||||
for t0 in quarters(a, b):
|
||||
for span in (HOUR, 7 * HOUR, 30 * HOUR):
|
||||
t1 = t0 + span
|
||||
n += 1
|
||||
try:
|
||||
sl = limited(dayplan._hour_slices, t0, t1)
|
||||
except Slow:
|
||||
slow = (t0, t1)
|
||||
break
|
||||
ok = bool(sl) and sl[0][0] == t0 and sl[-1][1] == t1
|
||||
ok = ok and all(x[1] == y[0] for x, y in zip(sl, sl[1:]))
|
||||
for s, e, h in sl:
|
||||
ok = ok and timedelta(0) < e - s <= HOUR
|
||||
lt = s.astimezone(tz)
|
||||
ok = ok and h == lt.hour
|
||||
last = (e - timedelta(microseconds=1)).astimezone(tz)
|
||||
# no local hour boundary inside the slice
|
||||
ok = ok and (lt.year, lt.month, lt.day, lt.hour) == (
|
||||
last.year, last.month, last.day, last.hour)
|
||||
# it ends on a local hour, the span's end, or where
|
||||
# the clock itself jumps (half-hour changes)
|
||||
el = e.astimezone(tz)
|
||||
ok = ok and (e == t1 or el.minute == 0
|
||||
or el.utcoffset() != lt.utcoffset())
|
||||
if whole:
|
||||
# whole-hour zones: local hours are UTC hours
|
||||
ok = ok and (e.minute == 0 or e == t1) \
|
||||
and (s.minute == 0 or s == t0)
|
||||
if not ok:
|
||||
bad.append((t0, t1))
|
||||
if slow:
|
||||
break
|
||||
if slow:
|
||||
break
|
||||
check(f"{zname}: every quarter x 1, 7, 30 h ({n} spans) returns within "
|
||||
f"{LIMIT_S:.0f} s" + ("" if not slow else f" - HUNG at {slow[0]}"),
|
||||
slow is None)
|
||||
check(f"{zname}: ...contiguous from t0 to t1, each slice at most an "
|
||||
f"hour, labelled with its local hour, none across a local hour",
|
||||
not bad)
|
||||
|
||||
# The change days: 23 and 25 local hours, from local midnight to midnight.
|
||||
# (spring day, autumn day, the local hour that happens twice in autumn)
|
||||
BRU_DAYS = ((2027, 3, 28), (2027, 10, 31), 2)
|
||||
US_DAYS = ((2027, 3, 14), (2027, 11, 7), 1)
|
||||
DAYS = {"no-tzdata EU rule": BRU_DAYS, "Europe/Brussels": BRU_DAYS,
|
||||
"US rule": US_DAYS, "America/New_York": US_DAYS}
|
||||
|
||||
|
||||
def local_day(tz, y, m, d):
|
||||
d0 = datetime(y, m, d, tzinfo=tz).astimezone(UTC)
|
||||
n = date(y, m, d) + timedelta(days=1)
|
||||
return d0, datetime(n.year, n.month, n.day, tzinfo=tz).astimezone(UTC)
|
||||
|
||||
|
||||
for zname, tz, _w, _win in ZONES:
|
||||
if zname not in DAYS:
|
||||
continue
|
||||
spring, autumn, twice = DAYS[zname]
|
||||
with In(tz):
|
||||
for what, (y, m, d), want, want_hours in (
|
||||
("spring", spring, 23, [h for h in range(24) if h != 2]),
|
||||
("autumn", autumn, 25,
|
||||
[h for h in range(24) for _ in range(2 if h == twice else 1)])):
|
||||
d0, d1 = local_day(tz, y, m, d)
|
||||
try:
|
||||
sl = limited(dayplan._hour_slices, d0, d1)
|
||||
except Slow:
|
||||
sl = None
|
||||
check(f"{zname}, {what} day {y}-{m:02d}-{d:02d}: {want} one-hour "
|
||||
f"slices from local midnight to midnight",
|
||||
sl is not None and len(sl) == want
|
||||
and all(e - s == HOUR for s, e, _h in sl)
|
||||
and d1 - d0 == want * HOUR and sl[0][0] == d0
|
||||
and sl[-1][1] == d1)
|
||||
check(f"{zname}, {what} day: the local hours are "
|
||||
f"{'0-23 without 2' if what == 'spring' else f'0-23 with {twice} twice'}",
|
||||
sl is not None and [h for _s, _e, h in sl] == want_hours)
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. normal days: the same slices as release/1.0 a1d0703")
|
||||
NORMAL = [(_q(2027, 1, 9), _q(2027, 1, 12)), (_q(2027, 6, 18), _q(2027, 6, 21)),
|
||||
(_q(2027, 8, 30), _q(2027, 9, 2)), (_q(2027, 12, 18), _q(2027, 12, 21))]
|
||||
for zname, tz, _w, _win in ZONES:
|
||||
diff, n = [], 0
|
||||
with In(tz):
|
||||
for a, b in NORMAL:
|
||||
for t0 in quarters(a, b):
|
||||
for off in (timedelta(0), timedelta(seconds=7, microseconds=5),
|
||||
timedelta(minutes=14, seconds=59, microseconds=999999),
|
||||
timedelta(minutes=37, seconds=1)):
|
||||
for span in (timedelta(minutes=1), timedelta(minutes=50),
|
||||
3 * HOUR, 26 * HOUR):
|
||||
s0 = t0 + off
|
||||
n += 1
|
||||
if dayplan._hour_slices(s0, s0 + span) != old_slices(
|
||||
s0, s0 + span):
|
||||
diff.append(s0)
|
||||
check(f"{zname}: {n} spans on ordinary days equal the old slices "
|
||||
f"(including starts off the quarter)", n > 1000 and not diff)
|
||||
with In(ZONES[0][1]):
|
||||
s0 = _q(2027, 1, 10, 9)
|
||||
eq = dayplan._hour_slices(s0, s0) == [] == old_slices(s0, s0) \
|
||||
and dayplan._hour_slices(s0, s0 - HOUR) == []
|
||||
check("an empty or backwards span gives no slices, as before", eq)
|
||||
plus2 = timezone(timedelta(hours=2))
|
||||
t0 = datetime(2027, 6, 1, 10, 20, tzinfo=plus2)
|
||||
check("a start given in another zone still gives the old slices",
|
||||
dayplan._hour_slices(t0, t0 + 3 * HOUR) ==
|
||||
old_slices(t0, t0 + 3 * HOUR))
|
||||
|
||||
# ============================================================================ #
|
||||
print("3. dayplan.plan() at every quarter of the change days")
|
||||
|
||||
|
||||
def st(kwh, at):
|
||||
return {"state": str(kwh), "attributes": {"unit_of_measurement": "kWh"},
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
def cheap_dear(h):
|
||||
return 0.08 if 1.0 <= h < 5.0 else 0.34 if 17.0 <= h < 21.0 else 0.20
|
||||
|
||||
|
||||
def price_plan(now, f=cheap_dear, days=4):
|
||||
"""15-minute firm prices from a day before `now` for `days` days."""
|
||||
start = (now - timedelta(days=1)).replace(minute=0, second=0,
|
||||
microsecond=0)
|
||||
pts = []
|
||||
for i in range(days * 96 + 96):
|
||||
b = brussels(start + timedelta(minutes=15 * i))
|
||||
pts.append(PricePoint(start=b, eur_per_kwh=f(b.hour + b.minute / 60),
|
||||
provisional=False))
|
||||
return TariffPlan(PriceRead.series(pts, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", now), None,
|
||||
"provider")
|
||||
|
||||
|
||||
_PLANS = {}
|
||||
|
||||
|
||||
def plan_for(start):
|
||||
"""One price plan covering `start` - 1 day .. + 5 days, kept: building it
|
||||
for every quarter is most of the run time."""
|
||||
if start not in _PLANS:
|
||||
_PLANS[start] = price_plan(start, days=6)
|
||||
return _PLANS[start]
|
||||
|
||||
|
||||
HOUSE = dayplan.HouseProfile({"2027-03-20": {
|
||||
str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}})
|
||||
MODES = {"Solar first": (packmode.SOLAR_FIRST, packmode.SOLAR_FIRST),
|
||||
"Leftover solar": (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR),
|
||||
"Cheap hours": (packmode.CHEAP_HOURS, packmode.CHEAP_HOURS)}
|
||||
|
||||
|
||||
def packs_of(modes, soc=(55.0, 40.0)):
|
||||
return [modetrade.Pack(f"inverter-{u}", True, mode, False, 2500.0, s, 10.0)
|
||||
for u, mode, s in zip("ab", modes, soc)]
|
||||
|
||||
|
||||
_INPUTS = {}
|
||||
|
||||
|
||||
def plan_at(now, modes, start=None):
|
||||
"""plan() on the price runs and forecast of `now` (made once per quarter:
|
||||
the runs do not depend on the pack modes)."""
|
||||
if now not in _INPUTS:
|
||||
pts = plan_for(start or now.replace(hour=0, minute=0)).read.points
|
||||
_INPUTS[now] = (
|
||||
priceruns.plan(pts, now, required_eur_kwh=0.1, charge_kwh=8.0,
|
||||
charge_kw=5.0, sell_kwh=0.0, sell_kw=0.0),
|
||||
solarfc.read(st(2.0, now), st(8.0, now), now)[0])
|
||||
runs, fc = _INPUTS[now]
|
||||
return dayplan.plan(fc, HOUSE.estimator(), runs, now, packs_of(modes),
|
||||
trust_pct=70, floor_pct=20, ceiling_pct=95)
|
||||
|
||||
|
||||
spy = []
|
||||
_slices = dayplan._hour_slices
|
||||
dayplan._hour_slices = lambda a, b: (spy.append((a, b)), _slices(a, b))[1]
|
||||
try:
|
||||
for mname, modes in MODES.items():
|
||||
for wname, (a, b) in (("27-29 March", BRU_SPRING),
|
||||
("30 Oct-1 Nov", BRU_AUTUMN)):
|
||||
made, slow, n = 0, None, 0
|
||||
spy.clear()
|
||||
for now in quarters(a, b):
|
||||
n += 1
|
||||
try:
|
||||
p = limited(plan_at, now, modes, a)
|
||||
except Slow:
|
||||
slow = now
|
||||
break
|
||||
made += p is not None
|
||||
check(f"plan(), {mname}, {wname}: {n} quarters return within "
|
||||
f"{LIMIT_S:.0f} s" + ("" if not slow else
|
||||
f" - HUNG at {slow}")
|
||||
+ f"; a plan is made at {made} of them", slow is None
|
||||
and made > 20)
|
||||
if wname == "27-29 March" and not slow:
|
||||
cross = [s for s in spy if s[0] <= _q(2027, 3, 28) < s[1]]
|
||||
check(f"plan(), {mname}: {len(cross)} spans ran across the "
|
||||
"change (28 March 00:00 UTC): the test reaches the "
|
||||
"loop", len(cross) > 20)
|
||||
finally:
|
||||
dayplan._hour_slices = _slices
|
||||
|
||||
# The answer on normal days is the old function's.
|
||||
for mname, modes in MODES.items():
|
||||
same, n = True, 0
|
||||
for now in quarters(_q(2027, 1, 11), _q(2027, 1, 13)):
|
||||
new = plan_at(now, modes)
|
||||
dayplan._hour_slices = old_slices
|
||||
try:
|
||||
was = plan_at(now, modes)
|
||||
finally:
|
||||
dayplan._hour_slices = _slices
|
||||
n += 1
|
||||
same = same and new == was
|
||||
check(f"plan(), {mname}: {n} quarters on ordinary days equal the old "
|
||||
"answer", same)
|
||||
|
||||
# ============================================================================ #
|
||||
print("4. Controller._mode_roles() at every quarter, modes on")
|
||||
|
||||
|
||||
def controller(modes, now):
|
||||
extra = {"battery_modes_enabled": True, "arbitrage_enabled": True,
|
||||
"cycle_cost_threshold_eur": 0.05,
|
||||
"pv_forecast_entities": ["sensor.t1", "sensor.t2", "sensor.t3"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.m1", "sensor.m2",
|
||||
"sensor.m3"]}
|
||||
opts = rig._options(2, extra)
|
||||
for i in opts["inverters"]:
|
||||
i["capacity_kwh"] = 10.0
|
||||
store = rig._Store()
|
||||
for u, mode in zip("ab", modes):
|
||||
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
|
||||
store.data["house_profile"] = {"2027-03-20": {
|
||||
str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}}
|
||||
|
||||
class Hass(rig._Hass):
|
||||
async def state(self, e):
|
||||
if e.startswith(("sensor.t", "sensor.m")):
|
||||
return st(2.0 if e.startswith("sensor.t") else 8.0, self.at)
|
||||
return await super().state(e)
|
||||
hass = Hass(("a", "b"), lambda u, t: 55.0 if u == "a" else 40.0,
|
||||
lambda u, t: False)
|
||||
hass.at = now
|
||||
c = M.Controller(opts, hass, store, rig._Pub(), stop=asyncio.Event())
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
i.soc = 55.0 if i.name.endswith("a") else 40.0
|
||||
c.auto = True
|
||||
return c, hass
|
||||
|
||||
|
||||
for mname, modes in MODES.items():
|
||||
for wname, (a, b) in (("27-29 March", BRU_SPRING),
|
||||
("30 Oct-1 Nov", BRU_AUTUMN)):
|
||||
c, hass = controller(modes, a)
|
||||
slow, n, made, roles = None, 0, 0, set()
|
||||
spy.clear()
|
||||
dayplan._hour_slices = lambda x, y: (spy.append((x, y)),
|
||||
_slices(x, y))[1]
|
||||
try:
|
||||
for now in quarters(a, b):
|
||||
n += 1
|
||||
c.tariff_plan, c.tariff_at = plan_for(a), now
|
||||
c.solar_fc, _w, _g = solarfc.read_sum(
|
||||
[("sensor.t1", st(2.0, now))],
|
||||
[("sensor.m1", st(8.0, now))], now)
|
||||
try:
|
||||
t = limited(c._mode_roles, now, None)
|
||||
except Slow:
|
||||
slow = now
|
||||
break
|
||||
made += c.day_plan is not None
|
||||
roles |= set(t.roles.values())
|
||||
finally:
|
||||
dayplan._hour_slices = _slices
|
||||
check(f"_mode_roles(), {mname}, {wname}: {n} quarters return within "
|
||||
f"{LIMIT_S:.0f} s" + ("" if not slow else f" - HUNG at {slow}"),
|
||||
slow is None)
|
||||
check(f"...a day plan is made at {made} of them (the loop is "
|
||||
f"reached), roles seen {sorted(roles)}", slow is None
|
||||
and made > 20 and len(spy) > 20)
|
||||
|
||||
# One whole control cycle through the real forecast read (three sensors a
|
||||
# day) at the instants the old loop hung: the page and control share the loop.
|
||||
for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00",
|
||||
"2027-03-28T00:00:05+00:00"):
|
||||
now = datetime.fromisoformat(iso)
|
||||
c, hass = controller(MODES["Leftover solar"], now)
|
||||
real_dt = M.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None, _n=now):
|
||||
return _n if tz is None else _n.astimezone(tz)
|
||||
M.datetime = _DT
|
||||
try:
|
||||
async def one():
|
||||
await c.read_inputs(now_mono=0.0)
|
||||
c.tariff_plan, c.tariff_at = price_plan(now), now
|
||||
await c.cycle()
|
||||
try:
|
||||
limited(lambda: asyncio.run(one()))
|
||||
ok = c.solar_fc is not None and c.day_plan is not None
|
||||
except Slow:
|
||||
ok = False
|
||||
finally:
|
||||
M.datetime = real_dt
|
||||
check(f"a whole control cycle at {iso[:16]}Z (three forecast sensors a "
|
||||
"day) returns with a day plan", ok)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all ok")
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
"""Runnable check for UX-53. `python3 test_ux53.py`
|
||||
|
||||
Home draws a grid charge only where the add-on will take it.
|
||||
|
||||
1. Modes on (F-1): inside a cheap run that is under way, the trade gate
|
||||
(`TariffPlan.arbitrage()`, as `Controller._mode_roles()` asks it) refuses
|
||||
with `not_worth`. The control cycle files that as `Controller.mode_gate`,
|
||||
and Home draws no charge, and marks no cheap quarter, from then on. With
|
||||
the gate open the charge is drawn as before. The real `_mode_roles()` runs
|
||||
at each instant, so the page is graded against the decision it makes.
|
||||
2. Modes off (F-2): the verdict's charge comes after its last dear period.
|
||||
Home asks the same gate at the charge's own start (`homeview.leg_taken()`):
|
||||
a flat remainder is refused there, so no charge is drawn and no quarter is
|
||||
marked cheap. A charge the gate allows at its start is drawn as before, and
|
||||
so is the sale.
|
||||
3. Wording, English and Dutch: "lowest price left", and "a fully fixed
|
||||
product" only for a real fixed product.
|
||||
4. Day/night and price-off pages are the same as before.
|
||||
|
||||
Mutations (MUTATION.md, purged copies): listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
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"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from app import arbitrage, homeview, modetrade, packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.peakguard import PeakDecision # noqa: E402
|
||||
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
||||
Uncertainty, brussels)
|
||||
from app.static_tariff import TariffPlan, static_plan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
class _Store:
|
||||
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 []
|
||||
|
||||
|
||||
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": False, "arbitrage_enabled": True,
|
||||
"cycle_cost_threshold_eur": 0.10,
|
||||
"inverters": [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.a",
|
||||
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
|
||||
"max_w": 2500, "capacity_kwh": 2.0},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.b",
|
||||
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
|
||||
"max_w": 2500, "capacity_kwh": 2.0}]}
|
||||
|
||||
# Tuesday 27 October 2026 in Brussels (UTC+1 since 25 October).
|
||||
DAY0 = datetime(2026, 10, 26, 23, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def at(h, m=0, s=0):
|
||||
return DAY0 + timedelta(hours=h, minutes=m, seconds=s)
|
||||
|
||||
|
||||
def quarters(price_of, days=2, product=PriceProduct.DAY_AHEAD):
|
||||
"""48 hours of 15-minute prices; `price_of(local_hour_float)` -> EUR/kWh."""
|
||||
pts = []
|
||||
for i in range(96 * days):
|
||||
t = DAY0 + timedelta(minutes=15 * i)
|
||||
pts.append(PricePoint(brussels(t), price_of((i % 96) / 4.0), False))
|
||||
return TariffPlan(PriceRead.series(tuple(pts), product,
|
||||
"generic_dayahead", DAY0), None,
|
||||
"provider")
|
||||
|
||||
|
||||
def make(opts, plan, now, *, modes=None, soc=(50.0, 50.0)):
|
||||
c = Controller(dict(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
|
||||
c.grid = 500.0
|
||||
c.tariff_plan, c.tariff_at = plan, now
|
||||
c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0,
|
||||
ceiling_from="cap", projected_w=800.0)
|
||||
c.arb_headroom_w = 3000.0
|
||||
if modes:
|
||||
for inv in c.inverters:
|
||||
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes
|
||||
return c
|
||||
|
||||
|
||||
def build(c, now):
|
||||
return homeview.build(c, level="ok", checks=[], now=now)["plan"]
|
||||
|
||||
|
||||
def kinds_at(plan_json, label):
|
||||
return [b["kind"] for b in plan_json["prices"] if b["label"] == label]
|
||||
|
||||
|
||||
def grid_blocks(plan_json):
|
||||
return [r for r in plan_json["runs"] if r["kind"] == "grid"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. modes on: the gate refuses inside a cheap run (F-1)")
|
||||
# All-in prices: 0.10 everywhere, 20:00 0.01, 20:15 0.09, 21:00-22:00 0.22. The
|
||||
# cheap run is 20:00-20:30. At 20:00 the spread is 0.21. From 20:15 the quarters
|
||||
# ahead are 0.09 to 0.22: 0.13 does not clear the 0.15 this cycle needs (0.10
|
||||
# widened by 1.5 for the missing weather forecast).
|
||||
def f1_price(h):
|
||||
if h == 20.0:
|
||||
return 0.01
|
||||
if h == 20.25:
|
||||
return 0.09
|
||||
if 21.0 <= h < 22.0:
|
||||
return 0.22
|
||||
return 0.10
|
||||
|
||||
|
||||
F1_PLAN = quarters(f1_price, days=1)
|
||||
c1 = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(19, 0, 5),
|
||||
modes=packmode.CHEAP_HOURS)
|
||||
|
||||
|
||||
def f1(now):
|
||||
"""One real control tick of `_mode_roles()` at `now`, then Home."""
|
||||
c1.tariff_at = now
|
||||
tick = c1._mode_roles(now, None)
|
||||
return tick, build(c1, now)
|
||||
|
||||
|
||||
t_pre, p_pre = f1(at(19, 0, 5)) # builds the run plan
|
||||
t_open, p_open = f1(at(20, 0, 5))
|
||||
print(f" 20:00:05 roles {t_open.roles}, action {t_open.action}, "
|
||||
f"gate {c1.mode_gate}, runs {c1.mode_runs.cheap}")
|
||||
check("gate open (20:00): the control cycle charges, mode_gate is None, and "
|
||||
"Home draws the charge on both batteries, 20:00 to 20:30",
|
||||
t_open.action == modetrade.CHARGE and c1.mode_gate is None
|
||||
and {r["battery"] for r in grid_blocks(p_open)} == {0, 1}
|
||||
and all(r["from"] == "20:00" and r["to"] == "20:30"
|
||||
for r in grid_blocks(p_open)))
|
||||
check("...and marks the run's two quarters cheap on the chart",
|
||||
kinds_at(p_open, "20:00") == ["cheap"]
|
||||
and kinds_at(p_open, "20:15") == ["cheap"])
|
||||
t_mid, p_mid = f1(at(20, 7, 30))
|
||||
check("gate still open at 20:07: the charge goes on and is still drawn",
|
||||
t_mid.action == modetrade.CHARGE and c1.mode_gate is None
|
||||
and len(grid_blocks(p_mid)) == 2)
|
||||
c1.inverters[0].soc = c1.inverters[1].soc = 52.0
|
||||
t_ref, p_ref = f1(at(20, 17, 52))
|
||||
print(f" 20:17:52 roles {t_ref.roles}, action {t_ref.action}, why "
|
||||
f"{t_ref.why}, gate {c1.mode_gate}, run {c1.mode_runs.cheap}")
|
||||
check("the run is still kept (20:00-20:30) and the gate refuses with "
|
||||
"not_worth at 20:17: no charge, why not_worth",
|
||||
c1.mode_runs.cheap.holds(at(20, 17, 52)) and c1.mode_gate == "not_worth"
|
||||
and t_ref.action is None and t_ref.why == "not_worth"
|
||||
and all(r != modetrade.CHARGE for r in t_ref.roles.values()))
|
||||
check("Home draws no charge block for the refused part, on any battery",
|
||||
grid_blocks(p_ref) == [] and not any(
|
||||
r["what"].startswith("Charges") for r in p_ref["runs"]))
|
||||
check("...marks no quarter from 20:15 cheap, keeps the 20:00 quarter it "
|
||||
"did charge in, and keeps the give run marked dear",
|
||||
kinds_at(p_ref, "20:00") == ["cheap"]
|
||||
and kinds_at(p_ref, "20:15") == ["mid"]
|
||||
and kinds_at(p_ref, "20:30") == ["mid"]
|
||||
and kinds_at(p_ref, "21:00") == ["dear"])
|
||||
saved = c1.mode_gate
|
||||
c1.mode_gate = None # what Home did before UX-53
|
||||
p_old = build(c1, at(20, 17, 52))
|
||||
c1.mode_gate = saved
|
||||
check("the same moment without the gate record draws the charge again (the "
|
||||
"defect, shown): so the fixture is the F-1 case",
|
||||
len(grid_blocks(p_old)) == 2 and kinds_at(p_old, "20:15") == ["cheap"])
|
||||
row = homeview.battery_rows(c1, False, True)[0]
|
||||
check("the battery card says what Home now says: it rests (role hold), and "
|
||||
"does not say it charges",
|
||||
t_ref.roles["inverter-a"] == modetrade.HOLD
|
||||
and row["why_key"] in ("saves", "saves_later"))
|
||||
t_n, p_n = f1(at(20, 31, 0))
|
||||
check("after the run: nothing is drawn, as before",
|
||||
grid_blocks(p_n) == [] and c1.mode_gate == "not_worth")
|
||||
|
||||
# Other refusals of the gate are followed the same way: no charge is drawn
|
||||
# while the control cycle's gate says no.
|
||||
c1b = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5),
|
||||
modes=packmode.CHEAP_HOURS)
|
||||
c1b.tariff_at = at(20, 0, 5)
|
||||
c1b.peak_guard = PeakDecision(engaged=True, floor_w=1000.0, ceiling_w=3500.0,
|
||||
ceiling_from="cap", projected_w=3600.0)
|
||||
tb = c1b._mode_roles(at(20, 0, 5), None)
|
||||
check("the peak guard holding refuses the charge (gate 'peak'): the cycle "
|
||||
"does not charge and Home draws no charge",
|
||||
c1b.mode_gate == "peak" and tb.action is None
|
||||
and grid_blocks(build(c1b, at(20, 0, 5))) == [])
|
||||
c1c = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5),
|
||||
modes=packmode.CHEAP_HOURS)
|
||||
c1c.peak_guard = None
|
||||
tc = c1c._mode_roles(at(20, 0, 5), None)
|
||||
check("a peak guard that measures nothing refuses it too (gate "
|
||||
"'not_covered'): no charge, none drawn",
|
||||
c1c.mode_gate == "not_covered" and tc.action is None
|
||||
and grid_blocks(build(c1c, at(20, 0, 5))) == [])
|
||||
c1d = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5),
|
||||
modes=packmode.CHEAP_HOURS)
|
||||
c1d.arb_headroom_w = None
|
||||
td = c1d._mode_roles(at(20, 0, 5), None)
|
||||
check("a refusal that is not the gate (no measured headroom yet) is not the "
|
||||
"gate's: mode_gate stays None and the charge is still drawn",
|
||||
c1d.mode_gate is None and td.why == "headroom"
|
||||
and len(grid_blocks(build(c1d, at(20, 0, 5)))) == 2)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. modes off: a charge after the last dear period (F-2)")
|
||||
NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
|
||||
|
||||
|
||||
def decide_at(c, now, soc=50.0):
|
||||
"""What the control cycle does at `now` on the plan (modes off)."""
|
||||
return arbitrage.decide(
|
||||
c.tariff_plan, now, peak_guard_active=False, charge_headroom_w=5000.0,
|
||||
soc=soc, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty=NO_WEATHER, charge_w=2500.0, discharge_w=2500.0,
|
||||
soc_floor_pct=10.0, soc_ceiling_pct=90.0)
|
||||
|
||||
|
||||
OFF = dict(BASE)
|
||||
|
||||
|
||||
def f2_price(h):
|
||||
return 0.54 if 21.5 <= h < 21.75 else 0.24
|
||||
|
||||
|
||||
F2_PLAN = quarters(f2_price, days=1)
|
||||
c2 = make(OFF, F2_PLAN, at(21, 36, 0))
|
||||
p2 = build(c2, at(21, 36, 0))
|
||||
g2 = [r for r in p2["runs"] if r["kind"] == "give"]
|
||||
v2, _w = homeview.trade_verdict(c2, F2_PLAN, at(21, 36, 0))
|
||||
print(f" 21:36 verdict: cheapest {homeview.hhmm(v2.cheapest.start)}, "
|
||||
f"dearest {homeview.hhmm(v2.dearest.start)}")
|
||||
check("the fixture: at 21:36 the verdict's cheapest quarter (21:45) comes "
|
||||
"after its dearest (21:30, in progress), and the rest of the day is flat",
|
||||
homeview.hhmm(v2.cheapest.start) == "21:45"
|
||||
and homeview.hhmm(v2.dearest.start) == "21:30")
|
||||
check("the add-on sells in 21:30, and at 21:45 it does nothing: idle, the "
|
||||
"tariff is flat",
|
||||
decide_at(c2, at(21, 30, 5)).action == arbitrage.DISCHARGE
|
||||
and decide_at(c2, at(21, 45, 5)).action is None
|
||||
and "the tariff is flat" in decide_at(c2, at(21, 45, 5)).reason)
|
||||
check("Home draws the sale in progress and NO charge at 21:45",
|
||||
len(g2) == 2 and all(r["from"] == "21:30" for r in g2)
|
||||
and grid_blocks(p2) == [])
|
||||
check("...and marks 21:30 dear and 21:45 not cheap",
|
||||
kinds_at(p2, "21:30") == ["dear"] and kinds_at(p2, "21:45") == ["mid"])
|
||||
real = homeview.leg_taken
|
||||
homeview.leg_taken = lambda *a, **k: True # Home before UX-53
|
||||
p2_old = build(c2, at(21, 36, 0))
|
||||
homeview.leg_taken = real
|
||||
check("without the question to the gate the page draws the 21:45 charge "
|
||||
"(the defect, shown): so the fixture is the F-2 case",
|
||||
[r["from"] for r in grid_blocks(p2_old)] == ["21:45", "21:45"]
|
||||
and kinds_at(p2_old, "21:45") == ["cheap"])
|
||||
|
||||
# A charge the gate allows at its own start is still drawn, with the sale.
|
||||
def q_price(h):
|
||||
return {20.0: 0.10, 20.5: 0.12, 21.0: 0.59, 21.5: 0.54}.get(h, 0.24)
|
||||
|
||||
|
||||
Q_PLAN = quarters(q_price, days=1)
|
||||
c3 = make(OFF, Q_PLAN, at(20, 20, 0))
|
||||
p3 = build(c3, at(20, 20, 0))
|
||||
v3, _w = homeview.trade_verdict(c3, Q_PLAN, at(20, 20, 0))
|
||||
check("gate allows (20:20, charge 20:30, sale 21:00): both are drawn, and "
|
||||
"the add-on does charge at 20:30 and sell at 21:00",
|
||||
[r["from"] for r in grid_blocks(p3)] == ["20:30", "20:30"]
|
||||
and [r["from"] for r in p3["runs"] if r["kind"] == "give"] == ["21:00"] * 2
|
||||
and decide_at(c3, at(20, 30, 5)).action == arbitrage.CHARGE
|
||||
and decide_at(c3, at(21, 0, 5)).action == arbitrage.DISCHARGE
|
||||
and kinds_at(p3, "20:30") == ["cheap"] and kinds_at(p3, "21:00") == ["dear"])
|
||||
check("...and the charge in progress at 20:30 is drawn too",
|
||||
len(grid_blocks(build(c3, at(20, 35, 0)))) == 2)
|
||||
|
||||
# Every probe on a day-ahead day: the charge is drawn exactly where the add-on
|
||||
# will take it. "Takes it" is the control cycle's own `decide()` at the start
|
||||
# of the charge's period, on the same plan.
|
||||
def sweep(plan, opts, lo, hi, step=15):
|
||||
rows = []
|
||||
for m in range(lo, hi, step):
|
||||
now = at(0, 0, 0) + timedelta(minutes=m, seconds=7)
|
||||
c = make(opts, plan, now)
|
||||
v, _w = homeview.trade_verdict(c, plan, now)
|
||||
if v is None:
|
||||
rows.append((now, None, None, None))
|
||||
continue
|
||||
legs = homeview.trade_legs(c, plan, v, now)
|
||||
drawn = {k: homeview.hhmm(pt.start) for k, pt in legs}
|
||||
rows.append((now, v, drawn, [k for k, _pt in legs]))
|
||||
return rows
|
||||
|
||||
|
||||
def takes(c, plan, kind, pt, now):
|
||||
"""True when decide() acts in that leg's first moment (or now, when it
|
||||
is already under way)."""
|
||||
t = max(now, pt.start + timedelta(seconds=5))
|
||||
d = decide_at(c, t)
|
||||
return d.action == (arbitrage.CHARGE if kind == "grid"
|
||||
else arbitrage.DISCHARGE)
|
||||
|
||||
|
||||
bad, nlegs, nsuppressed = [], 0, 0
|
||||
for now, v, drawn, kinds in sweep(F2_PLAN, OFF, 0, 24 * 60):
|
||||
if v is None:
|
||||
continue
|
||||
c = make(OFF, F2_PLAN, now)
|
||||
for kind, pt in (("grid", v.cheapest), ("give", v.dearest)):
|
||||
nlegs += 1
|
||||
shown = kind in kinds
|
||||
if not shown:
|
||||
nsuppressed += 1
|
||||
if shown != takes(c, F2_PLAN, kind, pt, now):
|
||||
bad.append((homeview.hhmm(now), kind, shown))
|
||||
print(f" {nlegs} verdict legs over a day, {nsuppressed} not drawn, "
|
||||
f"{len(bad)} where Home and decide() disagree {bad[:3]}")
|
||||
check("over a whole day of 15-minute probes, a leg is drawn exactly when "
|
||||
"decide() acts on it at its start; and some are not drawn",
|
||||
not bad and nsuppressed > 0)
|
||||
|
||||
# The same, on 40 random days of 15-minute prices (rounded to a cent, so there
|
||||
# are ties), today only, every quarter probed: Home and decide() agree on every
|
||||
# leg, and the page does drop some.
|
||||
import random # noqa: E402
|
||||
rnd = random.Random(53)
|
||||
n_leg = n_drop = 0
|
||||
disagree = []
|
||||
for day in range(40):
|
||||
shape = [round(rnd.choice((0.10, 0.18, 0.24, 0.24, 0.30, 0.45, 0.60))
|
||||
+ rnd.randrange(0, 3) * 0.01, 2) for _ in range(96)]
|
||||
if day % 2:
|
||||
shape = [shape[i // 4 * 4] for i in range(96)] # hour-constant
|
||||
plan = quarters(lambda h, sh=shape: sh[int(h * 4)], days=1)
|
||||
c = make(OFF, plan, at(0, 0, 0))
|
||||
for q in range(96):
|
||||
now = at(0, 0, 0) + timedelta(minutes=15 * q, seconds=7)
|
||||
v, _w = homeview.trade_verdict(c, plan, now)
|
||||
if v is None:
|
||||
continue
|
||||
kinds = [k for k, _pt in homeview.trade_legs(c, plan, v, now)]
|
||||
for kind, pt in (("grid", v.cheapest), ("give", v.dearest)):
|
||||
n_leg += 1
|
||||
shown = kind in kinds
|
||||
n_drop += not shown
|
||||
if shown != takes(c, plan, kind, pt, now):
|
||||
disagree.append((day, homeview.hhmm(now), kind, shown))
|
||||
print(f" 40 random days: {n_leg} verdict legs, {n_drop} not drawn, "
|
||||
f"{len(disagree)} disagreements with decide() {disagree[:3]}")
|
||||
check("40 random 15-minute days, every quarter: a leg is drawn exactly "
|
||||
"when decide() acts on it at its start",
|
||||
not disagree and n_leg > 3000 and n_drop > 0)
|
||||
|
||||
# A charge tomorrow: announced only when the add-on will take it. Today's
|
||||
# dear period is 21:00 (0.50, then 0.30). Tomorrow opens with a dear hour
|
||||
# (0.45) and the cheapest quarter comes right after it, at 01:00 (0.24).
|
||||
def two_days(tomorrow):
|
||||
pts = []
|
||||
for i in range(96 * 2):
|
||||
t = DAY0 + timedelta(minutes=15 * i)
|
||||
h = (i % 96) / 4.0
|
||||
price = (0.50 if h == 21.0 else 0.30) if i < 96 else tomorrow(h)
|
||||
pts.append(PricePoint(brussels(t), price, False))
|
||||
return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", DAY0), None,
|
||||
"provider")
|
||||
|
||||
|
||||
def tomorrow_lines(plan, now):
|
||||
c = make(OFF, plan, now)
|
||||
return [x for x in build(c, now)["lines"] if x.startswith("Tomorrow")]
|
||||
|
||||
|
||||
T_FLAT = two_days(lambda h: 0.45 if h < 1.0 else 0.24)
|
||||
T_WITH = two_days(lambda h: 0.45 if h < 1.0 else (0.50 if h == 18.0 else 0.24))
|
||||
l_with = tomorrow_lines(T_WITH, at(21, 5, 0))
|
||||
l_flat = tomorrow_lines(T_FLAT, at(21, 5, 0))
|
||||
print(f" tomorrow lines: with a later dear period {l_with}; without {l_flat}")
|
||||
check("tomorrow's charge (01:00) is announced when a dearer period follows "
|
||||
"it, and not when the rest of tomorrow is flat",
|
||||
len(l_with) == 1 and "01:00" in l_with[0] and l_flat == [])
|
||||
check("...and the add-on charges at 01:00 on the first and does not on the "
|
||||
"second",
|
||||
decide_at(make(OFF, T_WITH, at(21, 5, 0)),
|
||||
at(25, 0, 5)).action == arbitrage.CHARGE
|
||||
and decide_at(make(OFF, T_FLAT, at(21, 5, 0)),
|
||||
at(25, 0, 5)).action is None)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. wording, English and Dutch")
|
||||
c6 = make(OFF, Q_PLAN, at(20, 20, 0))
|
||||
en = homeview.build(c6, level="ok", checks=[], now=at(20, 20, 0),
|
||||
lang="en")["plan"]
|
||||
nl = homeview.build(c6, level="ok", checks=[], now=at(20, 20, 0),
|
||||
lang="nl")["plan"]
|
||||
gw_en = [r["why"] for r in en["runs"] if r["kind"] == "grid"]
|
||||
gw_nl = [r["why"] for r in nl["runs"] if r["kind"] == "grid"]
|
||||
dw_en = [r["why"] for r in en["runs"] if r["kind"] == "give"]
|
||||
dw_nl = [r["why"] for r in nl["runs"] if r["kind"] == "give"]
|
||||
print(f" {gw_en[:1]} {gw_nl[:1]} {dw_en[:1]} {dw_nl[:1]}")
|
||||
check("the charge says 'lowest price left', English and Dutch",
|
||||
gw_en and set(gw_en) == {"Lowest price left, \u20ac0.12 per kWh."}
|
||||
and set(gw_nl) == {"Laagste prijs die nog over is, \u20ac0,12 per kWh."})
|
||||
check("...and the sale 'highest price left'",
|
||||
set(dw_en) == {"Highest price left, \u20ac0.59 per kWh."}
|
||||
and set(dw_nl) == {"Hoogste prijs die nog over is, \u20ac0,59 per kWh."})
|
||||
check("...and no 'published' is left in the run words of either language",
|
||||
all("ublished" not in str(homeview.uitext.words(l)["home_words"]["run"])
|
||||
and "gepubliceerd" not in str(
|
||||
homeview.uitext.words(l)["home_words"]["run"])
|
||||
for l in ("en", "nl")))
|
||||
check("the short words fit the plan-line rule: one sentence of at most 12 "
|
||||
"words",
|
||||
all(len(x.split()) <= 12 for x in gw_en + gw_nl + dw_en + dw_nl))
|
||||
|
||||
NOW6 = at(21, 45, 5)
|
||||
FLAT_DYN = quarters(lambda h: 0.24, days=1)
|
||||
r_dyn = FLAT_DYN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.10,
|
||||
uncertainty=NO_WEATHER, now=NOW6).match(
|
||||
allowed=lambda v: None, refused=lambda v: v.reason)
|
||||
FIXED = {"periods": [{"name": "all", "from": "00:00", "to": "24:00",
|
||||
"days": "all", "eur_per_kwh": 0.30}]}
|
||||
FIXED_PLAN = static_plan({"static_profile": json.dumps(FIXED)}, at(0), at(36),
|
||||
now=at(0))
|
||||
r_fix = FIXED_PLAN.arbitrage(peak_guard_active=False,
|
||||
min_spread_eur_kwh=0.10, uncertainty=NO_WEATHER,
|
||||
now=NOW6).match(allowed=lambda v: None,
|
||||
refused=lambda v: v.reason)
|
||||
DN = {"periods": [{"name": "day", "from": "07:00", "to": "22:00",
|
||||
"days": "all", "eur_per_kwh": 0.34},
|
||||
{"name": "night", "from": "22:00", "to": "07:00",
|
||||
"days": "all", "eur_per_kwh": 0.26}]}
|
||||
DN_FLAT = static_plan({"static_profile": json.dumps(DN)}, at(23, 0), at(30),
|
||||
now=at(23, 0)) # 23:00 to 06:00: night only
|
||||
r_dn = DN_FLAT.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.10,
|
||||
uncertainty=NO_WEATHER, now=at(23, 10)).match(
|
||||
allowed=lambda v: None, refused=lambda v: v.reason)
|
||||
print(f" dynamic: {r_dyn}\n day/night: {r_dn}\n fixed: {r_fix}")
|
||||
check("a dynamic site whose remaining quarters are equal is refused as flat, "
|
||||
"and is not called a fully fixed product",
|
||||
r_dyn and r_dyn.startswith("the tariff is flat - every one of the ")
|
||||
and "fully fixed" not in r_dyn and "fixed product" not in r_dyn
|
||||
and r_dyn.endswith("arithmetically impossible"))
|
||||
check("...a day/night site whose remaining hours are all night: the same",
|
||||
r_dn and r_dn.startswith("the tariff is flat")
|
||||
and "fixed" not in r_dn)
|
||||
check("...a site with a real fixed product is still told so",
|
||||
r_fix and r_fix.endswith(
|
||||
"§9.2: a fully fixed product is self-consumption only")
|
||||
and FIXED_PLAN.product is PriceProduct.FIXED
|
||||
and DN_FLAT.product is PriceProduct.STATIC
|
||||
and FLAT_DYN.product is PriceProduct.DAY_AHEAD)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. day/night, hourly and price-off pages")
|
||||
def old_vs_new(plan, opts, hours=24):
|
||||
"""Verdict legs the page drew before (both, when there is a verdict) and
|
||||
the ones it draws now, probed every 30 minutes."""
|
||||
same = diff = none = 0
|
||||
for m in range(0, hours * 60, 30):
|
||||
now = at(0) + timedelta(minutes=m, seconds=7)
|
||||
c = make(opts, plan, now)
|
||||
v, _w = homeview.trade_verdict(c, plan, now)
|
||||
if v is None:
|
||||
none += 1
|
||||
continue
|
||||
if len(homeview.trade_legs(c, plan, v, now)) == 2:
|
||||
same += 1
|
||||
else:
|
||||
diff += 1
|
||||
return same, diff, none
|
||||
|
||||
|
||||
DN_OPTS = dict(OFF, cycle_cost_threshold_eur=0.02,
|
||||
static_profile=json.dumps(DN))
|
||||
DN_48 = static_plan(DN_OPTS, at(0), at(48), now=at(0))
|
||||
s_dn = old_vs_new(DN_48, DN_OPTS)
|
||||
print(f" day/night, 48 h plan, 48 probes: same {s_dn[0]}, changed "
|
||||
f"{s_dn[1]}, no verdict {s_dn[2]}")
|
||||
check("day/night (a 48-hour plan, as the refresh builds it): at every probe "
|
||||
"that has a verdict both legs are drawn, as before; and there are some",
|
||||
s_dn[1] == 0 and s_dn[0] >= 20)
|
||||
HOURLY = quarters(lambda h: [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22,
|
||||
.18, .16, .15, .14, .16, .19, .23, .28, .33, .36,
|
||||
.34, .29, .26, .24][int(h)], days=2)
|
||||
HR_OPTS = dict(OFF, cycle_cost_threshold_eur=0.05)
|
||||
s_hr = old_vs_new(HOURLY, HR_OPTS)
|
||||
print(f" hourly shape on a 48 h plan: same {s_hr[0]}, changed {s_hr[1]}, "
|
||||
f"no verdict {s_hr[2]}")
|
||||
check("an hourly-shaped day-ahead feed with tomorrow known: both legs are "
|
||||
"drawn at every probe with a verdict, as before",
|
||||
s_hr[1] == 0 and s_hr[0] >= 20)
|
||||
s_one = old_vs_new(quarters(lambda h: [.24, .22, .21, .20, .21, .22, .24, .27,
|
||||
.26, .22, .18, .16, .15, .14, .16, .19,
|
||||
.23, .28, .33, .36, .34, .29, .26,
|
||||
.24][int(h)], days=1), HR_OPTS)
|
||||
print(f" the same with today only: same {s_one[0]}, changed {s_one[1]}, "
|
||||
f"no verdict {s_one[2]}")
|
||||
check("...and with today only, the probes that change are the ones where "
|
||||
"the cheapest quarter left is after the last dear one (F-2)",
|
||||
s_one[1] > 0 and s_one[0] > 0)
|
||||
c7 = make(dict(OFF, arbitrage_enabled=False), DN_48, at(12, 0, 0))
|
||||
p7 = build(c7, at(12, 0, 0))
|
||||
check("price trading off (a day/night site, modes off): no charge or sale "
|
||||
"is drawn and no bar is marked, as before",
|
||||
[r for r in p7["runs"] if r["kind"] in ("grid", "give")] == []
|
||||
and {b["kind"] for b in p7["prices"]} == {"mid"})
|
||||
c8 = make(dict(OFF, arbitrage_enabled=False, battery_modes_enabled=True),
|
||||
DN_48, at(12, 0, 0), modes=packmode.SOLAR_FIRST)
|
||||
c8._mode_roles(at(12, 0, 0), None)
|
||||
p8 = build(c8, at(12, 0, 0))
|
||||
check("modes on, every battery Solar first (the owner's setting): no grid "
|
||||
"charge is drawn, and the gate record changes nothing",
|
||||
grid_blocks(p8) == [] and {b["kind"] for b in p8["prices"]} == {"mid"})
|
||||
c9 = make(dict(OFF, battery_modes_enabled=True), DN_48, at(12, 0, 0),
|
||||
modes=packmode.SOLAR_FIRST)
|
||||
check("a controller that never ran a cycle has no gate record, and the "
|
||||
"default is 'no refusal' (None)",
|
||||
getattr(c9, "mode_gate", "missing") is None)
|
||||
|
||||
print(f"fails: {len(fails)}")
|
||||
sys.exit(1 if fails else 0)
|
||||
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