Compare commits

..
14 Commits
Author SHA1 Message Date
glenn schrooyenandClaude 230ba0b57a TARIFF-24: test file follows the repo's suite rules (MUTATION.md pointer, named absence)
checks / unit (pull_request) Successful in 49m23s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-28 01:09:39 +01:00
glenn schrooyenandClaude d667f3f3a8 TARIFF-24: battery modes start the tariff task (hotfix 0.19.1)
With battery modes on, a day/night or fixed static_profile, no price feed and
arbitrage off, tariff_refresh_wanted() was false: no tariff task, no
tariff_plan, mode_runs None, so Leftover solar / Cheap hours never charged from
the grid and the Batteries screen said "No prices". packmode.enabled(opts) is
now a third term of the gate. With price_provider off the task is pure
arithmetic on static_profile, so no HTTPS call is added.

Also: version 0.19.1, CHANGELOG entry, DECISIONS note, test_tariff24.py, and a
shorter tariff_refresh_wanted() docstring (history stays in DECISIONS).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-28 01:02:32 +01:00
admin 793a67623c Merge pull request 'Deploy 0.19.0' (#208) from deploy-0.19.0 into master
Reviewed-on: #208
2026-10-26 08:47:42 +01:00
admin ad374ae208 Deploy 0.18.2 to master (#200) 2026-10-17 01:59:54 +02:00
admin ec2b0c3de4 Deploy 0.18.1 to master (#197) 2026-10-16 15:05:06 +02:00
admin 3b7d6b7f05 Deploy 0.18.0 to master (#193) 2026-10-16 00:42:54 +02:00
admin 1b6f16dba2 Deploy 0.17.0 to master (#186) 2026-10-11 01:37:01 +02:00
admin df154a26d1 Deploy 0.16.0 to master (#179) 2026-10-08 01:43:37 +02:00
admin 21599938ea Merge pull request 'Deploy 0.15.0 to master' (#174) from deploy-0.15.0 into master
Deploy 0.15.0 to master (#174)
2026-10-05 10:17:38 +02:00
admin 3a54b253cb Merge pull request 'Deploy 0.14.0 to master' (#170) from deploy-0.14.0 into master
Deploy 0.14.0 to master (#170)
2026-10-03 12:32:16 +02:00
admin 8b9680fc35 Merge pull request 'Deploy 0.13.0 to master' (#166) from deploy-0.13.0 into master
Reviewed-on: #166
2026-10-02 15:26:46 +02:00
admin 3ce18708b6 PKG-18: ship 0.12.0 to master, so TEL-12's pv_entities can reach an installed 0.11.0 (#118) 2026-09-24 23:22:34 +02:00
adminandClaude Opus 5 78acb558ff PKG-17: ship 0.11.0 to master, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1 (#115)
master is what the Home Assistant Supervisor clones, and the Supervisor
compares config.yaml version strings - so 0.11.0 could not be offered to an
installed 0.10.1 without this bump reaching master.

Carries SAFETY-56 (#112), TEL-11 (#113), the CI-9 follow-up (#102) and SEC-2's
docs/RELEASE.md (#108), by way of the 0.11.0 cut (#114).

NOT A FAST-FORWARD. master was not an ancestor of release/1.0: 0f14c2a, the
pre-SEC-2 direct push, exists only on master. Its tree is the same object as
the merge base b0b24e5 (c1fef26), so it carries no content delta, and
`git merge-tree --write-tree` over the two branches returns a tree bit-identical
to cccb2fa's own (6670502). This merge commit therefore contributes exactly
release/1.0's tree - master synthesises nothing.

Reviewed as the release decision, not a re-review of content. Verified from the
remote: slug goodwe_controller and every identity key unchanged (cross-checked
against the live box's Supervisor export), version 0.10.1 -> 0.11.0, and the
green is this PR's own - run 828 / job 1953, path checks.yml@refs/pull/115/head,
PRNUM 115, GRADED refs/pull/115/head merged into master, run_attempt 1, 31 test
files all passing in 8m01s of real work.

predeploy_check.py re-run against the owner's current options (exported
2026-09-24 20:02, 75 keys, pv_entity unset): CLEARED, exit 0. That retires the
stale-2026-09-07-export warning in the pull request body. Option surface
75 -> 76 with only pv_entity added, str? defaulting to "", no key removed, no
default moved, no rule added to validate.py - no SystemExit(78) on this hop.

Merging is not installing. No tag is cut here and `ha store reload` /
`ha addons update` have not been run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 20:28:35 +02:00
glenn schrooyenandClaude Opus 5 0f14c2a5de PKG-16: ship 0.10.1 to master, so the Supervisor can offer SAFETY-57's fix
master is what Home Assistant clones. SAFETY-57 fixed a deadlock that parked
the reference install's battery for ~13 minutes while the site exported
2.4 kW, and the Supervisor compares config.yaml version strings - so the fix
could not reach an installed 0.10.0 without this bump reaching master.

Brings SAFETY-57 (#104) and the 0.10.1 cut (#105). master was a strict
ancestor; no conflict was possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-22 15:54:29 +02:00
27 changed files with 504 additions and 4659 deletions
+26
View File
@@ -1,5 +1,31 @@
# Changelog
## 0.19.1
**A patch bump: one fix for battery modes on a day/night or fixed tariff, 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.0 also
start 0.19.1. The change below applies with one inverter or two.
**Battery modes now see a day/night or fixed tariff.** With battery modes on, a day/night or fixed tariff and no price service, the batteries never got the prices: "Cheap hours" never charged from the grid at the cheap rate, and the Batteries screen said "No prices". They get them now, with no price service and no internet call added.
Contents of this release, in merge order since 0.19.0 (`793a676`):
| | |
|---|---|
| `TARIFF-24` | With battery modes on, the add-on now also resolves the tariff when no price service and no price trading are set, so a day/night or fixed tariff gives the batteries their price runs ([[TARIFF-24]]). |
### Known issues
The list under `## 0.19.0` below still applies.
### 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.0
**A minor bump: price trading behaves differently, plus a fix for a false warning, the Home price chart in 15-minute bars, more Dutch text and EMS-port wording, and no option changes.**
@@ -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,35 @@
# TARIFF-24 - battery modes need the tariff task
**Status: FIXED in 0.19.1 (hotfix from 0.19.0, owner go 2026-10-28).**
Follows TARIFF-10 (fetching is not trading). Tests: `test_tariff24.py`.
**The defect.** The control cycle gets prices only from
`Controller.tariff_plan`, which only `run_tariff_refresh()` stores. `amain()`
starts that task when `tariff_refresh_wanted(opts)`, which was
`provider_configured(opts) or arbitrage.enabled(opts)`. Battery modes read the
plan but were not a term. With battery modes on, a day/night or fixed
`static_profile`, `price_provider: off` and `arbitrage_enabled: false` (the
site trade switch is not read with modes on) no task ran. Then
`fresh_tariff_plan()` was None, `_price_runs()` was None and `mode_runs` was
None, so Leftover solar and Cheap hours never charged from the grid. The
Batteries screen said "No prices, so it follows the house" while Home priced
the day from `static_plan()` itself (`homeview.build_plan()`).
**The fix** (`main.py`, `tariff_refresh_wanted()`). `packmode.enabled(opts)`
is a third term. The gate stays a widening: nothing that started the task
before stops. No HTTPS call is added: with `price_provider: off`,
`resolve_tariff()` is the pure `static_plan()`.
**Not changed.** Modes off, arbitrage off, no feed still starts no task. The
control law, the price runs and every message are untouched.
**One rule, not two.** Gating on "modes on and a static profile" was
considered. It saves one arithmetic pass every 30 min on a site with modes on
and no prices at all, and it adds a second condition that has to track what
`resolve_tariff()` accepts. With no profile and no feed the plan is `no_data`,
exactly what the cycle sees without the task.
**UX-55.** Home said "no prices have arrived" (`mode_runs` None) with every
battery on Solar first. The same cause, and this fix clears it for a site with
a static profile (`test_tariff24.py` part 5). A site with no profile and no
feed still has no prices, and Home still says so.
@@ -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.
-15
View File
@@ -3530,21 +3530,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
-293
View File
@@ -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)
+4 -6
View File
@@ -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
-448
View File
@@ -1,448 +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))
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)
+34 -122
View File
@@ -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):
@@ -308,65 +306,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
@@ -381,8 +346,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
@@ -402,22 +366,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:
@@ -425,7 +381,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)
@@ -433,26 +388,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:
@@ -464,7 +410,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:
@@ -491,18 +437,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)
@@ -645,20 +589,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)
@@ -678,7 +608,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:
@@ -1048,16 +978,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:
@@ -1386,11 +1306,6 @@ def run_house_amount() -> str:
return _t("run.house_amount")
def day_note(key, **values) -> str:
"""UX-54: one sentence of the Forecast tab (`home_words.day`)."""
return _t("day." + key, **values)
def line_solar(fc_kwh, count_kwh, at) -> str:
return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at)
@@ -1564,9 +1479,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")]
+13 -84
View File
@@ -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,
@@ -175,57 +174,17 @@ TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
def tariff_refresh_wanted(opts: dict) -> bool:
"""TARIFF-10. Does this install need the background tariff refresh at all?
"""Does this install need the background tariff refresh? (TARIFF-10, TARIFF-24)
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
only caller of `price_history.carry()` - so on a shipped install with a
provider configured NOTHING was ever fetched, no plan was ever resolved,
`/data/price_history.json` was never created, the *Price history* row read
`empty` forever, and TARIFF-09's short-horizon warning could not fire
because there was no fetch to warn about. Measured over a 40 h soak of two
instances side by side: zero feed requests and `present: false` across all
802 samples on the shipped default, against a resolved plan from the same
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
it, and the sentence saying a configured provider still fetches, all in one
change. config.yaml has said it since TARIFF-02 (852fc7c).
Two terms, and they answer two different questions:
provider_configured(opts) -> "the operator asked for a price feed", so
fetch it, validate it and record the day
(§8.3). This is the term TARIFF-10 ADDS.
arbitrage.enabled(opts) -> "the operator armed trading". This term is
what the gate USED to be, kept verbatim.
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
that started this task before still starts it; none that did not start it
stops anything. That matters because the second term is not redundant:
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
configuration validate.py explicitly ACCEPTS, and it trades - measured, at
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile,
`ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
that site forever and `decide()` would answer "no tariff plan has been
resolved yet" on every cycle - silently disarming a strategy the UI says is
running, which is the exact failure validate.py's OPT-04 rules exist to
refuse.
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
arithmetic, no socket - so the second term starts a task that fetches
nothing. The true shipped default (no provider, no arbitrage) starts no
task at all. What is fetched is decided by the FIRST term only.
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
Whether they move energy is `arbitrage.enabled()` at the one call site in
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
means no trade, ever, on any configuration.
Yes with a price feed, with trading on price armed, or with battery modes
on: the modes read `self.tariff_plan` for their price runs. With
`price_provider` off the refresh is pure arithmetic on `static_profile`, so
no term here adds an HTTPS call. It decides whether prices are looked up,
never whether energy moves. See DECISIONS/TARIFF-10-fetching-is-not-trading.md
and DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
"""
return provider_configured(opts) or arbitrage.enabled(opts)
return (provider_configured(opts) or arbitrage.enabled(opts)
or packmode.enabled(opts))
def load_options() -> dict:
@@ -1002,10 +961,6 @@ class Controller:
self.mode_runs = None
self._runs_key = None
self.mode_tick = None
# UX-53: why the trade gate refused a grid charge this tick (`peak`,
# `not_worth`, `not_covered`), or None when it allowed one. Home reads
# it; nothing in control does.
self.mode_gate = None
self._run_done = {}
self._mode_trade_was = False
self._charge_blocked_q = None
@@ -1262,9 +1217,6 @@ class Controller:
# UX-13 P1: today's prices as the Home chart has seen them. Display
# only; nothing reads it back into a decision.
self.home_prices = homeview.DayPrices()
# UX-54: the site's measured solar per hour, for the Forecast tab.
# In memory, display only; nothing reads it back into a decision.
self.solar_today = SolarToday()
# TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at`
# is the exact, derived statement of "the latest cycle did not get as
# far as the arbitrage step", which is the fact the *Arbitrage* row
@@ -2388,7 +2340,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:
@@ -3385,11 +3336,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
@@ -7644,21 +7590,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"):
@@ -8080,11 +8011,9 @@ async def amain() -> None:
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
#
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
# enabled` ships `false`, so a site that had selected a price feed
# fetched nothing at all; the whole argument, the measurement and what
# the `or` is protecting are in `tariff_refresh_wanted()` above.
# ⚠️ STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES:
# see `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and
# TARIFF-24 for why each term is there.
#
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
# two decisions; this is the first, and `cycle()`'s
-26
View File
@@ -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}
+2 -173
View File
@@ -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; }
}
-27
View File
@@ -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">&#8250;</span></summary>
<div class="body"><table class="fct" id="fc-table"></table></div>
</details>
</section>
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
<div class="row spread" style="margin-bottom:6px">
<h2 style="margin:0" data-s="name"></h2>
+2 -6
View File
@@ -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
+3 -37
View File
@@ -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"},
@@ -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.",
@@ -443,22 +425,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."
+3 -37
View File
@@ -42,7 +42,7 @@
"applies": {"restart": {"name": "Geldt na een herstart"}, "now": {"name": "Geldt meteen"}},
"home": {
"app": {"name": "Batterij-add-on"},
"tabs": {"home": "Start", "batteries": "Batterijen", "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"},
@@ -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.",
@@ -371,22 +353,6 @@
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
"day": {
"modes_off": "Batterijmodi staan uit. Er wordt geen prognose gelezen en geen niveau gepland.",
"not_set": "Er is geen zonneprognose ingesteld. Zonder prognose wordt geen laadniveau gepland.",
"no_forecast": "De zonneprognose ontbreekt of is te oud. De niveaus komen terug als ze er weer is.",
"learning": "De add-on leert het verbruik van uw huis. De niveaus starten na één volle dag.",
"no_tomorrow": "Er is nog geen zonneprognose voor morgen. De niveaus stoppen om {at}.",
"estimate": "De prognose geeft één totaal voor de dag. De add-on verdeelt het over de uren.",
"trust": "De niveaus rekenen op {pct} % van de prognose.",
"cycle": "{name} doet de maandelijkse cyclus. Het niveau wordt niet getoond zodra de cyclus start.",
"no_level": "{name} geeft nog geen laadniveau door. Het niveau wordt niet getoond.",
"measured_from": "Gemeten zon telt vanaf {at}, toen de add-on startte.",
"tomorrow_total": "Verwachte zon morgen: {kwh}, we rekenen op {count}.",
"no_blocks": "Geen laad- of leverblokken gepland.",
"give_later": "De add-on plant stroom te leveren. Er verschijnt geen blok zolang een batterij niets kan leveren.",
"ahead": "De add-on plant ongeveer een dag vooruit. Blokken voor latere uren verschijnen als ze naderen."
},
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
-7
View File
@@ -150,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 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.19.0"
version: "0.19.1"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
+4 -6
View File
@@ -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 "
-523
View File
@@ -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")
+3 -5
View File
@@ -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"),
+374
View File
@@ -0,0 +1,374 @@
"""Runnable check for TARIFF-24. `python3 test_tariff24.py`
Battery modes on, a day/night `static_profile`, no price feed and the site trade
switch off used to start no tariff task: `Controller.tariff_plan` stayed None,
`mode_runs` stayed None, Leftover solar and Cheap hours never charged from the
grid, and the Batteries screen said "No prices" while Home priced the day from
the profile itself. DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
1. The startup gate: `tariff_refresh_wanted()` by configuration, and the guard
`amain()` really puts on the task (read from its source, not retyped).
2. End to end, on the real Controller and the real `run_tariff_refresh()` task,
started only if `amain()`'s own guard says so: night period, one battery
Leftover solar and one Cheap hours.
3. The same with a gap below the cycle cost.
4. Home and the Batteries screen agree.
5. UX-55: with every battery Solar first Home no longer says "no prices have
arrived" on a static profile, and still does with no profile and no feed.
6. Nothing else moved: modes off starts no task; the gate rows in part 1 keep
the arbitrage and feed cases as before.
Mutations (MUTATION.md, purged copies, graded over this file): the gate
reverted to `provider or arbitrage` fails 18 checks in parts 1 to 5; `True`
fails the modes-off rows; `bool(opts.get(...))` fails the exact-true row;
dropping the arbitrage term fails the arbitrage rows.
"""
import ast
import asyncio
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, packmode # noqa: E402
from app import price as _price, static_tariff as _st # noqa: E402
from app.main import Controller, tariff_refresh_wanted # 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)
A, B = "inverter-a", "inverter-b"
# A Belgian-style day/night tariff, example values: dear 07:00-22:00, cheap
# 22:00-07:00, every day. The gap is 0.08 EUR/kWh.
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.22},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.30}]}')
# A Friday in winter time (+01:00), 23:00 local: inside the night period.
L = timezone(timedelta(hours=1))
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
def opts(*, modes=True, arb=False, profile=PROFILE, provider=None,
cost=0.05):
o = dict(rp.options(modes, arb, 2))
o["cycle_cost_threshold_eur"] = cost
if profile is not None:
o["static_profile"] = profile
if provider is not None:
o["price_provider"] = provider
return o
# --------------------------------------------------------------------------- #
# The guard amain() puts on the tariff task, read from amain()'s source.
# --------------------------------------------------------------------------- #
def _guards_of(node, guards=()):
"""[(guards, coroutine name)] for every create_task(...) under `node`."""
out = []
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.If):
test = ast.unparse(child.test)
for stmt in child.body:
out += _guards_of(stmt, guards + (test,))
for stmt in child.orelse:
out += _guards_of(stmt, guards + (f"not ({test})",))
continue
if isinstance(child, ast.Call) and \
ast.unparse(child.func).endswith("create_task") and child.args:
arg = child.args[0]
name = (arg.func.attr if isinstance(arg, ast.Call)
and isinstance(arg.func, ast.Attribute)
else ast.unparse(arg))
out.append((guards, name))
continue
out += _guards_of(child, guards)
return out
_TREE = ast.parse(open(os.path.join(HERE, "app", "main.py"),
encoding="utf-8").read())
_AMAIN = first((n for n in ast.walk(_TREE)
if isinstance(n, ast.AsyncFunctionDef) and n.name == "amain"),
"amain() in main.py")
_TASK_GUARDS = [g for g, n in _guards_of(_AMAIN) if n == "run_tariff_refresh"]
def task_started(o) -> bool:
"""Would amain() start the tariff refresh task for options `o`?"""
if len(_TASK_GUARDS) != 1:
return False
return all(bool(eval(g, vars(m), {"opts": o})) for g in _TASK_GUARDS[0])
# --------------------------------------------------------------------------- #
print("1. the startup gate")
check("amain() starts the tariff refresh from exactly one guard, and that "
"guard can be decided from the options alone",
len(_TASK_GUARDS) == 1 and len(_TASK_GUARDS[0]) >= 1
and all(isinstance(eval(g, vars(m), {"opts": opts()}), bool)
for g in _TASK_GUARDS[0]))
GATE = [
# label, options, task wanted
("modes on + static day/night only", opts(), True),
("modes on + a fixed price only",
opts(profile='{"periods": [{"name": "all", "days": "all", "from": '
'"00:00", "to": "24:00", "eur_per_kwh": 0.27}]}'), True),
("modes on, nothing else", opts(profile=None), True),
("modes off + arbitrage off + no feed (static profile)",
opts(modes=False), False),
("modes off + arbitrage off + no feed, no profile",
opts(modes=False, profile=None), False),
("modes off + arbitrage on", opts(modes=False, arb=True), True),
("modes off + a feed", opts(modes=False, provider="generic_dayahead"),
True),
("modes on + a feed", opts(provider="generic_dayahead"), True),
("modes on + arbitrage on", opts(arb=True), True),
]
for label, o, want in GATE:
got = tariff_refresh_wanted(o)
print(f" wanted={got!s:5} started={task_started(o)!s:5} {label}")
check(f"{label}: tariff_refresh_wanted() is {want}", got is want)
check(f"{label}: amain() starts the task exactly as that says",
task_started(o) is want)
check("battery_modes_enabled has to be exactly true (packmode.enabled()): "
"'true' as text or 1 does not start the task",
not tariff_refresh_wanted(dict(opts(), battery_modes_enabled="true"))
and not tariff_refresh_wanted(dict(opts(), battery_modes_enabled=1)))
# --------------------------------------------------------------------------- #
# The real Controller over a few minutes of a night: the task starts only if
# amain()'s own guard says so; then read_inputs() + cycle() as the control
# loop runs them.
# --------------------------------------------------------------------------- #
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
FETCHES = []
async def _no_fetch(*a, **k):
FETCHES.append(a)
raise AssertionError("the tariff task reached for a price feed")
def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
house_w=300.0, tick=5.0):
"""-> (controller, rows, task_was_started)."""
store = rp._Store()
for u, (use, mode, sell) in choice.items():
store.data[unit_key(f"inverter-{u}", "pack_use")] = use
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
store.data[unit_key(f"inverter-{u}", "pack_sell")] = sell
rows = []
async def go():
hass = rp.Hass(("a", "b"), lambda t: house_w,
soc0 or {"a": 40.0, "b": 40.0})
tmp = tempfile.mkdtemp(prefix="t24-")
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
real_dt, real_hv = m.datetime, homeview.datetime
clock = [start]
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
_real_load = _price.load_prices, _st.load_prices
_price.load_prices = _st.load_prices = _no_fetch
started = task_started(o)
task = (asyncio.create_task(c.run_tariff_refresh())
if started else None)
try:
t = 0.0
while t < minutes * 60:
now = start + timedelta(seconds=t)
hass.t = t
clock[0] = now
if t == 0.0 and started:
for _ in range(500): # let the task do its first pass
if c.tariff_plan is not None:
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(tick, 10.0)
rows.append(dict(
now=now, runs=c.mode_runs, tick=c.mode_tick,
plan=c.fresh_tariff_plan(now),
action=getattr(c.arb, "action", None),
p={u: p.p for u, p in hass.plants.items()},
soc={u: p.soc for u, p in hass.plants.items()}))
t += tick
# What the two screens say at the end, while the clock is still
# the simulated one.
c.home_plan = homeview.build_plan(c, clock[0], False, True)
c.battery_cards = homeview.battery_rows(c, False, True)
finally:
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
_price.load_prices, _st.load_prices = _real_load
return c, started
c, started = asyncio.run(go())
return c, rows, started
def charging_b(rows):
"""Cycles in which the Cheap hours battery was charging (grid: no sun and
a 300 W house in this scenario)."""
return [r for r in rows if r["p"]["b"] < -200.0]
def why_of(c, name):
card = first((b for b in c.battery_cards if b["name"] == name),
f"a card for {name}")
return card["why_key"]
# --------------------------------------------------------------------------- #
print("\n2. end to end, night, modes on, static day/night, no feed, no arbitrage")
c1, r1, started1 = drive(opts())
_last = nth(r1, -1, "the last cycle")
_first1 = nth(r1, 0, "the first cycle")
print(f" tariff task started by amain's guard: {started1}")
print(f" tariff_plan: {None if c1.tariff_plan is None else c1.tariff_plan.words}")
print(f" mode_runs: {_last['runs']}")
print(f" mode_tick reason / roles: "
f"{None if _last['tick'] is None else (_last['tick'].why, dict(_last['tick'].roles))}")
print(f" Batteries screen why_key: A {why_of(c1, A)}, B {why_of(c1, B)}")
print(f" cycles with B charging: {len(charging_b(r1))} of {len(r1)}, "
f"B soc {_first1['soc']['b']:.1f} -> {_last['soc']['b']:.1f}")
check("the tariff refresh task is started", started1)
check("...and it adds no network call: with price_provider off the feed "
"loader is never reached (it is replaced by one that fails)",
not FETCHES and not _price.provider_configured(opts()))
check("a fresh tariff plan with prices is held", present(_last)
and _last["plan"] is not None and _last["plan"].has_prices)
check("the price runs exist (mode_runs is not None) and carry a cheap run "
"that holds in the night period",
present(_last) and _last["runs"] is not None and _last["runs"].on
and _last["runs"].cheap is not None
and _last["runs"].cheap.holds(_last["now"]))
check("the Cheap hours battery charges from the grid in the night period",
len(charging_b(r1)) > len(r1) // 2
and present(_last, _first1)
and _last["soc"]["b"] > _first1["soc"]["b"] + 0.3)
check("...and the Leftover solar battery does not (no sun, no forecast)",
not any(r["p"]["a"] < -200.0 for r in r1))
check("...the reason is not 'no prices': neither battery reads no_prices",
why_of(c1, A) != "no_prices" and why_of(c1, B) != "no_prices")
check("...only the Cheap hours battery got the grid charge role",
present(_last) and _last["tick"] is not None
and _last["tick"].roles.get(B) == modetrade.CHARGE
and _last["tick"].roles.get(A) != modetrade.CHARGE)
# --------------------------------------------------------------------------- #
print("\n3. the same with a gap below the cycle cost")
c3, r3, started3 = drive(opts(cost=0.20))
_l3 = nth(r3, -1, "the last cycle")
print(f" started {started3}; mode_runs {_l3['runs']}; "
f"why A {why_of(c3, A)}, B {why_of(c3, B)}")
check("the task is started and the plan is there, so mode_runs is not None",
started3 and present(_l3) and _l3["plan"] is not None
and _l3["runs"] is not None)
check("the reason is the gap: no_spread ('Prices differ too little'), "
"never no_prices, on both batteries",
why_of(c3, A) == "no_spread" and why_of(c3, B) == "no_spread"
and present(_l3) and _l3["runs"] is not None
and not _l3["runs"].on)
check("...and no battery charges from the grid",
not charging_b(r3) and not any(r["p"]["a"] < -200.0 for r in r3))
# --------------------------------------------------------------------------- #
print("\n4. Home and the Batteries screen agree")
hp = c1.home_plan
_bars = [b for b in hp["prices"] if b["eur_kwh"] is not None]
_words = {b["why_key"]: b["why"] for b in c1.battery_cards}
print(f" Home: has_prices {hp['has_prices']}, {len(_bars)} priced bars, "
f"verdict {hp['verdict_key']}, lines {hp['lines']}")
print(f" Batteries: {[(b['name'], b['why_key'], b['why']) for b in c1.battery_cards]}")
check("Home prices the day, and the Batteries screen does not say it has no "
"prices", hp["has_prices"] and _bars
and all(b["why_key"] != "no_prices" for b in c1.battery_cards))
check("Home's plan has the grid charge block for the Cheap hours battery "
"(the 'trade' verdict) and no 'no prices have arrived' line",
hp["verdict_key"] == "trade"
and any(r["kind"] == "grid" for r in hp["runs"])
and not any("no prices have arrived" in ln for ln in hp["lines"]))
hp3 = c3.home_plan
check("with the gap too small both say so: Home 'price gap is too small', "
"the batteries 'differ too little'; neither says no prices",
any("price gap is too small" in ln for ln in hp3["lines"])
and all(b["why_key"] == "no_spread" for b in c3.battery_cards))
# --------------------------------------------------------------------------- #
print("\n5. UX-55: every battery Solar first, static day/night, no feed")
SF = {"a": (True, "solar_first", False), "b": (True, "solar_first", False)}
c5, r5, s5 = drive(opts(), choice=SF, minutes=5)
print(f" Home lines: {c5.home_plan['lines']}")
check("Home no longer says 'no prices have arrived' with every battery Solar "
"first on a static profile",
s5 and not any("no prices have arrived" in ln
for ln in c5.home_plan["lines"]))
c5b, r5b, s5b = drive(opts(profile=None), choice=SF, minutes=5)
print(f" no profile, no feed: started {s5b}, Home lines "
f"{c5b.home_plan['lines']}")
check("...and with no profile and no feed there are truly no prices, and "
"Home still says so",
any("no prices have arrived" in ln for ln in c5b.home_plan["lines"]))
# --------------------------------------------------------------------------- #
print("\n6. nothing else moved")
c6, r6, s6 = drive(opts(modes=False), choice={}, minutes=5)
check("modes off, arbitrage off, no feed: no task, no plan, no price runs "
"(as before)", not s6 and c6.tariff_plan is None
and all(r["runs"] is None for r in r6))
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
-589
View File
@@ -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
-296
View File
@@ -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")
-275
View File
@@ -1,275 +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
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