Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8966867968 | ||
|
|
2929ece7da | ||
|
|
6eebd4882b | ||
|
|
88ab8a50e3 |
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## 0.19.2
|
||||
|
||||
**A patch bump: one fix for charging from the grid under the peak limit, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.19.1 also
|
||||
start 0.19.2. The change below applies with one inverter or two.
|
||||
|
||||
**Charging from the grid now stays under this month's peak.** A late meter reading near a period's end could push a grid charge to full power. That could take the 15-minute period over the peak. Now the charge is cut back, with one inverter too. The peak guard line no longer says "under" when the period is over.
|
||||
|
||||
Contents of this release, in merge order since 0.19.1 (`2932f3a`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `SAFETY-125` | A grid charge (a battery mode's cheap run, price trading or the maintenance charge, with one inverter or two) counts the seconds since a late meter reading at that reading, so it is cut back near the end of a 15-minute period instead of going back to full power. With one inverter the maintenance charge is now held to the room left in the period, as with two, so the peak guard no longer steps in and gives power at the period's end. Over the limit at the current rate, the peak guard row and the month peak line say so, and that the grid charge is cut back ([[SAFETY-125]]). |
|
||||
|
||||
### Known issues
|
||||
|
||||
The list under `## 0.19.0` below still applies. The SAFETY-99 line there now
|
||||
covers the guard's discharge only, not a grid charge.
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.19.1
|
||||
|
||||
**A patch bump: two fixes for battery modes, and no option changes.**
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# OPT-27 - the night plan keeps the last fresh forecast
|
||||
|
||||
**Status: done on `release/1.0`, not released.** Tests: `test_opt27.py`.
|
||||
|
||||
**The defect.** A today sensor is stale after 3 h (`FRESH_S`) or when it is from
|
||||
before local midnight. Forecast.Solar stops updating in the evening, so at night
|
||||
every Forecast.Solar site had no forecast: a price mode's night cheap run needed
|
||||
1.5x the cycle cost (TARIFF-24 round 2) and Leftover solar planned nothing.
|
||||
Separately, one missed poll (`unavailable`) dropped the forecast at once.
|
||||
|
||||
**Where: `solarfc`, not the plan.** The last fresh reading of each sensor is
|
||||
kept in `solarfc.Hold` and `read_sum()` uses it when a sensor has none. A hold
|
||||
at the plan (`_wx_track`/`weather_held`) can only keep the 1.5x gap off. The
|
||||
day plan, Leftover solar's levels, Home and the Forecast tab all read
|
||||
`Controller.solar_fc`, so one kept forecast makes control and every screen agree
|
||||
(UX-53) with no second rule to keep in step. `weather_held()` is unchanged: it
|
||||
still keeps a run under way when a read is lost.
|
||||
|
||||
**The rule** (`Hold.stand_in()`). A kept reading is for one day. It covers a
|
||||
sensor that is missing or stale, never one with a bad value or unit, and:
|
||||
- for up to `FRESH_S` when the sensor is missing: a short outage, any time of day;
|
||||
- from the end of the bell (20:00 local, `BELL_TO_H`) until local midnight + 3 h
|
||||
(`KEPT_UNTIL_H`), however old;
|
||||
- the day it is for: a today figure only on that day, a tomorrow figure as
|
||||
tomorrow until midnight and, if the today sensors are still stale, as today's
|
||||
until midnight + 3 h (all sensors of the sum are needed, as for any sum);
|
||||
- until the next fresh reading, which replaces it at once.
|
||||
|
||||
So a figure is never used for another day and never older than one night.
|
||||
Daytime is as before: a sensor that stopped is stale after 3 h, and no forecast
|
||||
all day is still no forecast (the night run still needs 1.5x).
|
||||
|
||||
**Where it is stored.** The controller's store, key `solar_fc_hold`, so an
|
||||
evening restart keeps it. It is written when a reading's figure changes or
|
||||
every 15 min (`SAVE_EVERY_S`), not on every poll. A damaged entry is dropped.
|
||||
|
||||
**The screens.** `Forecast.kept` is true when a reading older than `FRESH_S` is
|
||||
in use; the forecast line then reads "Solar forecast read 17:05, used for
|
||||
tonight's plan." (`home_words.solar.kept`, EN and NL). The event log says
|
||||
"read, kept from 17:05".
|
||||
|
||||
**Not changed.** Modes off (nothing reads the sensors). A forecast that stays
|
||||
fresh. The hold does not look at the weather, only at the clock and the day.
|
||||
@@ -1,35 +0,0 @@
|
||||
# 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,10 @@
|
||||
# SAFETY-125 - a grid charge under the month peak
|
||||
|
||||
**Status: FIXED in 0.19.2 (hotfix from 0.19.1).** Tests: `test_safety125.py`.
|
||||
|
||||
A grid charge is sized by `peakguard.charge_room_w()`, the guard's headroom
|
||||
with the seconds since a late meter reading counted at that reading (the
|
||||
projection leaves them out, SAFETY-99, the bill does not), and the guard's own
|
||||
floor is unchanged. At N=1 the maintenance charge is now sized too (owner
|
||||
2026-10-29; SAFETY-84 had left it out). Evidence (repro on 0.19.1, sweep,
|
||||
digests): the SAFETY-125 pull request.
|
||||
@@ -39,7 +39,8 @@ was fine.
|
||||
**Not changed.**
|
||||
- At N=1 the charge is not sized to the headroom: the guard still suspends it
|
||||
and the battery gives until the quarter is safe. The same sizing would fit
|
||||
there; that is an owner question, not this fix.
|
||||
there; that is an owner question, not this fix. (SAFETY-125, 0.19.2: the
|
||||
owner said yes, and N=1 is sized the same way.)
|
||||
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
|
||||
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
|
||||
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,146 +0,0 @@
|
||||
# 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.
|
||||
@@ -555,7 +555,6 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
|
||||
- No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so.
|
||||
- A battery needs its capacity (`inverters.capacity_kwh`) for a plan.
|
||||
- **At night.** Some forecasts stop updating in the evening (Forecast.Solar does). The add-on keeps the last reading of each sensor and uses it for that night's plan: from 20:00 until 03:00, or until a newer reading comes. A sensor that is missing for up to 3 hours is covered the same way. A figure is never used for another day, and never older than that night. The Batteries screen says "Solar forecast read 17:05, used for tonight's plan." With no reading all day, the night run still needs the wider gap.
|
||||
|
||||
**What always comes first.**
|
||||
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
|
||||
@@ -1709,7 +1708,9 @@ DECISIONS/MAINT-13-full-pack-is-complete.md.
|
||||
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
||||
under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp
|
||||
is dropped so the battery can shave the peak instead. Money outranks the
|
||||
maintenance schedule.
|
||||
maintenance schedule. With one battery or several, the charge also stays
|
||||
within the room left in the 15-minute period, so a late meter reading does
|
||||
not let it run at full power.
|
||||
|
||||
With two or more batteries, the one being emptied gives its drain rate and the
|
||||
others only add power when the house needs more; none charges while it drains
|
||||
@@ -3531,21 +3532,6 @@ 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
|
||||
@@ -3613,7 +3599,7 @@ for a new install*.
|
||||
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
|
||||
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
|
||||
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *At this rate … over …* means it would go over at the current rate. With *grid charge limited*, a charge from the grid is cut back so it does not. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
|
||||
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
|
||||
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
|
||||
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
"""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,15 +157,13 @@ class Plan:
|
||||
|
||||
|
||||
def _hour_slices(t0, t1):
|
||||
"""Local-hour slices of [t0, t1): (start, end, local hour).
|
||||
|
||||
Steps in UTC, so `t` always advances (SAFETY-123, DECISIONS)."""
|
||||
"""Local-hour slices of [t0, t1): (start, end, local hour)."""
|
||||
out, t = [], t0
|
||||
while t < t1:
|
||||
lt = _local(t)
|
||||
left = timedelta(minutes=60 - lt.minute) - timedelta(
|
||||
seconds=lt.second, microseconds=lt.microsecond)
|
||||
e = min(t.astimezone(timezone.utc) + left, t1)
|
||||
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
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
"""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),
|
||||
kept=getattr(fc, "kept", False))
|
||||
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,7 +16,6 @@ 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
|
||||
@@ -213,26 +212,25 @@ def _price_mode_in_use(c):
|
||||
return False
|
||||
|
||||
|
||||
def price_windows(c, plan, verdict, now=None):
|
||||
def price_windows(c, plan, verdict):
|
||||
"""`[(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 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."""
|
||||
(`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."""
|
||||
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() and getattr(c, "mode_gate", None) is None \
|
||||
else []
|
||||
if rp.charge_useful() else []
|
||||
return out + [("dear", g.start, g.end) for g in rp.give]
|
||||
if verdict is None:
|
||||
return []
|
||||
return [("cheap" if kind == "grid" else "dear", pt.start,
|
||||
_point_end(plan, pt))
|
||||
for kind, pt in trade_legs(c, plan, verdict, now)]
|
||||
return [("cheap", verdict.cheapest.start,
|
||||
_point_end(plan, verdict.cheapest)),
|
||||
("dear", verdict.dearest.start,
|
||||
_point_end(plan, verdict.dearest))]
|
||||
|
||||
|
||||
def day_range(points):
|
||||
@@ -312,65 +310,32 @@ 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, ahead=False):
|
||||
def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
"""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.
|
||||
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."""
|
||||
With a level unknown the cycle does not trade, so nothing is drawn."""
|
||||
floor, ceiling = arbitrage.soc_bounds(c.o)
|
||||
if not ahead and any(inv.soc is None for inv in c.inverters):
|
||||
if any(inv.soc is None for inv in c.inverters):
|
||||
return [], [], "no_level"
|
||||
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]
|
||||
level = [float(inv.soc) for inv in c.inverters]
|
||||
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
|
||||
key=lambda kp: kp[1].start)
|
||||
runs, later = [], []
|
||||
for kind, pt in trade_legs(c, plan, verdict, now):
|
||||
for kind, pt in legs:
|
||||
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
|
||||
@@ -385,8 +350,7 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
|
||||
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").
|
||||
if not ahead:
|
||||
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
|
||||
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
|
||||
if kwh < MIN_KWH:
|
||||
continue
|
||||
eur = kwh * pt.eur_per_kwh
|
||||
@@ -406,22 +370,14 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
|
||||
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
|
||||
|
||||
def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
def mode_runs(c, day0, day1, now, shadow):
|
||||
"""OPT-11 P3: each battery's price runs today, as the control cycle keeps
|
||||
them (`c.mode_runs`): the cheap run on the batteries that may charge
|
||||
from the grid, the give runs on Leftover solar and Cheap hours, and the
|
||||
sell run on those with selling on. Amounts are upper bounds ("up to"),
|
||||
from the battery's own room, rating and the run's length. UX-53: no grid
|
||||
charge is drawn while the trade gate refuses one (`c.mode_gate`, what the
|
||||
control cycle found). Returns `(runs, no_trade_key)`.
|
||||
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)
|
||||
from the battery's own room, rating and the run's length. Returns
|
||||
`(runs, no_trade_key)`."""
|
||||
rp = 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:
|
||||
@@ -429,7 +385,6 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
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)
|
||||
@@ -437,26 +392,17 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
continue
|
||||
soc, cap = inv.soc, inv.capacity_kwh
|
||||
|
||||
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):
|
||||
def room(kind, hrs):
|
||||
kwh = inv.max_w / 1000.0 * hrs
|
||||
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)
|
||||
if cap and soc is not None:
|
||||
kwh = min(kwh, max(0.0, (ceiling - soc) if kind == "grid"
|
||||
else (soc - 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, 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):
|
||||
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):
|
||||
blocks.append(("grid", rp.cheap))
|
||||
sell = rp.sell if pm.sell and pm.can_sell else None
|
||||
for g in rp.give:
|
||||
@@ -468,7 +414,7 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
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, run.start)
|
||||
kwh = room(kind, hrs)
|
||||
if kind == "grid" and need is not None:
|
||||
kwh = min(kwh, need)
|
||||
if kwh < MIN_KWH:
|
||||
@@ -495,18 +441,16 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
return out, None
|
||||
|
||||
|
||||
def plan_need(c, name, soc=None):
|
||||
def plan_need(c, name):
|
||||
"""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). `soc`: UX-54, a level
|
||||
to read it from instead of the one now (`mode_runs(ahead=True)`)."""
|
||||
there is no plan for it (then P3 option A applies)."""
|
||||
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)
|
||||
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:
|
||||
if inv is None or inv.soc is None or not inv.capacity_kwh:
|
||||
return None
|
||||
return max(0.0, targets[name] - float(at)) / 100.0 * float(
|
||||
return max(0.0, targets[name] - float(inv.soc)) / 100.0 * float(
|
||||
inv.capacity_kwh)
|
||||
|
||||
|
||||
@@ -543,8 +487,7 @@ def solar_block(c, now):
|
||||
out["line"] = _t("solar." + st)
|
||||
elif fc is not None:
|
||||
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
|
||||
out["updated"],
|
||||
kept=getattr(fc, "kept", False))
|
||||
out["updated"])
|
||||
return out
|
||||
|
||||
|
||||
@@ -650,20 +593,6 @@ 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)
|
||||
@@ -683,7 +612,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, now),
|
||||
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict),
|
||||
now, days.kinds)
|
||||
modes_on = bool(getattr(c, "modes_on", False))
|
||||
if running:
|
||||
@@ -1034,7 +963,8 @@ def month_peak(c, now):
|
||||
ceiling = pg.ceiling_w or limit_w
|
||||
out["line"] = line_period(out["period_w"], ceiling, c.shadow,
|
||||
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
|
||||
> 0.0)
|
||||
> 0.0,
|
||||
cut=getattr(c, "room_charge", False))
|
||||
return out
|
||||
|
||||
|
||||
@@ -1053,16 +983,6 @@ 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:
|
||||
@@ -1391,16 +1311,7 @@ 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, kept=False) -> str:
|
||||
"""The forecast line. OPT-27: a reading kept past three hours (the
|
||||
evening's, used at night) says so instead of "Updated"."""
|
||||
if kept:
|
||||
return _t("solar.kept", at=at)
|
||||
def line_solar(fc_kwh, count_kwh, at) -> str:
|
||||
return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at)
|
||||
|
||||
|
||||
@@ -1457,13 +1368,19 @@ def line_peak_not_covered() -> str:
|
||||
return _t("peak_not_covered")
|
||||
|
||||
|
||||
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
|
||||
def line_period(period_w, limit_w, shadow, unmeasured=False,
|
||||
cut=False) -> str:
|
||||
step = _w(shadow, "step_in", kw=_kw(limit_w))
|
||||
if unmeasured:
|
||||
# SAFETY-90: the add-on started during this period.
|
||||
return _t("period_unmeasured") + " " + step
|
||||
if period_w is None:
|
||||
return step
|
||||
if period_w > limit_w:
|
||||
# SAFETY-125: the figure is the current rate, and a grid charge is
|
||||
# what gets cut back.
|
||||
return _t("period_over", kw=_kw(period_w)) + " " + (
|
||||
_w(shadow, "charge_cut", kw=_kw(limit_w)) if cut else step)
|
||||
return _t("period", kw=_kw(period_w)) + " " + step
|
||||
|
||||
|
||||
@@ -1536,6 +1453,8 @@ def _samples() -> list:
|
||||
("plan", line_period(1240, 3500, sh)),
|
||||
("plan", line_period(None, 3500, sh)),
|
||||
("plan", line_period(1240, 3500, sh, unmeasured=True)),
|
||||
("plan", line_period(5814, 5413, sh)),
|
||||
("plan", line_period(5814, 5413, sh, cut=True)),
|
||||
("plan", control_text(True, sh)),
|
||||
("plan", control_text(False, sh))]
|
||||
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
|
||||
@@ -1573,9 +1492,6 @@ 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,8 +64,7 @@ from .clamp import (
|
||||
shortfall_note, write_clamp_note,
|
||||
)
|
||||
from . import freshness
|
||||
from . import dayview, homeview
|
||||
from .daycurve import SolarToday
|
||||
from . import homeview
|
||||
from . import messages
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
@@ -208,9 +207,6 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
|
||||
# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and
|
||||
# how often the forecast sensors are read (they change hourly at most).
|
||||
HOUSE_PROFILE_KEY = "house_profile"
|
||||
# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an
|
||||
# evening restart keeps the figure the night plan uses.
|
||||
FC_HOLD_KEY = "solar_fc_hold"
|
||||
FORECAST_READ_S = 60.0
|
||||
|
||||
|
||||
@@ -968,10 +964,6 @@ class Controller:
|
||||
# was read; until then losing the forecast does not widen the gap.
|
||||
self._wx_free_to = 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
|
||||
@@ -998,8 +990,6 @@ class Controller:
|
||||
self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None
|
||||
else solarfc.MISSING)
|
||||
self._fc_read_mono = None
|
||||
self.fc_hold = solarfc.Hold(
|
||||
(getattr(store, "data", None) or {}).get(FC_HOLD_KEY))
|
||||
self.day_plan = None
|
||||
self._plan_targets = None
|
||||
self.house_profile = None
|
||||
@@ -1227,12 +1217,11 @@ class Controller:
|
||||
# because `self.peak_guard` is a different cycle's decision by the time
|
||||
# the status page renders.
|
||||
self.arb_headroom_w = None
|
||||
# SAFETY-125: a grid charge sized to the quarter's room this cycle.
|
||||
self.room_charge = False
|
||||
# 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
|
||||
@@ -2382,7 +2371,6 @@ 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:
|
||||
@@ -3244,17 +3232,19 @@ class Controller:
|
||||
return floor
|
||||
|
||||
def _charge_headroom(self, floor: float, claims) -> float:
|
||||
"""SAFETY-84: at N>=2 a cheap-window charge pulls no more than the
|
||||
quarter's headroom under the peak aim (`charge_headroom_w()`), so it
|
||||
yields just enough to the peak guard, and nothing under a site stop
|
||||
(its 0 W would need the other pack to give). N=1 is unchanged."""
|
||||
if len(self.inverters) < 2:
|
||||
return floor
|
||||
if any(c.kind == SET and c.actuator == ACTUATOR_SITE
|
||||
and c.priority > P_MAINT_SHAPE for c in claims):
|
||||
"""SAFETY-84: a cheap-window charge pulls no more than the quarter's
|
||||
room under the peak aim (`charge_room_w()`), so it yields just enough
|
||||
to the peak guard; at N>=2 nothing under a site stop (its 0 W would
|
||||
need the other pack to give). SAFETY-125: at N=1 too, owner
|
||||
2026-10-29; the stop rule stays N>=2 only."""
|
||||
if len(self.inverters) > 1 and any(
|
||||
c.kind == SET and c.actuator == ACTUATOR_SITE
|
||||
and c.priority > P_MAINT_SHAPE for c in claims):
|
||||
return 0.0
|
||||
room = peakguard.charge_headroom_w(self.peak_guard)
|
||||
return floor if room is None else min(floor, room)
|
||||
room = peakguard.charge_room_w(self.peak_guard, self._peak_proj)
|
||||
floor = floor if room is None else min(floor, room)
|
||||
self.room_charge = self.room_charge or floor > 0.0
|
||||
return floor
|
||||
|
||||
def _charge_ceiling(self, claims):
|
||||
"""SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None.
|
||||
@@ -3379,11 +3369,6 @@ 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
|
||||
@@ -3411,25 +3396,17 @@ class Controller:
|
||||
now = datetime.now(timezone.utc)
|
||||
fc, why, gaps = solarfc.read_sum(
|
||||
list(zip(self.fc_entities, states[:n])),
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now,
|
||||
hold=self.fc_hold)
|
||||
if self.fc_hold.changed:
|
||||
self.fc_hold.changed = False
|
||||
self.store.set(FC_HOLD_KEY, self.fc_hold.dump())
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now)
|
||||
# A member is named only in a sum of several; one entity reads as
|
||||
# before. Logged once per episode: on a change, never every read.
|
||||
gaps = tuple(g for g in gaps if len(
|
||||
self.fc_entities if g[0] == "today"
|
||||
else self.fc_tomorrow_entities) > 1)
|
||||
was_kept = bool(self.solar_fc is not None and self.solar_fc.kept)
|
||||
if (fc is None) != (self.solar_fc is None) \
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps \
|
||||
or bool(fc is not None and fc.kept) != was_kept:
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps:
|
||||
self.log_event(
|
||||
"solar forecast: " + (
|
||||
"none (" + str(why) + ")" if fc is None else
|
||||
f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept
|
||||
else "read")
|
||||
"solar forecast: " + ("read" if fc is not None else
|
||||
f"none ({why})")
|
||||
+ "".join(f", {d} {e} not usable ({w})"
|
||||
for d, _i, e, w in gaps),
|
||||
hint=messages.hint(
|
||||
@@ -4912,6 +4889,7 @@ class Controller:
|
||||
# decision presented as current.
|
||||
self.arb, self.arb_at = None, now
|
||||
self.arb_headroom_w = None
|
||||
self.room_charge = False
|
||||
# OPT-11 P3: with battery modes on, each battery's mode decides who
|
||||
# trades (`_mode_roles()`, `_mode_trade()`, below, which read the
|
||||
# same headroom), and this site-wide trade is not run.
|
||||
@@ -4922,7 +4900,9 @@ class Controller:
|
||||
# `arbitrage.sized_from_meter()` in `_shadow_settle()`, so the
|
||||
# caveat on the recorded watts can never be answered from a
|
||||
# different cycle's guard than the watts were.
|
||||
self.arb_headroom_w = peakguard.charge_headroom_w(self.peak_guard)
|
||||
# SAFETY-125: a late meter reading's seconds count, as the bill.
|
||||
self.arb_headroom_w = peakguard.charge_room_w(self.peak_guard,
|
||||
self._peak_proj)
|
||||
if site_trade:
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
|
||||
@@ -4977,6 +4957,7 @@ class Controller:
|
||||
arb_claim = arbitrage.claim(self.arb)
|
||||
if arb_claim is not None:
|
||||
claims.append(arb_claim)
|
||||
self.room_charge = self.arb.action == arbitrage.CHARGE
|
||||
# Edge-logged, for the peak guard's reason: the action log holds 40
|
||||
# lines and a trade lasts a whole hour, so a line per cycle would
|
||||
# flush the context that explains it. The transition is what an
|
||||
@@ -5196,6 +5177,8 @@ class Controller:
|
||||
mode_trade, trade_was = False, self._mode_trade_was
|
||||
if self.modes_on:
|
||||
mode_trade = self._mode_trade(claims, now)
|
||||
self.room_charge = self.room_charge or (
|
||||
mode_trade and self.arb.action == arbitrage.CHARGE)
|
||||
self._mode_trade_was = mode_trade
|
||||
self._charge_track(claims, maint_results)
|
||||
_live96 = set(self.live_units())
|
||||
@@ -7089,6 +7072,15 @@ class Controller:
|
||||
"text": messages.row_peak_guard_on(
|
||||
_pg.floor_w or 0.0, _pg.ceiling_w,
|
||||
shadow=self.shadow)})
|
||||
elif _pg.projected_w is not None:
|
||||
# SAFETY-125: "under" only when it is; over the limit at this
|
||||
# rate, a grid charge is cut back to the quarter's room.
|
||||
out.append({"ok": True, "warn": False, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_idle(
|
||||
_pg.projected_w, _pg.ceiling_w,
|
||||
month_peak=(_pg.ceiling_from
|
||||
== peakguard.CEILING_MONTH_PEAK),
|
||||
cut=self.room_charge and not self.shadow)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "peak_guard",
|
||||
"text": _pg.explain()})
|
||||
@@ -7646,21 +7638,6 @@ 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"):
|
||||
|
||||
@@ -1170,8 +1170,18 @@ def row_periods_measured(n) -> str:
|
||||
f"Gemeten kwartieren deze maand: {getoond}")
|
||||
|
||||
|
||||
def row_peak_guard_idle(projected_w, limit_w, month_peak: bool) -> str:
|
||||
"""The projection first and the limit second (SAFETY-38's ordered pair)."""
|
||||
def row_peak_guard_idle(projected_w, limit_w, month_peak: bool,
|
||||
cut: bool = False) -> str:
|
||||
"""The projection first and the limit second (SAFETY-38's ordered pair).
|
||||
SAFETY-125: over the limit at this rate it says so, and `cut` that a
|
||||
grid charge is limited to the room left."""
|
||||
if float(projected_w) > float(limit_w):
|
||||
doing, doet = (("grid charge limited", "laden beperkt") if cut
|
||||
else ("idle", "in rust"))
|
||||
return Bi(f"Peak guard: {doing}, at this rate {watts(projected_w)}, "
|
||||
f"over {watts(limit_w)}",
|
||||
f"Piekbewaking: {doet}, aan dit tempo "
|
||||
f"{watts(projected_w)}, boven {watts(limit_w)}")
|
||||
limit = (f"this month's {watts(limit_w)} peak" if month_peak
|
||||
else f"the {watts(limit_w)} peak limit")
|
||||
grens = (f"de maandpiek van {watts(limit_w)}" if month_peak
|
||||
@@ -1563,6 +1573,11 @@ def status_samples() -> list:
|
||||
("row_periods_measured", GREEN, row_periods_measured("unreadable")),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(5814, 5413, True)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(5814, 5413, True,
|
||||
cut=True)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(3814, 3500, False,
|
||||
cut=True)),
|
||||
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
|
||||
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
|
||||
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
|
||||
|
||||
@@ -1136,6 +1136,21 @@ def charge_headroom_w(decision) -> float | None:
|
||||
return max(0.0, -float(decision.wanted_w))
|
||||
|
||||
|
||||
def charge_room_w(decision, projection) -> float | None:
|
||||
"""SAFETY-125: `charge_headroom_w()` with the seconds since the last
|
||||
meter reading counted at that reading, so a late reading near a
|
||||
quarter's end is not read as room for a grid charge (SAFETY-99).
|
||||
DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md."""
|
||||
room = charge_headroom_w(decision)
|
||||
if not room or projection is None or projection.block_start is None:
|
||||
return room
|
||||
left = QUARTER_S - float(projection.elapsed_s)
|
||||
if left <= 0.0:
|
||||
return room
|
||||
late_ws = max(0.0, float(projection.held_w)) * uncovered_s(projection)
|
||||
return max(0.0, room - late_ws / left)
|
||||
|
||||
|
||||
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||
"""Must the maintenance charge-only shaping stand down this cycle?
|
||||
|
||||
|
||||
@@ -28,17 +28,6 @@ A reading older than `FRESH_S`, or from before local midnight, is stale,
|
||||
and a stale or missing forecast is no forecast: the day plan is off and
|
||||
every mode acts as in P3 option A.
|
||||
|
||||
OPT-27: but the last fresh reading of each sensor is kept (`Hold`), so the
|
||||
night plan is not made blind. Forecast.Solar's sensors stop changing in the
|
||||
evening and api.forecast.solar is sometimes out for a poll. A kept reading
|
||||
stands in for its sensor (a) for up to `FRESH_S` when the sensor is missing
|
||||
or unavailable, as a sensor that merely stopped updating is already
|
||||
tolerated, and (b) from the end of the bell (`BELL_TO_H`) until local
|
||||
midnight + `KEPT_UNTIL_H` however old it is. Never beyond that, and never
|
||||
for another day: a today figure is only today's, a tomorrow figure is
|
||||
tomorrow's until midnight and then, until midnight + 3 h, today's.
|
||||
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
|
||||
|
||||
OPT-17: each day may instead name a list (`pv_forecast_entities`,
|
||||
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
|
||||
not usable makes that day's sum unusable, never a partial sum.
|
||||
@@ -61,11 +50,6 @@ FRESH_S = 3 * 3600.0
|
||||
# The bell a state-only forecast is spread over, in local hours.
|
||||
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
|
||||
|
||||
# OPT-27: a kept reading serves the night plan from the end of the bell until
|
||||
# this many hours after local midnight (the card: "local midnight + 3 h").
|
||||
KEPT_UNTIL_H = 3.0
|
||||
SAVE_EVERY_S = 900.0
|
||||
|
||||
# Why there is no forecast (the page words are in ui_text `home_words`).
|
||||
NOT_SET, MISSING, UNIT, STALE, BAD = (
|
||||
"not_set", "missing", "unit", "stale", "bad")
|
||||
@@ -91,10 +75,6 @@ class Forecast:
|
||||
today: Day
|
||||
tomorrow: Day | None
|
||||
at: datetime # when the sensor last reported
|
||||
# OPT-27: a reading kept past `FRESH_S` is in it (the evening's figure at
|
||||
# night). The screens say so. Last, so `Forecast(today, tomorrow, at)` is
|
||||
# as before.
|
||||
kept: bool = False
|
||||
|
||||
def between(self, t0: datetime, t1: datetime) -> float | None:
|
||||
"""kWh the sun should give between `t0` (now or later) and `t1`.
|
||||
@@ -254,108 +234,6 @@ def _reported(state):
|
||||
return max(ts) if ts else None
|
||||
|
||||
|
||||
class Hold:
|
||||
"""OPT-27: the last fresh reading of each forecast sensor, per day it is
|
||||
for. `read_sum()` remembers every fresh reading here and, when a sensor
|
||||
has none now, asks it (`stand_in()`).
|
||||
|
||||
Keyed `(kind, entity, day0)`: `kind` is "today" (energy still to come on
|
||||
`day0`) or "tomorrow" (all of `day0`), and `day0` is the day the figure is
|
||||
FOR. A today figure therefore never answers for another day, and a
|
||||
tomorrow figure answers for its own day only: tomorrow until midnight,
|
||||
then today until midnight + `KEPT_UNTIL_H`. Only the newest reading of a
|
||||
key is kept, so the next fresh reading ends the hold at once.
|
||||
|
||||
`dump()` / `load()` are what the controller keeps in its store, so an
|
||||
evening restart does not lose it (`load()` drops what is bad).
|
||||
`changed` is set when `dump()` would differ."""
|
||||
|
||||
def __init__(self, data=None):
|
||||
self._r = {}
|
||||
self._saved = {} # key -> the `at` last flagged for saving
|
||||
self.changed = False
|
||||
if isinstance(data, dict):
|
||||
self.load(data)
|
||||
|
||||
def remember(self, kind, entity, day: "Day", at: datetime) -> None:
|
||||
key = (kind, entity, day.day0)
|
||||
old = self._r.get(key)
|
||||
if old is not None and at <= old[1]:
|
||||
return
|
||||
self._r[key] = (day, at)
|
||||
# A sensor that reports every minute with the same figure must not
|
||||
# rewrite the store every minute: the saved age may lag by
|
||||
# `SAVE_EVERY_S` (the bounds are hours).
|
||||
mark = self._saved.get(key)
|
||||
if old is None or old[0].kwh != day.kwh or mark is None \
|
||||
or (at - mark).total_seconds() >= SAVE_EVERY_S:
|
||||
self._saved[key] = at
|
||||
self.changed = True
|
||||
|
||||
def forget_before(self, day0: datetime) -> None:
|
||||
"""Drop what is for a day before `day0` (the local midnight now)."""
|
||||
gone = [k for k in self._r if k[2] < day0]
|
||||
for k in gone:
|
||||
del self._r[k]
|
||||
self._saved.pop(k, None)
|
||||
if gone:
|
||||
self.changed = True
|
||||
|
||||
def stand_in(self, kind, entity, day0, now, *, as_today=False):
|
||||
"""`(Day, at, kept)` for a sensor that has no fresh reading, or None.
|
||||
|
||||
The kept reading must be for `day0`, and either (a) at most `FRESH_S`
|
||||
old, or (b) used at night: from the end of the bell to local midnight,
|
||||
or within `KEPT_UNTIL_H` after it. `kept` is true when (b) was needed,
|
||||
or `as_today` (a tomorrow figure standing in for today, after
|
||||
midnight)."""
|
||||
rec = self._r.get((kind, entity, day0))
|
||||
if rec is None:
|
||||
return None
|
||||
day, at = rec
|
||||
if (now - at).total_seconds() <= FRESH_S and not as_today:
|
||||
return day, at, False
|
||||
# Both sides: a tomorrow sensor is asked with day0 = the next
|
||||
# midnight, which is in the future all day (round 2).
|
||||
after_midnight = day0 <= now < day0 + timedelta(hours=KEPT_UNTIL_H)
|
||||
evening = _local_hour(now) >= BELL_TO_H and now < day0 + timedelta(
|
||||
days=1)
|
||||
if as_today:
|
||||
return (day, at, True) if after_midnight else None
|
||||
return (day, at, True) if (evening or after_midnight) else None
|
||||
|
||||
def dump(self) -> dict:
|
||||
return {"v": 1, "r": [
|
||||
[kind, ent, d.day0.isoformat(), d.kwh, at.isoformat(),
|
||||
[[s.isoformat(), e.isoformat(), k] for s, e, k in d.periods]]
|
||||
for (kind, ent, _d0), (d, at) in sorted(
|
||||
self._r.items(), key=lambda kv: (kv[0][0], kv[0][1],
|
||||
kv[0][2]))]}
|
||||
|
||||
def load(self, data: dict) -> None:
|
||||
rows = data.get("r") if isinstance(data, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return
|
||||
for row in rows:
|
||||
try:
|
||||
kind, ent, d0, kwh, at, per = row
|
||||
day0, at = _parse_time(d0), _parse_time(at)
|
||||
kwh = float(kwh)
|
||||
periods = tuple((_parse_time(s), _parse_time(e), float(k))
|
||||
for s, e, k in per)
|
||||
ok = (kind in ("today", "tomorrow") and isinstance(ent, str)
|
||||
and day0 is not None and at is not None
|
||||
and math.isfinite(kwh) and kwh >= 0
|
||||
and all(s is not None and e is not None and e > s
|
||||
and math.isfinite(k) and k >= 0
|
||||
for s, e, k in periods))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if ok:
|
||||
self._r[(kind, ent, day0)] = (Day(day0, kwh, periods), at)
|
||||
self._saved[(kind, ent, day0)] = at
|
||||
|
||||
|
||||
def entities(opts: dict, *, tomorrow=False) -> tuple:
|
||||
"""The forecast entities one day names, in configured order: the list
|
||||
if it has any entry, else the single key, else (). A repeated id is
|
||||
@@ -392,14 +270,14 @@ def _today(state, day0, now):
|
||||
|
||||
|
||||
def _tomorrow(state, day0, day1, now):
|
||||
"""`(Day, at, None)` or `(None, None, why)` for one tomorrow sensor."""
|
||||
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
|
||||
t, why = _day(state, day1, now, today=False)
|
||||
if t is None:
|
||||
return None, None, why
|
||||
return None, why
|
||||
t_at = _reported(state)
|
||||
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
|
||||
return None, None, STALE
|
||||
return t, t_at, None
|
||||
return None, STALE
|
||||
return t, None
|
||||
|
||||
|
||||
def read(today_state, tomorrow_state, now: datetime, *,
|
||||
@@ -415,7 +293,7 @@ def read(today_state, tomorrow_state, now: datetime, *,
|
||||
return None, why
|
||||
tomorrow = None
|
||||
if tomorrow_state is not None:
|
||||
tomorrow, _at, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
return Forecast(today, tomorrow, at), None
|
||||
|
||||
|
||||
@@ -431,12 +309,7 @@ def _sum(days):
|
||||
return Day(days[0].day0, sum(d.kwh for d in days), periods)
|
||||
|
||||
|
||||
# Why a sensor may be covered by a kept reading: it has none now, or it is
|
||||
# too old. A bad value or a wrong unit is a set-up fault and is never covered.
|
||||
_COVERABLE = (MISSING, STALE)
|
||||
|
||||
|
||||
def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
|
||||
def read_sum(today, tomorrow, now: datetime):
|
||||
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
|
||||
`(entity, state)`, one per configured member, in configured order.
|
||||
|
||||
@@ -449,67 +322,29 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
|
||||
`gaps` is `((day, position, entity, why), ...)` for every unusable
|
||||
member (`day` "today" or "tomorrow", `position` 1-based), so the page
|
||||
and the log can name it. With one member per day the forecast is what
|
||||
`read()` gives (test_opt17.py proves it).
|
||||
|
||||
OPT-27: with a `hold`, every fresh reading is remembered in it, and a
|
||||
member that is missing or stale is covered by its last fresh reading
|
||||
where `Hold.stand_in()` allows it (the forecast then has `kept` set when
|
||||
that took a reading older than `FRESH_S`). After midnight, if the today
|
||||
side is still short, the tomorrow figures read before midnight are
|
||||
today's, until midnight + `KEPT_UNTIL_H`. Without a `hold` this is
|
||||
exactly as before."""
|
||||
`read()` gives (test_opt17.py proves it)."""
|
||||
if not today:
|
||||
return None, NOT_SET, ()
|
||||
day0, day1 = _bounds(now)
|
||||
if hold is not None:
|
||||
hold.forget_before(day0)
|
||||
gaps, days, ats, why0, kept = [], [], [], None, False
|
||||
gaps, days, ats, why0 = [], [], [], None
|
||||
for i, (ent, st) in enumerate(today, 1):
|
||||
d, at, why = _today(st, day0, now)
|
||||
if d is not None and hold is not None:
|
||||
hold.remember("today", ent, d, at)
|
||||
elif hold is not None and why in _COVERABLE:
|
||||
r = hold.stand_in("today", ent, day0, now)
|
||||
if r is not None:
|
||||
d, at, k = r
|
||||
kept = kept or k
|
||||
if d is None:
|
||||
gaps.append(("today", i, ent, why))
|
||||
why0 = why0 or why
|
||||
else:
|
||||
days.append(d)
|
||||
ats.append(at)
|
||||
tdays, tats, tkept = [], [], False
|
||||
tdays = []
|
||||
for i, (ent, st) in enumerate(tomorrow, 1):
|
||||
t, t_at, why = _tomorrow(st, day0, day1, now)
|
||||
if t is not None and hold is not None:
|
||||
hold.remember("tomorrow", ent, t, t_at)
|
||||
elif hold is not None and why in _COVERABLE:
|
||||
r = hold.stand_in("tomorrow", ent, day1, now)
|
||||
if r is not None:
|
||||
t, t_at, k = r
|
||||
tkept = tkept or k
|
||||
t, why = _tomorrow(st, day0, day1, now)
|
||||
if t is None:
|
||||
gaps.append(("tomorrow", i, ent, why))
|
||||
else:
|
||||
tdays.append(t)
|
||||
tats.append(t_at)
|
||||
if why0 is not None and hold is not None and tomorrow and all(
|
||||
g[3] in _COVERABLE for g in gaps if g[0] == "today"):
|
||||
# Past midnight the sensors may still show yesterday's "left today".
|
||||
# The figure each tomorrow sensor gave before midnight is for this
|
||||
# day; all of them are needed, as for any sum.
|
||||
subs = [hold.stand_in("tomorrow", ent, day0, now, as_today=True)
|
||||
for ent, _st in tomorrow]
|
||||
if all(r is not None for r in subs):
|
||||
days = [r[0] for r in subs]
|
||||
ats = [r[1] for r in subs]
|
||||
gaps = [g for g in gaps if g[0] != "today"]
|
||||
why0, kept = None, True
|
||||
gaps = tuple(gaps)
|
||||
if why0 is not None:
|
||||
return None, why0, gaps
|
||||
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
|
||||
kept = kept or (tday is not None and tkept)
|
||||
# The oldest member's report is when the sum was last fully known.
|
||||
return Forecast(_sum(days), tday, min(ats), kept), None, gaps
|
||||
return Forecast(_sum(days), tday, min(ats)), None, gaps
|
||||
|
||||
@@ -246,29 +246,3 @@ 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,175 +448,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 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.
|
||||
@@ -680,13 +511,11 @@
|
||||
}
|
||||
addEventListener("hashchange", fromHash);
|
||||
|
||||
/* ---------------- polling: every 3 s while Home, Batteries or Forecast shows ---------------- */
|
||||
/* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */
|
||||
let timer = null;
|
||||
function schedule() {
|
||||
scheduleForecast();
|
||||
const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" ||
|
||||
$("#t-batt").getAttribute("aria-selected") === "true" ||
|
||||
$("#t-fc").getAttribute("aria-selected") === "true");
|
||||
$("#t-batt").getAttribute("aria-selected") === "true");
|
||||
if (live && !timer) { refresh(); timer = setInterval(refresh, 3000); }
|
||||
if (!live && timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
<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>
|
||||
@@ -115,32 +114,6 @@
|
||||
</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,12 +1103,8 @@ 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"
|
||||
# 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 ""))
|
||||
f"selling high is arithmetically impossible. §9.2: a fully "
|
||||
f"fixed product is self-consumption only")
|
||||
|
||||
# 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", "forecast": "Forecast", "settings": "Settings", "setup": "Setup", "label": "Screens"},
|
||||
"tabs": {"home": "Home", "batteries": "Batteries", "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,24 +68,6 @@
|
||||
"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"},
|
||||
@@ -360,7 +342,7 @@
|
||||
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
|
||||
"no_forecast": "No solar forecast, so it charges from solar only."
|
||||
},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "kept": "Solar forecast read {at}, used for tonight's plan.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"today": {
|
||||
"not_planned": "Not planned yet.",
|
||||
"no_runs": "No cheap or expensive hours left today.",
|
||||
@@ -385,11 +367,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 price left, {eur} per kWh.",
|
||||
"grid_why": "Lowest published price, {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 price left, {eur} per kWh.",
|
||||
"give_why": "Highest published price, {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.",
|
||||
@@ -434,6 +416,9 @@
|
||||
"step_in_would": "The batteries would step in above {kw}.",
|
||||
"period": "This 15-minute period is heading for {kw}.",
|
||||
"period_unmeasured": "Part of this 15-minute period was not measured.",
|
||||
"period_over": "At this rate this 15-minute period reaches {kw}.",
|
||||
"charge_cut": "The grid charge is cut back to stay under {kw}.",
|
||||
"charge_cut_would": "The grid charge would be cut back to stay under {kw}.",
|
||||
"prices": {
|
||||
"now": "Price now {eur} per kWh, all in.",
|
||||
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
|
||||
@@ -443,22 +428,6 @@
|
||||
"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", "forecast": "Prognose", "settings": "Instellingen", "setup": "Installatie", "label": "Schermen"},
|
||||
"tabs": {"home": "Start", "batteries": "Batterijen", "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,24 +65,6 @@
|
||||
"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"},
|
||||
@@ -300,7 +282,7 @@
|
||||
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
|
||||
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
|
||||
},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "kept": "Zonneprognose van {at}, gebruikt voor het plan van vanavond.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"today": {
|
||||
"not_planned": "Nog niet gepland.",
|
||||
"no_runs": "Vandaag geen goedkope of dure uren meer.",
|
||||
@@ -325,11 +307,11 @@
|
||||
"run": {
|
||||
"grid_what": "Laadt tot {kwh} van het net.",
|
||||
"grid_what_would": "Zou tot {kwh} van het net laden.",
|
||||
"grid_why": "Laagste prijs die nog over is, {eur} per kWh.",
|
||||
"grid_why": "Laagste gepubliceerde prijs, {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 prijs die nog over is, {eur} per kWh.",
|
||||
"give_why": "Hoogste gepubliceerde prijs, {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.",
|
||||
@@ -369,24 +351,11 @@
|
||||
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
|
||||
"period": "Dit kwartier gaat richting {kw}.",
|
||||
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
|
||||
"period_over": "Aan dit tempo komt dit kwartier op {kw}.",
|
||||
"charge_cut": "Laadt minder van het net om onder {kw} te blijven.",
|
||||
"charge_cut_would": "Zou minder van het net laden om onder {kw} te blijven.",
|
||||
"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,12 +150,6 @@ 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()
|
||||
@@ -477,7 +471,6 @@ 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),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.19.1"
|
||||
version: "0.19.2"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
|
||||
|
||||
@@ -936,6 +936,8 @@ def _s45_assigned(name, clear=("None",)):
|
||||
|
||||
|
||||
_s45_head_src = _s45_assigned("arb_headroom_w")
|
||||
# SAFETY-125: the guard's headroom with a late reading's seconds counted.
|
||||
S125_ROOM = "peakguard.charge_room_w(self.peak_guard, self._peak_proj)"
|
||||
print(f" main.py has {len(_s45_calls)} `arbitrage.decide(` call site(s); "
|
||||
f"charge_headroom_w= is {_s45_arg}, and self.arb_headroom_w is assigned "
|
||||
f"from {_s45_head_src}")
|
||||
@@ -944,7 +946,7 @@ check("main.py passes the guard's OWN measured headroom at its one "
|
||||
"transcription of it, so a mutation back to a literal or to max_w is red "
|
||||
"rather than silent",
|
||||
_s45_arg == ["self.arb_headroom_w"]
|
||||
and _s45_head_src == ["peakguard.charge_headroom_w(self.peak_guard)"])
|
||||
and _s45_head_src == [S125_ROOM])
|
||||
# ⚠️ AND THE HOIST DID NOT MOVE IT OUT OF THE CYCLE. An `arb_headroom_w` set
|
||||
# once in `__init__` and never refreshed would satisfy the pin above and would
|
||||
# size every trade from the guard decision of whatever cycle happened to run
|
||||
@@ -960,8 +962,7 @@ check("...and the assignment is INSIDE the same `cycle()` that makes the call, "
|
||||
"and not one carried from an arbitrary earlier one",
|
||||
any(isinstance(n, ast.Assign)
|
||||
and [ast.unparse(t) for t in n.targets] == ["self.arb_headroom_w"]
|
||||
and ast.unparse(n.value)
|
||||
== "peakguard.charge_headroom_w(self.peak_guard)"
|
||||
and ast.unparse(n.value) == S125_ROOM
|
||||
for n in ast.walk(_s45_cycle)))
|
||||
|
||||
|
||||
|
||||
@@ -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 price left, €{_lo.eur_per_kwh:.2f} "
|
||||
and _grid[0]["why"] == f"Lowest published price, €{_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,12 +831,10 @@ 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("five tabs with the five hash routes, in a keyboard tablist (UX-54 "
|
||||
"adds Forecast beside Home and Batteries)",
|
||||
check("four tabs with the four hash routes, in a keyboard tablist",
|
||||
re.findall(r'data-hash="(\w+)"', PAGE) == ["home", "batteries",
|
||||
"forecast", "settings",
|
||||
"setup"]
|
||||
and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 5
|
||||
"settings", "setup"]
|
||||
and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 4
|
||||
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 "
|
||||
|
||||
@@ -672,24 +672,9 @@ check("Cheap hours with a sun forecast charges as with none (P3)",
|
||||
and top > 80.0)
|
||||
|
||||
# The forecast goes away mid-run: Leftover solar stops at once (P3 option A).
|
||||
# OPT-27: a sensor that is unavailable is covered by its last fresh reading
|
||||
# for up to 3 h (`solarfc.Hold`), so "lost" here is a hold that never covers;
|
||||
# the next scenario is the same loss with the hold (test_opt27.py).
|
||||
DROP = UTC0 + timedelta(hours=1, minutes=30)
|
||||
|
||||
|
||||
class _NoHold(solarfc.Hold):
|
||||
def stand_in(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
def seed_profile_no_hold(c):
|
||||
seed_profile(c)
|
||||
c.fc_hold = _NoHold()
|
||||
|
||||
|
||||
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
|
||||
soc0={"a": 60.0, "b": 10.0}, patch=seed_profile_no_hold)
|
||||
soc0={"a": 60.0, "b": 10.0})
|
||||
invariants("forecast lost mid-run", rows, c)
|
||||
before = [r for r in rows if r["now"] < DROP and r["action"]
|
||||
== arbitrage.CHARGE]
|
||||
@@ -697,15 +682,6 @@ late = [r for r in rows if r["now"] > DROP + timedelta(
|
||||
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE]
|
||||
check("forecast lost mid-run: Leftover solar was charging, and stops within "
|
||||
"a forecast read (60 s) and a cycle", before and not late)
|
||||
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
|
||||
soc0={"a": 60.0, "b": 10.0})
|
||||
invariants("forecast unavailable mid-run, covered", rows, c)
|
||||
check("forecast unavailable mid-run (OPT-27): the last reading covers it, so "
|
||||
"Leftover solar goes on charging past the point where it was lost",
|
||||
[r for r in rows if r["now"] < DROP and r["action"]
|
||||
== arbitrage.CHARGE]
|
||||
and [r for r in rows if r["now"] > DROP + timedelta(
|
||||
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE])
|
||||
|
||||
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
|
||||
# charge, so B covers less of the evening - a money risk, never a safety one.
|
||||
|
||||
@@ -243,12 +243,16 @@ check("no opposite-direction split on any of these cycles",
|
||||
# ============================================================================ #
|
||||
print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
|
||||
# Printed by tools/opt20_scen.py on release/1.0 bf9019e (2026-10-15),
|
||||
# goodwe-addon-checks:1.
|
||||
# goodwe-addon-checks:1. SAFETY-125 (0.19.2) moved the two runs that charge
|
||||
# against the peak aim: the rig's P1 sample is 0.35 s old, and a grid charge
|
||||
# now counts those seconds, so ch_ch's quarters land on 3450.0 W where they
|
||||
# landed 1.3-1.4 W over it (bf9019e: 15fc25dd...), and ch_ch_maint's first,
|
||||
# partial quarter charges a little less at its end (bf9019e: e46e3c66...).
|
||||
AT_BASE = {
|
||||
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
|
||||
"ch_ch": "15fc25dd34186e56d34b752d37c9e8613295ba737938c92454acca08c83080fb",
|
||||
"ch_ch": "2b5879ec95810c39a5f0f82c04b9f8e358ea03ddce4ba187cc2b7e0885dfcbfa",
|
||||
"ch_ch_maint":
|
||||
"e46e3c666dd63ab33b56e83c0a560c07cec7774a9a70560f55c62a6ae6f55920",
|
||||
"e56eddabdb785f81793e7afa525f8e6da237b0b2165860aa8e0f8a6499b275a9",
|
||||
}
|
||||
_got = sc.no_probe_digests()
|
||||
for _k in AT_BASE:
|
||||
@@ -256,7 +260,8 @@ for _k in AT_BASE:
|
||||
f"{_got[_k][:16]}")
|
||||
check("with no refusal every share, grid, trade sentence, role and plant "
|
||||
"power of a scripted cheap run equals bf9019e's (A Solar first, A "
|
||||
"charging, A in maintenance charge)", _got == AT_BASE)
|
||||
"charging, A in maintenance charge; the last two as SAFETY-125 moved "
|
||||
"them)", _got == AT_BASE)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -1,548 +0,0 @@
|
||||
"""Runnable check for OPT-27. `python3 test_opt27.py`
|
||||
|
||||
Forecast.Solar's sensors stop changing in the evening, so at night every
|
||||
Forecast.Solar site was "no forecast": a price mode's night cheap run needed
|
||||
1.5x the cycle cost and Leftover solar bought nothing. And one missed poll
|
||||
(`unavailable`) dropped the forecast at once. `solarfc.Hold` now keeps the last
|
||||
fresh reading of each sensor, for the day it is FOR:
|
||||
|
||||
* up to `FRESH_S` when the sensor is missing or unavailable (a short outage);
|
||||
* from the end of the bell (20:00) until local midnight + 3 h however old it
|
||||
is (the night plan); never beyond, never for another day.
|
||||
|
||||
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
|
||||
|
||||
1. `solarfc` alone: the evening, the outage, the bounds, the day a figure is
|
||||
for, restart (dump/load), and no hold = as before.
|
||||
2. The real Controller on a day/night tariff (0.34/0.26, gap 0.08, cycle cost
|
||||
0.07), forecast sensors that stop updating at 17:05 local:
|
||||
a. the 22:00 night run is planned with the cycle-cost gap and charges;
|
||||
b. no forecast all day: the night run still needs 1.5x and does not charge;
|
||||
c. an evening restart keeps it (a new Controller on the saved store);
|
||||
d. a one-hour outage at 18:47 does not drop it;
|
||||
e. past midnight + 3 h the figure is not used, before it it is.
|
||||
3. The screens: the forecast line says when it was read and that it is used
|
||||
for tonight's plan (EN and NL), and Home agrees with control.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file): see the
|
||||
DECISIONS note and the dev report.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
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 opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, modetrade, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # 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)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
|
||||
|
||||
|
||||
def at(day, hh, mm=0):
|
||||
"""Local time on 2026-11-`day` (CET) as UTC."""
|
||||
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def st(kwh, when, *, unit="kWh"):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
|
||||
"last_updated": when.isoformat(),
|
||||
"last_reported": when.isoformat()}
|
||||
|
||||
|
||||
GONE = {"state": "unavailable", "attributes": {}}
|
||||
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. solarfc: the hold")
|
||||
D1_READ = at(3, 17, 5) # the last fresh reading, 17:05
|
||||
EVE = at(3, 22, 0)
|
||||
|
||||
|
||||
def rs(today, tomorrow, now, hold):
|
||||
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
|
||||
|
||||
|
||||
h = solarfc.Hold()
|
||||
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 17, 10), h)
|
||||
check("a fresh reading is a forecast, not kept", fc is not None
|
||||
and not fc.kept and fc.tomorrow.kwh == 12.0 and fc.today.kwh == 0.4)
|
||||
check("it is remembered (the hold is flagged for saving)", h.changed)
|
||||
fc_no, why_no, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, None)
|
||||
check("with no hold, 22:00 is no forecast (as before)",
|
||||
fc_no is None and why_no == solarfc.STALE)
|
||||
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, h)
|
||||
print(f" 22:00, sensors still show 17:05: {fc and fc.kept=}, {gaps=}")
|
||||
check("22:00 with the sensors stale since 17:05: the 17:05 figures are used",
|
||||
fc is not None and fc.kept and fc.tomorrow.kwh == 12.0
|
||||
and fc.at == D1_READ and gaps == ())
|
||||
check("... today has no sun left in it, tomorrow's is counted from 06:00",
|
||||
fc is not None and fc.between(EVE, at(4, 7, 0)) is not None
|
||||
and 0.0 < fc.between(EVE, at(4, 7, 0)) < 0.5
|
||||
and fc.today.kwh == 0.4)
|
||||
fc2, _w, _g = rs(GONE, GONE, EVE, h)
|
||||
check("the same when the sensors are unavailable at 22:00",
|
||||
fc2 is not None and fc2.kept and fc2.tomorrow.kwh == 12.0)
|
||||
# A new fresh reading replaces the old one at once.
|
||||
fc3, _w, _g = rs(st(0.0, at(3, 22, 20)), st(14.5, at(3, 22, 20)),
|
||||
at(3, 22, 30), h)
|
||||
check("a newer fresh reading is used and is not 'kept'",
|
||||
fc3 is not None and not fc3.kept and fc3.tomorrow.kwh == 14.5)
|
||||
fc3b, _w, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 23, 0), h)
|
||||
check("... and later the newest reading is the one kept, not the old one",
|
||||
fc3b is not None and fc3b.tomorrow.kwh == 14.5)
|
||||
|
||||
# A short outage: unavailable for one or two polls, any time of day.
|
||||
h2 = solarfc.Hold()
|
||||
rs(st(1.1, at(3, 18, 5)), st(12.0, at(3, 18, 5)), at(3, 18, 6), h2)
|
||||
for label, now in (("18:47", at(3, 18, 47)), ("19:20", at(3, 19, 20)),
|
||||
("19:47", at(3, 19, 47)), ("20:55", at(3, 20, 55))):
|
||||
fo, _w, _g = rs(GONE, GONE, now, h2)
|
||||
check(f"unavailable at {label}: the 18:05 forecast is still used",
|
||||
fo is not None and fo.tomorrow.kwh == 12.0)
|
||||
fo, _w, _g = rs(GONE, GONE, at(3, 20, 55), solarfc.Hold())
|
||||
check("(nothing was ever read: still no forecast)", fo is None)
|
||||
# Daytime is as before: an outage longer than FRESH_S drops it.
|
||||
h3 = solarfc.Hold()
|
||||
rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h3)
|
||||
d_in = rs(GONE, GONE, at(4, 11, 50), h3)[0]
|
||||
d_out = rs(GONE, GONE, at(4, 12, 10), h3)[0]
|
||||
d_out2 = rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 14, 0), h3)[0]
|
||||
check("daytime: an outage under 3 h is covered, one over 3 h is not",
|
||||
d_in is not None and not d_in.kept and d_out is None)
|
||||
check("daytime: a sensor that stopped is stale after 3 h, as before "
|
||||
"(the hold does not start before the bell ends)", d_out2 is None)
|
||||
|
||||
# Round 2: today fresh, tomorrow's sensor stopped or down in the daytime.
|
||||
h9 = solarfc.Hold()
|
||||
rs(st(5.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h9)
|
||||
for label, tm in (("stale", st(12.0, at(4, 9, 5))), ("unavailable", GONE)):
|
||||
fd = rs(st(3.0, at(4, 13, 55)), tm, at(4, 14, 0), h9)[0]
|
||||
check(f"14:00, today fresh, tomorrow {label} since 09:05 (over 3 h): "
|
||||
"no tomorrow, nothing kept",
|
||||
fd is not None and fd.tomorrow is None and not fd.kept)
|
||||
fn = rs(st(0.1, at(4, 20, 55)), tm, at(4, 21, 0), h9)[0]
|
||||
check(f"21:00, tomorrow {label}: the 09:05 figure covers it, kept",
|
||||
fn is not None and fn.tomorrow is not None
|
||||
and fn.tomorrow.kwh == 12.0 and fn.kept)
|
||||
fs = rs(st(3.0, at(4, 10, 55)), tm, at(4, 11, 0), h9)[0]
|
||||
check(f"11:00, tomorrow {label} for under 3 h: still covered, not kept",
|
||||
fs is not None and fs.tomorrow is not None and not fs.kept)
|
||||
|
||||
# The bound: midnight + 3 h, and the day a figure is for.
|
||||
fm = rs(st(0.0, at(3, 23, 0)), st(0.0, at(3, 23, 0)), at(4, 1, 0), h2)[0]
|
||||
check("00:30 is covered by the 18:05 tomorrow figure, as TODAY's",
|
||||
rs(GONE, GONE, at(4, 0, 30), h2)[0] is not None)
|
||||
fmid = rs(GONE, GONE, at(4, 2, 59), h2)[0]
|
||||
print(f" 02:59: {fmid and (fmid.today.kwh, fmid.tomorrow, fmid.kept)}")
|
||||
check("02:59: tomorrow's 12.0 kWh is now today's, there is no tomorrow",
|
||||
fmid is not None and fmid.today.kwh == 12.0 and fmid.tomorrow is None
|
||||
and fmid.kept and fmid.today.day0 == at(4, 0, 0))
|
||||
check("... its sun is counted on the day it is for (06:00-20:00 on the 4th)",
|
||||
fmid is not None and fmid.between(at(4, 3, 0), at(4, 7, 0)) is not None
|
||||
and fmid.between(at(4, 3, 0), at(4, 7, 0)) > 0.0)
|
||||
check("03:01 (midnight + 3 h): not used any more",
|
||||
rs(GONE, GONE, at(4, 3, 1), h2)[0] is None)
|
||||
check("... and not the next evening either (older than one night)",
|
||||
rs(GONE, GONE, at(4, 22, 0), h2)[0] is None)
|
||||
fe = rs(st(0.1, at(4, 21, 50)), GONE, at(4, 22, 0), h2)[0]
|
||||
check("... the next evening, with today fresh and tomorrow's sensor down, "
|
||||
"there is no tomorrow: the 4th's figure is not the 5th's",
|
||||
fe is not None and fe.today.kwh == 0.1 and fe.tomorrow is None)
|
||||
# Never today's figure for tomorrow.
|
||||
h4 = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h4)
|
||||
# Next day 00:30: today's sensor is fresh again (4.0), tomorrow's is gone.
|
||||
fx = rs(st(4.0, at(4, 0, 20)), GONE, at(4, 0, 30), h4)[0]
|
||||
check("00:30: today is the fresh 4.0, and there is NO tomorrow "
|
||||
"(the 3rd's 'today' 3.0 and 'tomorrow' 12.0 are not the 5th's)",
|
||||
fx is not None and fx.today.kwh == 4.0 and fx.tomorrow is None
|
||||
and not fx.kept)
|
||||
fy = rs(st(4.0, at(4, 0, 20)), st(9.0, at(4, 0, 20)), at(4, 0, 30), h4)[0]
|
||||
check("00:30 with both fresh: tomorrow is the fresh 9.0, not the old 12.0",
|
||||
fy is not None and fy.tomorrow.kwh == 9.0 and not fy.kept)
|
||||
# A tomorrow sensor that is down while today's is fresh, in the evening: the
|
||||
# hold gives the tomorrow figure for tomorrow, never today's figure.
|
||||
h5 = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h5)
|
||||
fz = rs(st(0.2, at(3, 21, 30)), GONE, at(3, 21, 40), h5)[0]
|
||||
check("21:40: today fresh, tomorrow down: tomorrow is the kept 12.0 (not "
|
||||
"today's 0.2 or 3.0)", fz is not None and fz.tomorrow.kwh == 12.0
|
||||
and fz.today.kwh == 0.2 and fz.kept)
|
||||
# A set-up fault is never covered.
|
||||
h6 = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h6)
|
||||
fb = rs(st("lots", at(3, 21, 30)), st(12.0, at(3, 21, 30)),
|
||||
at(3, 21, 40), h6)
|
||||
fu = rs(st(0.2, at(3, 21, 30), unit="W"), st(12.0, at(3, 21, 30)),
|
||||
at(3, 21, 40), h6)
|
||||
check("a bad value or a wrong unit is not covered by the hold",
|
||||
fb[0] is None and fb[1] == solarfc.BAD and fu[0] is None
|
||||
and fu[1] == solarfc.UNIT)
|
||||
# Several sensors: all or nothing.
|
||||
h7 = solarfc.Hold()
|
||||
two_t = [("sensor.t1", st(1.0, at(3, 17, 0))),
|
||||
("sensor.t2", st(2.0, at(3, 17, 0)))]
|
||||
two_m = [("sensor.m1", st(5.0, at(3, 17, 0))),
|
||||
("sensor.m2", st(6.0, at(3, 17, 0)))]
|
||||
solarfc.read_sum(two_t, two_m, at(3, 17, 5), h7)
|
||||
gone2 = [("sensor.t1", GONE), ("sensor.t2", GONE)]
|
||||
f2 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
|
||||
at(3, 22, 0), h7)
|
||||
check("three or two sensors: the kept figures are summed",
|
||||
f2[0] is not None and f2[0].tomorrow.kwh == 11.0
|
||||
and f2[0].today.kwh == 3.0 and f2[2] == ())
|
||||
h8 = solarfc.Hold()
|
||||
solarfc.read_sum(two_t[:1] + [("sensor.t2", GONE)], two_m, at(3, 17, 5), h8)
|
||||
f3 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
|
||||
at(3, 22, 0), h8)
|
||||
check("one sensor never read fresh: no partial sum, the gap is named",
|
||||
f3[0] is None and any(g[:3] == ("today", 2, "sensor.t2") for g in f3[2]))
|
||||
# Restart: what the store holds comes back.
|
||||
saved = json.loads(json.dumps(h.dump()))
|
||||
hr = solarfc.Hold(saved)
|
||||
fr = rs(GONE, GONE, at(3, 23, 0), hr)[0]
|
||||
check("dump/load through JSON: the kept figures come back (40 min old: "
|
||||
"inside FRESH_S, so not 'kept' in the screens' sense)",
|
||||
fr is not None and not fr.kept and fr.tomorrow.kwh == 14.5
|
||||
and fr.at == at(3, 22, 20))
|
||||
junk = solarfc.Hold({"r": [["today", 7, "x", "y", "z", []],
|
||||
["tomorrow", T_ENT, "2026-11-04T00:00:00+00:00",
|
||||
-1, "2026-11-03T16:00:00+00:00", []],
|
||||
"bad", None]})
|
||||
check("a damaged store entry is dropped, not raised",
|
||||
rs(GONE, GONE, at(3, 22, 0), junk)[0] is None
|
||||
and solarfc.Hold(None).dump()["r"] == [] and solarfc.Hold(5).changed
|
||||
is False)
|
||||
hp = solarfc.Hold()
|
||||
rs(st(8.0, at(3, 9, 0), ), st(12.0, at(3, 9, 0)), at(3, 9, 5), hp)
|
||||
periods = [{"period_start": f"2026-11-04T{hh:02d}:00:00+01:00",
|
||||
"pv_estimate": 1.0} for hh in range(8, 16)]
|
||||
s_per = st(10.0, at(3, 17, 0))
|
||||
s_per["attributes"]["detailedForecast"] = periods
|
||||
rs(st(0.0, at(3, 17, 0)), s_per, at(3, 17, 5), hp)
|
||||
hq = solarfc.Hold(json.loads(json.dumps(hp.dump())))
|
||||
fq = rs(GONE, GONE, at(3, 22, 0), hq)[0]
|
||||
check("an hourly split (Solcast shape) survives the store",
|
||||
fq is not None and fq.tomorrow.periods
|
||||
and abs(fq.between(at(4, 8, 0), at(4, 16, 0)) - 7.5) < 1e-6)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. The real Controller: day/night 0.34 / 0.26, cycle cost 0.07")
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.26},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.34}]}')
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
DAY3 = 3
|
||||
|
||||
|
||||
def opts(cost=0.07):
|
||||
o = dict(rp.options(True, False, 2))
|
||||
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE,
|
||||
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT)
|
||||
return o
|
||||
|
||||
|
||||
def stops_at(last, *, down=()):
|
||||
"""Sensors that update until `last` and then show that reading for
|
||||
good. `down`: (from, to) windows in which they are unavailable."""
|
||||
def f(now, ent):
|
||||
for a, z in down:
|
||||
if a <= now < z:
|
||||
return GONE
|
||||
when = min(now, last)
|
||||
return st(0.4 if ent == T_ENT else 12.0, when)
|
||||
return f
|
||||
|
||||
|
||||
def never(now, ent):
|
||||
return st(0.0 if ent == T_ENT else 12.0, now - timedelta(hours=9))
|
||||
|
||||
|
||||
def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
|
||||
keep_store=True, snap=False):
|
||||
"""-> (controller, rows, pre_rows). `pre`: the times the forecast is read
|
||||
before the first cycle (the evening). `restart`: a time in `pre` at which
|
||||
the add-on restarts: a new Controller on the saved store."""
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in CHOICE.items():
|
||||
for k, val in (("pack_use", use), ("pack_mode", mode),
|
||||
("pack_sell", sell)):
|
||||
store.data[unit_key(f"inverter-{u}", k)] = val
|
||||
clock = [pre[0] if pre else start]
|
||||
rows, pre_rows = [], []
|
||||
cur = [store]
|
||||
|
||||
class H(rp.Hass):
|
||||
async def state(self, e):
|
||||
return sensors(clock[0], e)
|
||||
|
||||
def build(store):
|
||||
hass = H(("a", "b"), lambda t: 300.0, {"a": 40.0, "b": 40.0})
|
||||
tmp = tempfile.mkdtemp(prefix="o27-")
|
||||
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
# A house profile a few days old (300 W round the clock), so the day
|
||||
# plan is made as it is on a site that has run for a week.
|
||||
c.house_profile.load({d: {str(hh): 300.0 for hh in range(24)}
|
||||
for d in ("2026-10-31", "2026-11-01",
|
||||
"2026-11-02")})
|
||||
return c, hass
|
||||
|
||||
async def go():
|
||||
c, hass = build(store)
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
task = None
|
||||
try:
|
||||
for t_pre in pre:
|
||||
clock[0] = t_pre
|
||||
if restart is not None and t_pre == restart:
|
||||
# The saved file, through JSON; `keep_store=False` loses
|
||||
# the hold only (the control for the test).
|
||||
data = json.loads(json.dumps(cur[0].data))
|
||||
if not keep_store:
|
||||
data.pop(m.FC_HOLD_KEY, None)
|
||||
new = rp._Store()
|
||||
new.data.update(data)
|
||||
cur[0] = new
|
||||
c, hass = build(new)
|
||||
await c._read_forecast()
|
||||
pre_rows.append((t_pre, c.solar_fc is not None,
|
||||
bool(c.solar_fc and c.solar_fc.kept)))
|
||||
clock[0] = start
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
t = 0.0
|
||||
while t < minutes * 60:
|
||||
now = start + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if t == 0.0:
|
||||
for _ in range(500):
|
||||
if c.tariff_plan is not None and (
|
||||
c.tariff_at is not None and c.tariff_at >= now):
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(5.0, 10.0)
|
||||
rp_ = c.mode_runs
|
||||
rows.append(dict(
|
||||
t=t, fc_none=c.solar_fc is None,
|
||||
kept=bool(c.solar_fc and c.solar_fc.kept),
|
||||
runs_on=None if rp_ is None else rp_.on,
|
||||
reason=None if rp_ is None else rp_.reason,
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
charging=hass.plants["b"].p < -200.0,
|
||||
soc_b=hass.plants["b"].soc))
|
||||
t += 5.0
|
||||
keep = c.o
|
||||
c.o = dict(keep, arbitrage_enabled=True)
|
||||
try:
|
||||
c.mirror, c.mirror_why = homeview.trade_verdict(
|
||||
c, c.fresh_tariff_plan(clock[0]), clock[0])
|
||||
finally:
|
||||
c.o = keep
|
||||
c.cards = homeview.battery_rows(c, False, True)
|
||||
c.block = homeview.solar_block(c, clock[0])
|
||||
c.hold_saved = json.loads(json.dumps(cur[0].data.get(
|
||||
m.FC_HOLD_KEY)))
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
return c
|
||||
|
||||
return asyncio.run(go()), rows, pre_rows
|
||||
|
||||
|
||||
def why_of(c, name):
|
||||
card = first((b for b in c.cards if b["name"] == name), f"card {name}")
|
||||
return card["why_key"]
|
||||
|
||||
|
||||
EVENING = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
|
||||
LAST = at(DAY3, 17, 5)
|
||||
NIGHT_START = at(DAY3, 22, 0)
|
||||
|
||||
# a. a forecast that stops updating at sunset
|
||||
ca, ra, pa = drive27(opts(), stops_at(LAST), pre=EVENING + [at(DAY3, 21, 55)],
|
||||
start=NIGHT_START)
|
||||
la = nth(ra, -1, "last cycle")
|
||||
print(f" a. 22:00 with the 17:05 forecast: kept={la['kept']} on="
|
||||
f"{la['runs_on']} reason={la['reason']}; B charging "
|
||||
f"{sum(r['charging'] for r in ra)} of {len(ra)}; Why A {why_of(ca, A)} "
|
||||
f"B {why_of(ca, B)}; Home mirror {ca.mirror}")
|
||||
check("the forecast was read fresh before 17:05 and kept after it",
|
||||
any(not k for _t, ok, k in pa if _t <= LAST + timedelta(minutes=30))
|
||||
and all(ok for _t, ok, _k in pa)
|
||||
and all(k for _t, _ok, k in pa if _t >= at(DAY3, 20, 15)))
|
||||
check("22:00 night run: planned with the cycle-cost gap (needs 0.07 < 0.08)",
|
||||
present(la) and not la["fc_none"] and la["kept"]
|
||||
and la["runs_on"] is True and la["reason"] != "not_worth")
|
||||
check("... and B (Cheap hours) charges from the grid in the night period",
|
||||
sum(r["charging"] for r in ra) > len(ra) // 2
|
||||
and la["roles"].get(B) == modetrade.CHARGE
|
||||
and why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread")
|
||||
check("the day plan is made from the kept forecast (levels for Leftover "
|
||||
"solar), as it is from a fresh one",
|
||||
ca.day_plan is not None and ca.plan_targets() is not None
|
||||
and ca.block["state"] == "plan")
|
||||
check("Home's mirror of the gate allows it, as control does",
|
||||
ca.mirror is not None and ca.mirror_why == "allowed")
|
||||
|
||||
# b. no forecast all day
|
||||
cb, rb, pb = drive27(opts(), never, pre=EVENING + [at(DAY3, 21, 55)],
|
||||
start=NIGHT_START)
|
||||
lb = nth(rb, -1, "last cycle")
|
||||
print(f" b. no forecast all day: on={lb['runs_on']} reason={lb['reason']}; "
|
||||
f"B charging {sum(r['charging'] for r in rb)}; Why A {why_of(cb, A)} "
|
||||
f"B {why_of(cb, B)}")
|
||||
check("with no forecast all day the night run still needs 1.5x (0.105 > 0.08)"
|
||||
": not worth, no charge, 'Prices differ too little'",
|
||||
present(lb) and lb["fc_none"] and lb["runs_on"] is False
|
||||
and lb["reason"] == "not_worth"
|
||||
and not any(r["charging"] for r in rb)
|
||||
and why_of(cb, A) == "no_spread" and why_of(cb, B) == "no_spread"
|
||||
and not any(ok for _t, ok, _k in pb))
|
||||
check("... and there is no day plan (Leftover solar takes solar only)",
|
||||
cb.day_plan is None and cb.plan_targets() is None
|
||||
and cb.block["state"] == "no_forecast")
|
||||
check("... Home's mirror refuses it too, for the gap ('not_worth')",
|
||||
cb.mirror is None and cb.mirror_why == "not_worth")
|
||||
check("... and nothing is saved for the night (no fresh reading was made)",
|
||||
cb.hold_saved is None or cb.hold_saved.get("r") == [])
|
||||
|
||||
# c. an evening restart
|
||||
cc, rc, pc = drive27(opts(), stops_at(LAST),
|
||||
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
|
||||
restart=at(DAY3, 21, 55))
|
||||
lc = nth(rc, -1, "last cycle")
|
||||
check("an evening restart (new Controller on the saved store) keeps the "
|
||||
"forecast: the night run is planned and charges",
|
||||
present(lc) and lc["kept"] and lc["runs_on"] is True
|
||||
and sum(r["charging"] for r in rc) > len(rc) // 2
|
||||
and present(nth(pc, -1, "last read")) and pc[-1][1] and pc[-1][2])
|
||||
cc0, rc0, pc0 = drive27(opts(), stops_at(LAST),
|
||||
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
|
||||
restart=at(DAY3, 21, 55), keep_store=False)
|
||||
lc0 = nth(rc0, -1, "last cycle")
|
||||
check("(control: the same restart with the store lost is blind, 1.5x)",
|
||||
present(lc0) and lc0["fc_none"] and lc0["runs_on"] is False
|
||||
and present(nth(pc0, -1, "last read")) and not pc0[-1][1])
|
||||
|
||||
# d. a one-hour outage at 18:47
|
||||
OUT = (at(DAY3, 18, 47), at(DAY3, 19, 47))
|
||||
quarter = [at(DAY3, 16, 0) + timedelta(minutes=15 * i) for i in range(24)]
|
||||
cd, rd, pd = drive27(opts(), stops_at(at(DAY3, 18, 5), down=[OUT]),
|
||||
pre=quarter + [at(DAY3, 21, 55)], start=NIGHT_START)
|
||||
ld = nth(rd, -1, "last cycle")
|
||||
inside = [(t_, ok) for t_, ok, _k in pd if OUT[0] <= t_ < OUT[1]]
|
||||
print(f" d. reads inside the outage: {len(inside)}, with a forecast: "
|
||||
f"{sum(ok for _t, ok in inside)}")
|
||||
check("a one-hour outage at 18:47 does not drop the forecast "
|
||||
"(every read from 16:00 to 22:00 has one)",
|
||||
present(pd) and len(inside) >= 4 and all(ok for _t, ok, _k in pd))
|
||||
check("... and the 22:00 night run is planned and charges",
|
||||
present(ld) and ld["runs_on"] is True
|
||||
and sum(r["charging"] for r in rd) > len(rd) // 2)
|
||||
|
||||
# e. the bound: midnight + 3 h
|
||||
mid = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
|
||||
ce, re_, pe = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 2, 30)],
|
||||
start=at(4, 2, 30))
|
||||
le = nth(re_, -1, "last cycle")
|
||||
check("02:30 (before midnight + 3 h): the figure from 17:05 is still used, "
|
||||
"as today's; the night run is planned",
|
||||
present(le) and le["kept"] and le["runs_on"] is True)
|
||||
cf, rf, pf = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 3, 30)],
|
||||
start=at(4, 3, 30))
|
||||
lf = nth(rf, -1, "last cycle")
|
||||
check("03:30 (after midnight + 3 h): a figure from before the night is not "
|
||||
"used: no forecast, 1.5x, not worth, no charge",
|
||||
present(lf) and lf["fc_none"] and lf["runs_on"] is False
|
||||
and lf["reason"] == "not_worth" and not any(r["charging"] for r in rf))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. The screens")
|
||||
blk = ca.block
|
||||
print(f" forecast line: {blk['line']!r} (state {blk['state']})")
|
||||
check("the forecast line says when it was read and that it is used for "
|
||||
"tonight's plan", blk["line"] == "Solar forecast read 17:05, used for "
|
||||
"tonight's plan.")
|
||||
check("the same line comes from the Forecast tab (dayview) on today",
|
||||
homeview.line_solar(0.4, 0.3, "17:05", kept=True) == blk["line"]
|
||||
and homeview.line_solar(0.4, 0.3, "17:05").startswith(
|
||||
"Solar still to come today"))
|
||||
en = json.load(open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
||||
encoding="utf-8"))
|
||||
nl = json.load(open(os.path.join(HERE, "app", "ui_text", "nl.json"),
|
||||
encoding="utf-8"))
|
||||
k_en = en["home_words"]["solar"]["kept"]
|
||||
k_nl = nl["home_words"]["solar"]["kept"]
|
||||
print(f" EN {k_en!r}\n NL {k_nl!r}")
|
||||
check("EN and NL both say it, with the time, and the NL is not the English",
|
||||
"{at}" in k_en and "{at}" in k_nl and k_en != k_nl
|
||||
and "plan" in k_en and "vanavond" in k_nl)
|
||||
check("a daytime reading keeps the old line (daytime unchanged)",
|
||||
"Updated 09:05" in homeview.line_solar(1.0, 0.7, "09:05")
|
||||
and "Bijgewerkt" in json.dumps(nl["home_words"]["solar"]["line"]))
|
||||
check("no forecast: the old 'No solar forecast now' line, no 'kept'",
|
||||
cb.block["state"] == "no_forecast"
|
||||
and "tonight" not in cb.block["line"])
|
||||
check("control and Batteries screen agree on the 22:00 run: not 'no_spread'",
|
||||
why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread"
|
||||
and la["runs_on"] is True)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -609,6 +609,10 @@ print("\n5. modes off: byte-identical to release/1.0 e3e4ba6")
|
||||
# AT_BASE the same rows through `ident.trade_digest()`, equal to what
|
||||
# e3e4ba6 gives through it;
|
||||
# AT_NOW this tree's own digests with nothing hidden.
|
||||
# SAFETY-125 (0.19.2) moved the two lag pins: their P1 sample is 0.35 s old on
|
||||
# every cycle, and a grid charge now counts those seconds at the held reading,
|
||||
# so 16 of 1080 cycles charge about 1.7 W less (before: n1_lag f6c67b35...
|
||||
# masked, 78aac892... full; n2_lag as n2). n1 and n2 still equal e3e4ba6.
|
||||
AT_BASE_OLD = {
|
||||
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
@@ -618,14 +622,14 @@ AT_BASE_OLD = {
|
||||
AT_BASE = {
|
||||
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
||||
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||
"n1_lag": "f6c67b351bd732fb69e62cafbfdb2bcff6032f5d8845836659d096b8c677d3c0",
|
||||
"n2_lag": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||
"n1_lag": "28e3182ae73c52dae0e65b2495bc04b2b71003889bf9de1a394b047d2c0e0edf",
|
||||
"n2_lag": "45bfb095b5707a91316f56afc408dba616afc729ce4c83a10b337f36948fa3f2",
|
||||
}
|
||||
AT_NOW = {
|
||||
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
|
||||
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||
"n1_lag": "78aac892975be81a7ceac0c000ed441a731bd4f42ff9f729996cbee53d6b7f02",
|
||||
"n2_lag": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||
"n1_lag": "1827918d570bfa0cc371c35dbbb64378b4937557061e7e1a396aba7663e68117",
|
||||
"n2_lag": "6acc1d1b5a11f3481c7a30d384612f37b59f8e5e9cf89204edd07d09a5b90d8d",
|
||||
}
|
||||
_cmd_digest = ident.trade_digest
|
||||
_rows5 = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_BASE}
|
||||
@@ -636,7 +640,8 @@ for _k in AT_BASE:
|
||||
f"{_got[_k][:16]}")
|
||||
check("with battery modes off every command, integrator, allocation and "
|
||||
"trade action equals e3e4ba6's (the sentence masked as above), also when "
|
||||
"every quarter boundary's cycle still sees the old P1 block",
|
||||
"every quarter boundary's cycle still sees the old P1 block (there, as "
|
||||
"SAFETY-125 moved it)",
|
||||
_got == AT_BASE)
|
||||
check("...the mask is needed and nothing more: unmasked, the digests differ "
|
||||
"from e3e4ba6's printed ones, and equal this tree's own pins, so a "
|
||||
|
||||
@@ -19,7 +19,9 @@ DECISIONS/SAFETY-121-bound-every-quarter.md.
|
||||
1. The rig's shape (1 s meter that lags the battery, 1 s battery lag, house
|
||||
800 W, rating 2500 W, limit 2500 W, margin 50, charge 2000 W, no
|
||||
forecast), 2 h, three seeds: no notification, the cap held, every command
|
||||
the same as without the fix; without the fix it warns.
|
||||
the same as without the fix; without the fix it warns. SAFETY-125 sized
|
||||
the N=1 charge to the room, which removed this hand-back; these runs keep
|
||||
the old sizing, and one run checks that with it nothing hands back.
|
||||
2. A genuine integrator bound still warns: the loop in charge with the house
|
||||
over the rating (at once, as before), and the loop at its rail after a
|
||||
hand-back (after the settle).
|
||||
@@ -140,10 +142,29 @@ def house(seed, base=800.0, step=None):
|
||||
return load
|
||||
|
||||
|
||||
_SIZED_CH = Controller._charge_headroom
|
||||
|
||||
|
||||
def _n1_unsized(self, floor, claims):
|
||||
"""The N=1 cheap-window charge as before SAFETY-125: not sized to the
|
||||
quarter's room, so the guard steps in at each quarter's end and hands
|
||||
back - the shape this file grades the settle on."""
|
||||
return floor if len(self.inverters) < 2 \
|
||||
else _SIZED_CH(self, floor, claims)
|
||||
|
||||
|
||||
def run(load, *, n=1, dur=7200.0, delay=1, extra=None, cheap=True, cut=None,
|
||||
charging=True, blind=0.0):
|
||||
charging=True, blind=0.0, sized=False):
|
||||
"""One run of `n` inverters of 2500 W; `charging=False`: no maintenance,
|
||||
the loop in charge; `blind`: no meter reading until that second."""
|
||||
the loop in charge; `blind`: no meter reading until that second.
|
||||
SAFETY-125 sizes the N=1 charge to the room, so it no longer hands back
|
||||
at a quarter's end; an N=1 charge here keeps the old sizing unless
|
||||
`sized`, so the hand-back this file is about still happens."""
|
||||
if n == 1 and charging and not sized:
|
||||
return patched(Controller, "_charge_headroom", _n1_unsized)(
|
||||
lambda: run(load, n=n, dur=dur, delay=delay, extra=extra,
|
||||
cheap=cheap, cut=cut, charging=charging,
|
||||
blind=blind, sized=True))
|
||||
extra = dict(extra or {})
|
||||
s84.OPT = dict(OPT0, peak_cap_w=int(CAP * n), peak_margin_w=50,
|
||||
maintenance_charge_w=2000)
|
||||
@@ -231,6 +252,15 @@ check("non-vacuous: without the fix every run warns, at a quarter's edge",
|
||||
all(len(notes(c)) >= 1 for _r, c in OLD.values())
|
||||
and all(min(t % QUARTER, QUARTER - t % QUARTER) <= 40
|
||||
for _r, c in OLD.values() for t in notes(c)))
|
||||
_sz = run(house(1), sized=True)
|
||||
print(f" SAFETY-125's sizing, seed 1: guard step-ins "
|
||||
f"{guard_steps(_sz[1])}, notifications {notes(_sz[1])}, highest "
|
||||
f"quarter {max(quarters(_sz[1]), default=0.0):.0f} W")
|
||||
check("SAFETY-125: with the N=1 charge sized to the room the guard does not "
|
||||
"step in at the quarter's end, nothing warns and the cap holds",
|
||||
not any(r[-1][1] and r[-1][1][0] for r in _sz[0])
|
||||
and notes(_sz[1]) == [] and any(r[1] < -1000.0 for r in _sz[0])
|
||||
and len(quarters(_sz[1])) == 8 and max(quarters(_sz[1])) < CAP)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a genuine integrator bound still warns")
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,565 @@
|
||||
"""Runnable check for SAFETY-125. `python3 test_safety125.py`
|
||||
|
||||
A meter reading that is late near a quarter's end left seconds the
|
||||
projection did not count (SAFETY-99), and `charge_headroom_w()` read them as
|
||||
room: a grid charge went back to full power in the quarter's last seconds and
|
||||
the billed quarter went over the aim, above a banked month peak or the cap.
|
||||
And the peak guard's row said "under" while the period was over the limit at
|
||||
the current rate. DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md.
|
||||
|
||||
Drives the REAL Controller (read_inputs + cycle, the tariff task on a
|
||||
day/night profile, a P1 meter on a simulated clock) in the live shape: modes
|
||||
on, inverter-a Cheap hours at 5000 W charging harder than asked (as it did
|
||||
live), inverter-b Solar first, a house of 1 kW, cap 3500 W, margin 50 W.
|
||||
|
||||
1. `charge_room_w()` on its own.
|
||||
2. A reading 30 s late at each quarter's end: every quarter at or under the
|
||||
aim, with a month peak banked above the cap and with none, with the P1
|
||||
projection and with a `peak_forecast_entity`; the charge is cut late in
|
||||
the quarter and back in the next one.
|
||||
3. The same for a price trade with modes off, and a maintenance charge at N=2.
|
||||
4. A healthy meter: the quarters as before, and the row and the month-peak
|
||||
line say what is true.
|
||||
5. The words on their own.
|
||||
6. One inverter: the maintenance charge is sized to the room too (owner
|
||||
2026-10-29), on a 5 s and a 10 s meter, with and without a banked peak
|
||||
and with late readings; the guard no longer steps in during it.
|
||||
7. The row during an N=2 maintenance charge and in shadow mode.
|
||||
8. Edge cases: a reading past its 30 s hold (blind), a start mid-quarter,
|
||||
the summer-time change, the forecast entity in W, a 10 s meter, the
|
||||
quarter's last second.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
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 opt11_identity as rig # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
import app.p1 as p1mod # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
from app import homeview, messages, peakguard, tariff # noqa: E402
|
||||
from app import price as _price, static_tariff as _st # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
from app.tariff import QUARTER_S, Projection # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
PEAK = 5413.17 # October's banked peak on the live site
|
||||
AIM_PEAK = PEAK - 50.0
|
||||
AIM_CAP = 3500.0 - 50.0
|
||||
TOL = 10.0 # W over the aim a quarter may land (SAFETY-81)
|
||||
FC = "sensor.piek_prognose_kwartier"
|
||||
L = timezone(timedelta(hours=1))
|
||||
START = datetime(2026, 10, 29, 0, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.2333},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.2801}]}')
|
||||
|
||||
|
||||
def opts(*, fc=False, modes=True, arb=False):
|
||||
o = dict(rp.options(modes, arb, 2))
|
||||
o.update(cycle_cost_threshold_eur=0.03, static_profile=PROFILE,
|
||||
peak_cap_w=3500, peak_margin_w=50)
|
||||
o["inverters"][0].update(max_w=5000, capacity_kwh=15.36)
|
||||
o["inverters"][1].update(max_w=4600, capacity_kwh=20.48)
|
||||
if fc:
|
||||
o["peak_forecast_entity"] = FC
|
||||
return o
|
||||
|
||||
|
||||
class Harder(rp.Plant):
|
||||
"""Charges harder than asked, as inverter-a did live (asked -4271 W,
|
||||
measured about -4750 W; asked -5000 W, about -5210 W)."""
|
||||
|
||||
def step(self, dt, floor):
|
||||
c = self.cmd
|
||||
if c < 0:
|
||||
self.cmd = max(c * 1.11, -5216.0)
|
||||
try:
|
||||
super().step(dt, floor)
|
||||
finally:
|
||||
self.cmd = c
|
||||
|
||||
|
||||
async def _no_fetch(*a, **k):
|
||||
raise AssertionError("the tariff task reached for a price feed")
|
||||
|
||||
|
||||
class OnItsOwn(rp.Plant):
|
||||
"""Charges 5000 W whatever it is asked (shadow mode: nothing is
|
||||
written, the inverter charges on its own)."""
|
||||
|
||||
def step(self, dt, floor):
|
||||
self.cmd = -5000.0
|
||||
super().step(dt, floor)
|
||||
|
||||
|
||||
def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None,
|
||||
period=0.0, fc_unit="kW", plant=Harder):
|
||||
"""The real Controller from `start` (00:00 local) for `minutes` at a 1 s
|
||||
cycle (OPT-15's lag). `late`: no P1 telegram in each quarter's last
|
||||
`late` s; `period`: one telegram every `period` s (0: every cycle).
|
||||
-> (controller, rows, {quarter start: true grid average}, samples), the
|
||||
samples taken every `every` cycles from the second quarter (the first
|
||||
starts before the first reading): (projection, ceiling, engaged,
|
||||
peak_guard row, month-peak line)."""
|
||||
store = rp._Store()
|
||||
for u, mode in (("a", "cheap_hours"), ("b", "solar_first")):
|
||||
store.data[unit_key(f"inverter-{u}", "pack_use")] = True
|
||||
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
|
||||
store.data[unit_key(f"inverter-{u}", "pack_sell")] = False
|
||||
rows, samples, sums = [], [], {}
|
||||
t0 = start or START
|
||||
|
||||
async def go():
|
||||
lag = rp.Lag()
|
||||
hass = rp.Hass(("a", "b"), lambda t: 1000.0, {"a": 11.0, "b": 10.0},
|
||||
limits={"a": (-5000.0, 5000.0),
|
||||
"b": (-4600.0, 4600.0)}, lag=lag)
|
||||
hass.plants["a"].__class__ = plant
|
||||
fc_kw = [0.0]
|
||||
|
||||
async def state(e):
|
||||
# The HA quarter projection, in kW like the live template.
|
||||
return None if e != FC else {
|
||||
"state": (f"{fc_kw[0]:.3f}" if fc_unit == "kW"
|
||||
else f"{fc_kw[0] * 1000.0:.0f}"),
|
||||
"attributes": {"unit_of_measurement": fc_unit}}
|
||||
hass.state = state
|
||||
tmp = tempfile.mkdtemp(prefix="s125-")
|
||||
db = os.path.join(tmp, "tariff.db")
|
||||
if peak is not None:
|
||||
tariff.PeakStore(db)._db.execute(
|
||||
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
|
||||
(tariff.month_key(t0), peak,
|
||||
"2026-10-21T20:15:00+00:00")).connection.commit()
|
||||
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=db,
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
|
||||
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
|
||||
c.auto = True
|
||||
real = m.datetime, homeview.datetime, p1mod.time
|
||||
clock, simt = [t0], [0.0]
|
||||
|
||||
class _DT(real[0]):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
m.datetime = homeview.datetime = _DT
|
||||
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
|
||||
real_load = _price.load_prices, _st.load_prices
|
||||
_price.load_prices = _st.load_prices = _no_fetch
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
try:
|
||||
hass.advance(0.0, 10.0)
|
||||
t, n = 0.0, 0
|
||||
while t < minutes * 60:
|
||||
now = t0 + timedelta(seconds=t)
|
||||
hass.t, simt[0], clock[0] = t, t, now
|
||||
if t == 0.0:
|
||||
for _ in range(500):
|
||||
if c.tariff_plan is not None:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
q = int(now.timestamp() // QUARTER_S)
|
||||
acc = sums.setdefault(q, [0.0, 0])
|
||||
g_now = hass.grid()
|
||||
fc_kw[0] = (acc[0] + g_now * (QUARTER_S - acc[1])) \
|
||||
/ QUARTER_S / 1000.0
|
||||
if now.timestamp() % QUARTER_S < QUARTER_S - late \
|
||||
and t >= lag.p1_s \
|
||||
and (not period or t % period < 1e-9):
|
||||
imp, exp = split_signed(hass._past(lag.meter_s
|
||||
+ lag.p1_s)[0])
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now - timedelta(seconds=lag.p1_s),
|
||||
ingest_mono=1e6 + t))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(lag.tick, 10.0)
|
||||
acc[0] += max(0.0, hass.grid())
|
||||
acc[1] += 1
|
||||
rows.append((now, c.target, hass.plants["a"].p,
|
||||
getattr(c.arb, "action", None)))
|
||||
pg = c.peak_guard
|
||||
if every and n % every == 0 and t >= QUARTER_S \
|
||||
and pg is not None and pg.projected_w is not None:
|
||||
row = first((r["text"] for r in c.checks(now)
|
||||
if r["id"] == "peak_guard"), "guard row")
|
||||
samples.append((pg.projected_w, pg.ceiling_w, pg.engaged,
|
||||
row, homeview.month_peak(c, now)["line"]))
|
||||
t += lag.tick
|
||||
n += 1
|
||||
finally:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime, p1mod.time = real
|
||||
_price.load_prices, _st.load_prices = real_load
|
||||
return c
|
||||
c = asyncio.run(go())
|
||||
full = {datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"):
|
||||
s / k for q, (s, k) in sorted(sums.items()) if k >= QUARTER_S}
|
||||
return c, rows, full, samples
|
||||
|
||||
|
||||
def into(now):
|
||||
return now.timestamp() % QUARTER_S
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. charge_room_w(): a late reading's seconds count at that reading")
|
||||
_dec = peakguard.decide(
|
||||
Projection(5800.0, 5800.0, 450.0, 1.0, True, START, 5800.0),
|
||||
batt_w=-4800.0, cap_w=3500, margin_w=50,
|
||||
reading=types.SimpleNamespace(is_observed=True, peak_w=PEAK),
|
||||
achievable_w=9600.0, soc=50.0, soc_floor=20.0)
|
||||
_room = peakguard.charge_headroom_w(_dec)
|
||||
_late = Projection(5800.0, 5800.0, 450.0, 430.0 / 450.0, False, START,
|
||||
5800.0)
|
||||
print(f" headroom {_room:.0f} W; 20 s late, 450 s left: "
|
||||
f"{peakguard.charge_room_w(_dec, _late):.0f} W")
|
||||
check("with every second counted it is charge_headroom_w() exactly",
|
||||
peakguard.charge_room_w(
|
||||
_dec, Projection(5800.0, 5800.0, 450.0, 1.0, True, START, 5800.0))
|
||||
== _room and _room > 0.0)
|
||||
check("20 s late at a 5800 W reading with 450 s left: 5800 x 20 / 450 W "
|
||||
"less room",
|
||||
abs(peakguard.charge_room_w(_dec, _late)
|
||||
- (_room - 5800.0 * 20.0 / 450.0)) < 1e-6)
|
||||
check("never below 0 W, and an export held counts nothing",
|
||||
peakguard.charge_room_w(_dec, Projection(
|
||||
5800.0, 5800.0, 880.0, 850.0 / 880.0, False, START, 5800.0)) == 0.0
|
||||
and peakguard.charge_room_w(_dec, Projection(
|
||||
5800.0, 5800.0, 450.0, 430.0 / 450.0, False, START, -900.0))
|
||||
== _room)
|
||||
check("None, blind (0 W) and no projection pass through",
|
||||
peakguard.charge_room_w(None, _late) is None
|
||||
and peakguard.charge_room_w(_dec, None) == _room
|
||||
and peakguard.charge_room_w(
|
||||
peakguard.PeakDecision(engaged=True, floor_w=0.0, ceiling_w=3500.0,
|
||||
ceiling_from="cap", blind="frozen"),
|
||||
_late) == 0.0)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
def graded(label, full, aim):
|
||||
print(f" {label}: quarters "
|
||||
f"{ {k: round(v, 1) for k, v in full.items()} } W, aim {aim:.0f}")
|
||||
return len(full) >= 2 and all(v <= aim + TOL for v in full.values())
|
||||
|
||||
|
||||
def cut_and_back(rows):
|
||||
"""The charge is off in each quarter's last 5 s, and back (A charging
|
||||
over 2 kW) within the next quarter's first minute."""
|
||||
ends = [r for r in rows if into(r[0]) >= QUARTER_S - 5]
|
||||
starts = {}
|
||||
for r in rows:
|
||||
if into(r[0]) < 60:
|
||||
q = int(r[0].timestamp() // QUARTER_S)
|
||||
starts[q] = starts.get(q, False) or r[2] < -2000.0
|
||||
return (present(first(iter(ends), "a quarter end"))
|
||||
and all(r[1] > -1.0 for r in ends)
|
||||
and len(starts) >= 2 and all(starts.values()))
|
||||
|
||||
|
||||
print("\n2. a reading 30 s late at each quarter's end, the live shape")
|
||||
GAP = {}
|
||||
for name, kw, aim in (
|
||||
("month peak 5413 W banked, P1 projection",
|
||||
dict(peak=PEAK), AIM_PEAK),
|
||||
("month peak banked, peak_forecast_entity (kW)",
|
||||
dict(peak=PEAK, fc=True), AIM_PEAK),
|
||||
("no peak banked (aim = cap - margin), P1 projection",
|
||||
dict(), AIM_CAP),
|
||||
("no peak banked, peak_forecast_entity", dict(fc=True), AIM_CAP)):
|
||||
fc = kw.pop("fc", False)
|
||||
_c, _rows, _full, _s = drive(opts(fc=fc), late=30.0, **kw)
|
||||
GAP[name] = (_c, _rows, _full)
|
||||
check(f"{name}: every quarter at or under the aim "
|
||||
f"({aim:.0f} W, +{TOL:.0f} W landing)", graded(name, _full, aim))
|
||||
check(f"{name}: the charge is off in the quarter's last seconds and "
|
||||
f"back in the next quarter", cut_and_back(_rows))
|
||||
_c_fc = GAP["month peak banked, peak_forecast_entity (kW)"][0]
|
||||
check("the forecast entity is read, in kW, as the live template (SAFETY-118), "
|
||||
"and the cheap run's charge does not depend on it",
|
||||
_c_fc.peak_fc is not None and _c_fc.peak_fc > 1000.0
|
||||
and [r[1] for r in GAP["month peak banked, peak_forecast_entity (kW)"]
|
||||
[1]] == [r[1] for r in GAP[
|
||||
"month peak 5413 W banked, P1 projection"][1]])
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the same for a price trade (modes off) and a maintenance charge")
|
||||
_ca, _ra, _fa = drive(opts(modes=False, arb=True), peak=PEAK, late=30.0)[:3]
|
||||
check("modes off, price trading on, a reading 30 s late: every quarter at "
|
||||
"or under the aim", graded("trade", _fa, AIM_PEAK)
|
||||
and any(r[3] == "ARBITRAGE_CHARGE" for r in _ra))
|
||||
|
||||
|
||||
def late_feed(gap, every=0.0, look=None):
|
||||
"""safety84's cheap-window feed, no telegram in each quarter's last
|
||||
`gap` s, and one only every `every` s (0: every cycle). `look(c, t)`
|
||||
runs first, on the state the previous cycle left."""
|
||||
feed = s84.feed(True)
|
||||
|
||||
def before(c, t):
|
||||
if look is not None:
|
||||
look(c, t)
|
||||
if ((rig.BASE.timestamp() + t) % QUARTER_S < QUARTER_S - gap
|
||||
and (not every or t % every < 1e-9)) \
|
||||
or not getattr(c.hass, "_s84", False):
|
||||
feed(c, t)
|
||||
return before
|
||||
|
||||
|
||||
def rig_quarters(rows, load):
|
||||
"""The safety84 rig's whole quarters: the true average import (W)."""
|
||||
qs = {}
|
||||
for r in rows:
|
||||
q = int((rig.BASE.timestamp() + r[0]) // QUARTER_S)
|
||||
a = qs.setdefault(q, [0.0, 0])
|
||||
a[0] += max(0.0, load(r[0]) - sum(v for _u, v in r[2]))
|
||||
a[1] += 1
|
||||
return {datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"):
|
||||
s / k for q, (s, k) in sorted(qs.items())
|
||||
if k * rig.TICK >= QUARTER_S}
|
||||
|
||||
|
||||
for peak, house, aim in ((5413.0, 4000.0, 5363.0), (None, 2500.0, AIM_CAP)):
|
||||
_mr, _mc = s84.run(2, load=lambda t, h=house: h, dur=2700.0,
|
||||
month_peak=peak, before=late_feed(30.0))
|
||||
_mf = rig_quarters(_mr, lambda t, h=house: h)
|
||||
check(f"N=2 maintenance charge, house {house:.0f} W, "
|
||||
f"{'peak banked' if peak else 'no peak banked'}, a reading 30 s "
|
||||
f"late: every quarter at or under the aim",
|
||||
graded("maintenance", _mf, aim)
|
||||
and any(mm.phase == "charge" for mm in _mc.maints))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. a healthy meter: the quarters, and the words")
|
||||
for name, kw, aim, kw_text in (("month peak banked", dict(peak=PEAK),
|
||||
AIM_PEAK, "5.4 kW"),
|
||||
("no peak banked", dict(), AIM_CAP, "3.5 kW")):
|
||||
_c, _rows, _full, _s = drive(opts(), every=10, **kw)
|
||||
check(f"{name}: every quarter at or under the aim",
|
||||
graded(name, _full, aim))
|
||||
over = [s for s in _s if not s[2] and s[0] > s[1]]
|
||||
under = [s for s in _s if not s[2] and s[0] <= s[1]]
|
||||
_o, _u = first(iter(over), "an over sample"), \
|
||||
first(iter(under), "an under sample")
|
||||
print(f" over the limit at this rate ({len(over)} samples): "
|
||||
f"{_o[3] if present(_o) else _o!s} / "
|
||||
f"{_o[4] if present(_o) else _o!s}")
|
||||
print(f" under it ({len(under)}): "
|
||||
f"{_u[3] if present(_u) else _u!s} / "
|
||||
f"{_u[4] if present(_u) else _u!s}")
|
||||
check(f"{name}: over the limit at this rate the guard row never says "
|
||||
f"'under'; it says the grid charge is limited",
|
||||
present(_o) and all(
|
||||
"under" not in s[3] and "Peak guard: grid charge limited, "
|
||||
"at this rate" in s[3] and " over " in s[3] for s in over))
|
||||
check(f"{name}: ...and the month-peak line says the rate, and that the "
|
||||
f"grid charge is cut back to stay under {kw_text}",
|
||||
present(_o) and all(
|
||||
s[4].startswith("At this rate this 15-minute period reaches")
|
||||
and s[4].endswith("The grid charge is cut back to stay "
|
||||
f"under {kw_text}.") for s in over))
|
||||
check(f"{name}: under the limit both read as before",
|
||||
present(_u) and all(
|
||||
s[3].startswith("Peak guard: idle, heading for")
|
||||
and " under " in s[3]
|
||||
and s[4].startswith("This 15-minute period is heading for")
|
||||
and s[4].endswith(f"The batteries step in above {kw_text}.")
|
||||
for s in under))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the words on their own")
|
||||
_en = messages.row_peak_guard_idle(5814.0, 5413.0, month_peak=True)
|
||||
_cut = messages.row_peak_guard_idle(5814.0, 5413.0, month_peak=True,
|
||||
cut=True)
|
||||
_ok = messages.row_peak_guard_idle(5200.0, 5413.0, month_peak=True)
|
||||
print(f" {_en}\n {_cut}\n {_ok}")
|
||||
check("over, nothing cut: 'idle, at this rate ..., over ...'",
|
||||
_en == "Peak guard: idle, at this rate 5 814 W, over 5 413 W")
|
||||
check("over, a grid charge cut",
|
||||
_cut == "Peak guard: grid charge limited, at this rate 5 814 W, "
|
||||
"over 5 413 W")
|
||||
check("under: the 0.19.1 sentence, unchanged",
|
||||
_ok == "Peak guard: idle, heading for 5 200 W, under this month's "
|
||||
"5 413 W peak")
|
||||
check("the Dutch forms say the same",
|
||||
_cut.nl == "Piekbewaking: laden beperkt, aan dit tempo 5 814 W, "
|
||||
"boven 5 413 W"
|
||||
and _en.nl.startswith("Piekbewaking: in rust, aan dit tempo"))
|
||||
check("the month-peak line in shadow mode says 'would'",
|
||||
homeview.line_period(5814.0, 5413.0, True, cut=True).endswith(
|
||||
"The grid charge would be cut back to stay under 5.4 kW.")
|
||||
and homeview.line_period(5814.0, 5413.0, False).endswith(
|
||||
"The batteries step in above 5.4 kW."))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. one inverter: the maintenance charge is sized to the room too")
|
||||
# The start-up quarter on a 10 s meter: the charge's first step is not seen
|
||||
# until the second reading, so it may land up to START_TOL over the aim; the
|
||||
# same at N=2 and before this fix. Every later quarter is graded at TOL.
|
||||
START_TOL = 15.0
|
||||
N1 = {}
|
||||
for peak, house, aim in ((5413.0, 4000.0, 5363.0), (None, 2500.0, AIM_CAP)):
|
||||
for late, every in ((0.0, 0.0), (30.0, 0.0), (20.0, 10.0)):
|
||||
_r, _c = s84.run(1, load=lambda t, h=house: h, dur=2700.0,
|
||||
month_peak=peak, before=late_feed(late, every))
|
||||
_q = rig_quarters(_r, lambda t, h=house: h)
|
||||
N1[peak, late, every] = (_r, _c)
|
||||
_vals = list(_q.values())
|
||||
_tag = (f"N=1, {'peak banked' if peak else 'no peak banked'}, "
|
||||
f"{'10 s' if every else '5 s'} meter, "
|
||||
f"{'late ' + format(late, '.0f') + ' s' if late else 'healthy'}")
|
||||
print(f" {_tag}: quarters "
|
||||
f"{ {k: round(v, 1) for k, v in _q.items()} } W, aim {aim:.0f}")
|
||||
check(f"{_tag}: every quarter at or under the aim",
|
||||
len(_vals) == 3
|
||||
and all(v <= aim + TOL for v in _vals[1:])
|
||||
and nth(_vals, 0, "a first quarter")
|
||||
<= aim + (START_TOL if every else TOL)
|
||||
and any(mm.phase == "charge" for mm in _c.maints))
|
||||
_hr, _hc = N1[5413.0, 0.0, 0.0]
|
||||
_eng = sum(1 for r in _hr if r[-1][1] and r[-1][1][0])
|
||||
print(f" healthy, peak banked: guard engaged on {_eng} of {len(_hr)} "
|
||||
f"cycles; A charges on "
|
||||
f"{sum(1 for r in _hr if dict(r[2])['a'] < -1.0)} cycles")
|
||||
check("N=1: the guard no longer steps in during the charge (no hand-back), "
|
||||
"and the battery charges on every cycle, at no more than its rate",
|
||||
_eng == 0 and all(-2000.0 <= dict(r[2])["a"] < -1.0 for r in _hr)
|
||||
and not any("suspended" in e for e in _hc.events))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. the row during an N=2 maintenance charge and in shadow mode")
|
||||
_rows7 = []
|
||||
|
||||
|
||||
def _look(c, t):
|
||||
pg = c.peak_guard
|
||||
if t >= QUARTER_S and pg is not None and pg.projected_w is not None \
|
||||
and not pg.engaged and pg.projected_w > pg.ceiling_w:
|
||||
_rows7.append(first((r["text"] for r in c.checks()
|
||||
if r["id"] == "peak_guard"), "guard row"))
|
||||
|
||||
|
||||
def _steps(t):
|
||||
"""4000 W, 800 W more for the first 30 s of every minute."""
|
||||
return 4000.0 + (800.0 if t % 60.0 < 30.0 else 0.0)
|
||||
|
||||
|
||||
_m2r, _m2c = s84.run(2, load=_steps, dur=1800.0, month_peak=5413.0,
|
||||
before=late_feed(0.0, look=_look))
|
||||
_m2q = rig_quarters(_m2r, _steps)
|
||||
print(f" N=2 maintenance charge, house steps 4000/4800 W: "
|
||||
f"{len(_rows7)} over-the-limit rows, e.g. "
|
||||
f"{first(iter(_rows7), 'a row')}; quarters "
|
||||
f"{ {k: round(v, 1) for k, v in _m2q.items()} } W")
|
||||
check("N=2 maintenance charge, over the limit at this rate: the row says the "
|
||||
"grid charge is limited",
|
||||
present(first(iter(_rows7), "a row")) and all(
|
||||
r.startswith("Peak guard: grid charge limited, at this rate")
|
||||
for r in _rows7)
|
||||
and all(v <= 5363.0 + TOL for v in _m2q.values()))
|
||||
_sh_o = dict(opts(), shadow_mode="on")
|
||||
_csh, _rsh, _fsh, _ssh = drive(_sh_o, peak=PEAK, minutes=17, every=10,
|
||||
plant=OnItsOwn)
|
||||
_sh_over = [s for s in _ssh if not s[2] and s[0] > s[1]]
|
||||
_sh1 = first(iter(_sh_over), "an over sample")
|
||||
print(f" shadow mode, the inverter charging 5000 W on its own: "
|
||||
f"{len(_sh_over)} over samples, e.g. "
|
||||
f"{_sh1[3] if present(_sh1) else _sh1!s} / "
|
||||
f"{_sh1[4] if present(_sh1) else _sh1!s}")
|
||||
check("shadow mode: nothing is cut on the wire, so the row says 'idle', "
|
||||
"never 'grid charge limited', and the line says 'would'",
|
||||
_csh.shadow and present(_sh1) and all(
|
||||
s[3].startswith("Peak guard: idle, at this rate")
|
||||
and "would" in s[4] for s in _sh_over))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n8. edge cases")
|
||||
_e45 = drive(opts(), peak=PEAK, late=45.0)
|
||||
check("a reading 45 s late, past the 30 s hold (the guard goes blind): every "
|
||||
"quarter at or under the aim", graded("late 45 s", _e45[2], AIM_PEAK))
|
||||
_e07 = drive(opts(), peak=PEAK, late=30.0, minutes=24,
|
||||
start=START + timedelta(minutes=7))
|
||||
check("a start at 00:07, mid-quarter, then 30 s late: the next quarter at or "
|
||||
"under the aim",
|
||||
len(_e07[2]) == 1 and all(v <= AIM_PEAK + TOL
|
||||
for v in _e07[2].values()))
|
||||
print(f" start at 00:07: {_e07[2]}")
|
||||
_dst = drive(opts(), peak=PEAK, late=30.0,
|
||||
start=datetime(2026, 10, 25, 0, 45, tzinfo=timezone.utc))
|
||||
check("across the end of summer time (2026-10-25 01:00 UTC), 30 s late: "
|
||||
"every quarter at or under the aim", graded("DST", _dst[2], AIM_PEAK))
|
||||
_ew = drive(opts(fc=True), peak=PEAK, late=30.0, fc_unit="W")
|
||||
check("the forecast entity in W: read as W, and the charge as with kW",
|
||||
_ew[0].peak_fc is not None and _ew[0].peak_fc > 1000.0
|
||||
and [r[1] for r in _ew[1]] == [r[1] for r in GAP[
|
||||
"month peak banked, peak_forecast_entity (kW)"][1]])
|
||||
_e10 = drive(opts(), peak=PEAK, period=10.0)
|
||||
_e10v = list(_e10[2].values())
|
||||
print(f" 10 s meter: {_e10[2]}")
|
||||
check("a 10 s meter: every quarter after the start-up one at or under the "
|
||||
"aim, and that one within START_TOL",
|
||||
len(_e10v) == 2 and all(v <= AIM_PEAK + TOL for v in _e10v[1:])
|
||||
and nth(_e10v, 0, "a first quarter") <= AIM_PEAK + START_TOL)
|
||||
_p_end = Projection(5800.0, 5800.0, 899.5, 898.5 / 899.5, False, START,
|
||||
5800.0)
|
||||
check("0.5 s left and 1 s late: finite, the formula, at its floor of 0 W",
|
||||
peakguard.charge_room_w(_dec, _p_end) == max(
|
||||
0.0, _room - 5800.0 * 1.0 / 0.5))
|
||||
_d_end = peakguard.decide(
|
||||
Projection(5800.0, 5800.0, 900.0, 1.0, True, START, 5800.0),
|
||||
batt_w=-4800.0, cap_w=3500, margin_w=50,
|
||||
reading=types.SimpleNamespace(is_observed=True, peak_w=PEAK),
|
||||
achievable_w=9600.0, soc=50.0, soc_floor=20.0)
|
||||
check("no second left: no room is measured (None), and nothing divides by 0",
|
||||
peakguard.charge_room_w(_d_end, Projection(
|
||||
5800.0, 5800.0, 900.0, 890.0 / 900.0, False, START, 5800.0))
|
||||
is None)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -476,13 +476,18 @@ print("\n8. the N=1 and N=2 digests")
|
||||
# loop's bound just after the guard hands back are gone; every command and
|
||||
# claim is the same (test_safety119.py part 6). SAFETY-121 re-pinned all six
|
||||
# again: only OUTPUT_CLAMP rows (test_safety119.py part 6).
|
||||
# SAFETY-125 (0.19.2, owner 2026-10-29) re-pinned n1_charge and
|
||||
# n1_charge_banked: at N=1 the cheap-window charge is now sized to the
|
||||
# quarter's room, as at N=2, so the guard no longer steps in and discharges.
|
||||
# The banked quarters go 3461.9 -> 3450.0 W (aim 3450) and 3938.9 -> 3950.0
|
||||
# W (aim 3950). n1_charge_dear (no cheap window) is unchanged.
|
||||
PINNED = {
|
||||
"n1_charge":
|
||||
"0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5",
|
||||
"00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9",
|
||||
"n1_charge_dear":
|
||||
"c25340b56060d4581f7204e200b07e7c8618711be2baca3b1667b7ebeb600dc3",
|
||||
"n1_charge_banked":
|
||||
"2619acdd06b04e56c000b168d64e1d044346c6ba3f929c94258d6ad8189ec609",
|
||||
"09a08298d35195380ff427cdd2448d9212119be4692db26b2b1a45957fdf3300",
|
||||
"n2_charge":
|
||||
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c",
|
||||
"n2_charge_dear":
|
||||
@@ -496,8 +501,9 @@ for k in PINNED:
|
||||
f"{_now[k][:16]}")
|
||||
check("⭐ at N=1 a charge in and out of a cheap window and under a banked "
|
||||
"month peak is byte-identical to 9d5b7d1 (commands, reasons and every "
|
||||
"claim; the log as re-pinned by SAFETY-121), and N=2 outside a cheap "
|
||||
"window matches its SAFETY-85 pin",
|
||||
"claim; the log as re-pinned by SAFETY-121; in a cheap window as "
|
||||
"SAFETY-125 re-pinned it), and N=2 outside a cheap window matches its "
|
||||
"SAFETY-85 pin",
|
||||
all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
|
||||
"n1_charge_banked",
|
||||
"n2_charge_dear")))
|
||||
|
||||
@@ -522,7 +522,9 @@ print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7")
|
||||
# log. The loop's bound just after a hand-back is not counted, so OUTPUT_CLAMP
|
||||
# rows are gone or count fewer; every command and claim is the same
|
||||
# (test_safety119.py part 6). SAFETY-121 re-pinned the same three again:
|
||||
# only OUTPUT_CLAMP rows (test_safety119.py part 6).
|
||||
# only OUTPUT_CLAMP rows (test_safety119.py part 6). SAFETY-125 (0.19.2)
|
||||
# re-pinned n1_charge_low: the N=1 cheap-window charge is sized to the
|
||||
# quarter's room, so the banked quarter goes 3458.9 -> 3450.0 W (aim 3450).
|
||||
PINNED = { # tools/safety87_identity.py on b8ddfd7
|
||||
"n1_under":
|
||||
"d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",
|
||||
@@ -533,7 +535,7 @@ PINNED = { # tools/safety87_identity.py on b8ddfd7
|
||||
"n1_under_banked":
|
||||
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
|
||||
"n1_charge_low":
|
||||
"91336e3c0314985a3b00f314c79b5ffb5a740d218de3ad44f88a4db6e4235b32",
|
||||
"c3af6218cc14c072e08b09550b153502dafec806009a92ba9032738b9ac9fa61",
|
||||
"n1_hold":
|
||||
"0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d",
|
||||
}
|
||||
@@ -542,8 +544,8 @@ for k in PINNED:
|
||||
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
|
||||
check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
|
||||
"log) equals b8ddfd7's (the log as re-pinned by SAFETY-121), under, "
|
||||
"crossing and above the reserve, in a charge and a HOLD, and under a "
|
||||
"banked peak",
|
||||
"crossing and above the reserve, in a charge (as SAFETY-125 re-pinned "
|
||||
"it) and a HOLD, and under a banked peak",
|
||||
_now == PINNED)
|
||||
|
||||
# ============================================================================ #
|
||||
|
||||
@@ -636,12 +636,10 @@ _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, UX-13 one GET and "
|
||||
"UX-54 one more GET (the Forecast tab), and none touches the three "
|
||||
"that were there",
|
||||
check("the configuration surface adds five routes and UX-13 one GET, and "
|
||||
"none touches the three that were there",
|
||||
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
|
||||
("GET", "/forecast"), ("GET", "/ha/entities"),
|
||||
("GET", "/status"),
|
||||
("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"),
|
||||
|
||||
@@ -43,7 +43,7 @@ logging.disable(logging.WARNING)
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import arbitrage, homeview, modetrade, solarfc # noqa: E402
|
||||
from app import arbitrage, homeview, modetrade # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
||||
@@ -54,13 +54,6 @@ from app.store import unit_key # noqa: E402
|
||||
fails = []
|
||||
|
||||
|
||||
class _NoHold(solarfc.Hold):
|
||||
"""OPT-27: a hold that never stands in for a lost sensor."""
|
||||
|
||||
def stand_in(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
@@ -164,11 +157,6 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
# OPT-27: a sensor that is missing is covered for up to 3 h by its last
|
||||
# fresh reading. This file is about what the plan does when the
|
||||
# forecast IS lost, so it runs with a hold that never covers
|
||||
# (test_opt27.py tests the cover).
|
||||
c.fc_hold = _NoHold()
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
clock = [NIGHT]
|
||||
|
||||
|
||||
@@ -1,589 +0,0 @@
|
||||
"""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
@@ -1,296 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,281 +0,0 @@
|
||||
"""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
|
||||
if name not in ("nofc", "off", "stale") and \
|
||||
opts.get("battery_modes_enabled"):
|
||||
# TARIFF-24: with a forecast the gap needed is the cycle cost, not
|
||||
# 1.5 x it. These scenarios were written for the old gate (0.15 at
|
||||
# the shipped 0.10), so they keep it: the cost is 0.15.
|
||||
opts["cycle_cost_threshold_eur"] = 0.15
|
||||
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