Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63dda4b0d3 | ||
|
|
6e2fde63ff | ||
|
|
72ccfd788c | ||
|
|
c432d4e419 | ||
|
|
50332d4190 | ||
|
|
eda6d0aed1 | ||
|
|
a3f725e8a4 | ||
|
|
c08eb124c8 | ||
|
|
194efac98d | ||
|
|
96ca66fc38 | ||
|
|
e0db3b0e8a | ||
|
|
f31307105a | ||
|
|
8afadbdd9a | ||
|
|
bb57a85b35 | ||
|
|
57b0eb2f20 | ||
|
|
8f0af9f318 | ||
|
|
eee7616022 | ||
|
|
412e47f111 | ||
|
|
deee98d352 | ||
|
|
a66a7f1a0f | ||
|
|
8e2463bc32 | ||
|
|
40595e42ae | ||
|
|
cc9bbeb73e | ||
|
|
b9dea6d90d | ||
|
|
13b61d586a | ||
|
|
307cb47caf | ||
|
|
60aa7f310c | ||
|
|
bf842997c1 | ||
|
|
4fde0b177c | ||
|
|
50b567d6d1 | ||
|
|
f1b72443ff |
@@ -42,12 +42,12 @@
|
||||
SERIAL="test_loop.py test_freeze.py test_safety87.py"
|
||||
#
|
||||
# LONGEST FIRST (SAFETY-125's CI run, job 2580; then a local parallel run, seconds:
|
||||
# modes_p4 758, safety125 508 on hotfix-0.19.2 only, modes_p3 364, safety119 328,
|
||||
# modes_p4 758, safety126 634 (SAFETY-126, local, one core), safety125 508 on hotfix-0.19.2 only, modes_p3 364, safety119 328,
|
||||
# price_run_fixes 184, safety90 160, safety103 134, safety94 114, peakguard 113,
|
||||
# opt20 102, safety97 101, safety81 100). With JOBS workers pulling from one
|
||||
# queue, starting the longest files first keeps the tail short: the batch takes
|
||||
# about as long as the longest file. A sort order, not a selection.
|
||||
HEAVY="test_modes_p4.py test_safety125.py test_modes_p3.py test_safety119.py test_price_run_fixes.py test_safety90.py test_safety103.py test_safety94.py test_peakguard.py test_opt20.py test_safety97.py test_safety81.py"
|
||||
HEAVY="test_modes_p4.py test_safety126.py test_safety125.py test_modes_p3.py test_safety119.py test_price_run_fixes.py test_safety90.py test_safety103.py test_safety94.py test_peakguard.py test_opt20.py test_safety97.py test_safety81.py"
|
||||
#
|
||||
JOBS=${CI_JOBS:-6}
|
||||
cd "${TESTS_DIR:-/w/goodwe-addon/goodwe_controller}"
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
## 0.19.2
|
||||
|
||||
**A patch bump: one fix for charging from the grid under the peak limit, and no option changes.**
|
||||
### Not in a release yet
|
||||
|
||||
- The night grid charge no longer raises "Battery stopped limiting the peak" when the 15-minute period stays on the aim. A steadily rising load now alarms a little later, and with a peak margin of 0 a period can end up to 10 W over the limit without this alarm ([[SAFETY-126]]).
|
||||
- A night grid charge for *Leftover solar* no longer stops at 03:00 when the solar forecast sensors went quiet in the evening. The evening's figure is used until 06:00, and a charge that has started goes on to its level ([[OPT-34]]).
|
||||
|
||||
**A patch bump: two fixes, one for charging from the grid under the peak limit and one for the night charge of Leftover solar, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.19.1 also
|
||||
start 0.19.2. The change below applies with one inverter or two.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# FCST-03 - the hours the feed has not published, and what may not rest on them
|
||||
|
||||
**Status: SHIPPED.** Tests: `test_price_forecast.py`, `test_price_history.py`.
|
||||
Written down by MAINT-20 from the long module comment of `app/price_forecast.py`.
|
||||
Follows specs.md 8.3: "Day-ahead prices arrive once daily. Until they do, unknown
|
||||
hours use yesterday's profile and are flagged `provisional: true`, and no
|
||||
irreversible action may be taken on a provisional price. Real-time / imbalance
|
||||
products are treated as high-volatility: arbitrage thresholds widen." This module
|
||||
owns the first two; the widening is in price.py (`Uncertainty`, `widen_factor`,
|
||||
`required_spread`), spent by `static_tariff.TariffPlan.arbitrage()` as one more
|
||||
refusal on its existing ladder. Persistence of yesterday: [[FCST-04]].
|
||||
|
||||
## The flag is never passed through
|
||||
|
||||
A carried point and a published one are the same three floats and a bool.
|
||||
`PricePoint(..., provisional=y.provisional)` copies yesterday's `False` onto
|
||||
today's guess: stale data presented as current (SAFETY-18's defect one layer up).
|
||||
So:
|
||||
|
||||
- `_carried()` has no parameter for the flag; `provisional=True` is a literal in
|
||||
one place.
|
||||
- A published point is passed through by identity (`filled.points[i] is
|
||||
fetched.points[j]`, checked by the test), never copied.
|
||||
- `_audit()` re-derives the expected flag for every emitted point from the hours
|
||||
the feed published and raises on a mismatch. It never corrects (a module that
|
||||
quietly fixes its own provenance cannot be trusted).
|
||||
- test_price_forecast.py walks this file's AST and fails if any `PricePoint(...)`
|
||||
passes anything but a literal for `provisional`.
|
||||
|
||||
"Published" does not mean "firm": 9.3 carries `provisional` per point because a
|
||||
real-time product's current-interval print is published AND provisional, so the
|
||||
audit's rule for a published point is "whatever the feed said". No shipped
|
||||
plugin emits one since [[TARIFF-07]]; the rule is a property of the format and
|
||||
weakening it would let the first imbalance feed arrive mispriced and flagged firm.
|
||||
|
||||
## The audit is a provenance check, not a completeness check (FCST-05)
|
||||
|
||||
Rules: a PUBLISHED point must be one of `published[u]` by identity and be
|
||||
emitted once, with the feed's own flag; a CARRIED point's hour must not be in
|
||||
`published` at all, flag exactly True, and no second point in that hour. This
|
||||
replaced FCST-03's "at most one point per hour" count, which cannot express a
|
||||
quarter-hourly feed. FCST-04 claimed two strengthenings; both were false: a
|
||||
differential over every published mask on 3 hours crossed with every output of
|
||||
length 0-3 found zero divergence either way (OLD raised/NEW accepted 0, OLD
|
||||
accepted/NEW raised 0). The one real change is the intended relaxation (several
|
||||
distinct published points per hour).
|
||||
|
||||
The audit does not check completeness: fed the decimation defect (four points in
|
||||
an hour, only the last emitted), two of four, or an empty output, it accepts.
|
||||
What stops decimation is the point COUNT and IDENTITY checks in the two test
|
||||
files. It cannot be made complete from its arguments: `published` is over the
|
||||
whole unwindowed read while the fill emits `[since, until)`, so a published point
|
||||
outside the window is legitimately absent. FCST-05 built "every published point
|
||||
is emitted" (+162 B) and it took the 1 h, 12 h and 48 h horizon checks red on a
|
||||
healthy feed (a real change: the differential scored it 49 cases stronger),
|
||||
so the rule as stated is wrong, and
|
||||
asserting a completeness the instrument lacks is MUTATION.md habit 16's defect.
|
||||
|
||||
## The fill keys on the wall clock
|
||||
|
||||
For each whole hour of the horizon the feed has not published, take yesterday's
|
||||
value for the SAME BRUSSELS WALL-CLOCK HOUR, not `t - 86400 s`: a 23-hour day has
|
||||
no 02:00 and a 25-hour day has two, so a fixed 24 h shifts every carried hour
|
||||
across a transition. On a 25-hour day both 02:00s are filled from yesterday's
|
||||
single 02:00 (both guesses, both flagged); if yesterday was a 25-hour day, the
|
||||
first of its two 02:00 points is used. An hour with no published price and no
|
||||
yesterday value is left out, not filled with a neighbour, mean or zero: 11.4
|
||||
applies (`Absence`, then the static profile). If nothing can be produced the
|
||||
result is `no_data`, not an empty series.
|
||||
|
||||
A sub-hourly feed is not decimated: the hourly walk decides which hours need
|
||||
filling, never what a published hour may hold. Belgian day-ahead settles per
|
||||
quarter since 2025-10-01 (96 points a day), so every published point in the
|
||||
window is passed through and a carried one is added only for an hour with no
|
||||
published coverage. `{_utc_hour(p.start): p for p in r.points}` reads ordinary
|
||||
and keeps the last point of each hour; FCST-04's first caller shipped it, the
|
||||
series came out 15 points long, the spread fell from 0.143 to 0.014 EUR/kWh and
|
||||
the cheapest "hour" became a `:45` point. `_published_by_hour()` holds a tuple
|
||||
per hour for that reason.
|
||||
|
||||
The window is whole hours at both ends: `since` floored (the current hour is
|
||||
still tradeable) and `until` floored too; with `until` compared raw a 48 h horizon
|
||||
asked at 08:17 emitted 49 hours. `yesterday` has no default (a missing yesterday
|
||||
and a useless one must not look alike). The fill is idempotent and re-runnable
|
||||
(5.4 late publication, 14-16 h CET): published hours win, and the series is
|
||||
rebuilt, not patched. TARIFF-11 log line: carried HOURS, total hours and
|
||||
published POINTS are three different quantities (a quarter-hourly feed made it
|
||||
read "33 of 141 hours" for 48 h).
|
||||
|
||||
## AC2: the irreversible-action gate
|
||||
|
||||
An irreversible action is a value, `Irreversible`, carrying the hours it commits.
|
||||
`price_permits()` looks at exactly those hours and answers with a `Gate` carrying
|
||||
evidence on the permitting arm and a reason on the blocking one (the `_Outcome`
|
||||
shape). It blocks when the read observed nothing (11.4), an hour has no price, or
|
||||
an hour is provisional, and names the hour. The maintenance drain's window is
|
||||
derived from `maintenance.DRAIN_TIMEOUT_MIN` / `CHARGE_TIMEOUT_MIN`, so the gate
|
||||
and the phase machine cannot drift. `_window_hours()` is separate so the gate and
|
||||
its caller agree on "the hours it commits".
|
||||
|
||||
What is NOT gated matters more: the maintenance cycle is a calendar obligation
|
||||
(12.2: a cycle that silently never fires looks like one not yet due), and a site
|
||||
with `price_provider: off` (the default) has no series. Routing every start
|
||||
through a gate that blocks on `no_data` would stop the monthly cycle on every
|
||||
default install. So `begin_phase()` is the entry point for a start JUSTIFIED BY A
|
||||
PRICE; main.py's calendar path is untouched, and OPT-04 / MAINT-05 own which a
|
||||
site runs. Reversible actions (self-consumption modulation, the loop) are not
|
||||
gated: 6 says absent machinery must not disable the loop. Read this before wiring
|
||||
`begin_phase()` in.
|
||||
@@ -188,3 +188,64 @@ still raises; so does the same published point emitted twice; and a carried
|
||||
point sharing an hour with a real print now raises too, which the count never
|
||||
looked at. Each of those three is driven by its own mutation in
|
||||
`test_price_forecast.py`.
|
||||
|
||||
## 5. Store details (moved here from `app/price_history.py` by MAINT-20)
|
||||
|
||||
**Why a file, and which one.** FCST-03 shipped the fill with no caller, so no
|
||||
series ever contained a carried point. Without a yesterday that survives a
|
||||
restart, the mornings after an operator touched something spend the whole
|
||||
window on the truncated series. The cache is `/data/price_history.json`, not
|
||||
`/data/state.json`: `Store.set()` rewrites the whole of state.json on each
|
||||
write, and the refresh runs every `TARIFF_REFRESH_S` (1800 s), so 48 extra
|
||||
rewrites a day would go through the file whose corruption reads as "no
|
||||
maintenance ever ran". Separate file, separate blast radius. The directory is
|
||||
not created (`tariff.PeakStore`'s rule): `/data` is always mounted, so a
|
||||
`makedirs` could only succeed where the durable volume is missing and build a
|
||||
cache on the ephemeral layer. "Cannot persist" is one question asked after the
|
||||
attempt: see [[FCST-06]]; `self._days` never holds a point the file does not.
|
||||
|
||||
**Brussels date, not UTC.** 23:30 UTC in July is already tomorrow in Belgium.
|
||||
`_yesterday_of()` subtracts on the local `date`, not the instant: the old
|
||||
`brussels(stamp) - timedelta(days=1)` took 24 absolute hours and, at
|
||||
2026-03-30T00:30+02:00, returned 2026-03-28 (a two-day-old profile flagged as
|
||||
yesterday's). Rows are keyed by instant, not local hour, so the two 02:00s of
|
||||
the 25-hour October day both survive and `fill_from_yesterday()`'s own rule is
|
||||
the only collapsing rule. The merge key is the validated instant; the old
|
||||
string key merged by spelling and sorted the `+01:00` 02:00 before the `+02:00`.
|
||||
`KEEP_DAYS` is a sort-order cap, not a horizon: nothing is deleted by arithmetic
|
||||
against the clock (a jumped clock, WITNESS-03, would otherwise delete everything
|
||||
on the one morning the fill is for), and the two dates `yesterday()` can be
|
||||
asked for are pinned on top of the cap, which can only retain.
|
||||
|
||||
**One validator (FCST-05, MEDIUM).** The read path was defensive about every
|
||||
field and the write path trusted `q["start"]` to be hashable and sortable. An
|
||||
int, null, dict or list `start` raised `TypeError` out of `carry()` on the day
|
||||
being written; `run_tariff_refresh` caught it, so the add-on stayed up and every
|
||||
refresh failed for good (the plan retires after `TARIFF_MAX_AGE_S`, arbitrage and
|
||||
the cheap window go off, and `_remember()` raised before `_save()`, so no
|
||||
self-heal). `_point_of()` is now the one standard for both paths and returns
|
||||
`None` rather than raising.
|
||||
|
||||
**A broken cache is a short horizon, never a controller that will not start**
|
||||
(6: absent machinery must not disable the loop). `PriceHistory(...)` and `.words`
|
||||
are unguarded in `Controller.__init__`. Shipped defects, all fixed:
|
||||
|
||||
- `_load()` caught `json.JSONDecodeError` and `OSError` only; one non-UTF-8 byte
|
||||
raised `UnicodeDecodeError` (a `ValueError`, not an `OSError`) and the add-on
|
||||
did not start (FCST-05, HIGH). The handler names the `ValueError` family. The
|
||||
schema version is compared with its type (`1.0 == 1` in Python).
|
||||
- `{"days": {"2026-01-14": "oops"}}` loaded as healthy and raised
|
||||
`AttributeError` out of `words`, `yesterday()` and `resolve_tariff()`.
|
||||
`_usable_days()` drops such records, and counts (not drops) malformed rows
|
||||
inside a kept record so the store stops reporting health it lacks.
|
||||
- `words` printed `f"{d} ({_n_points(rec)} h)"`: points under the hour's name
|
||||
(`'2026-01-13 (96 h)'` for a 24-hour quarter-hourly day, FCST-05 LOW). Both
|
||||
numbers are printed now; only rows that are points are counted. `words` must
|
||||
never raise, `_held()` is total.
|
||||
- A failed merge is undone from a deep copy (a one-level copy shares the day
|
||||
records the merge rebinds).
|
||||
|
||||
`NO_HISTORY` is a name, not `None`, so `history=` can be required: `None`
|
||||
meaning "skip the fill" is how a caller silently gets the truncated horizon.
|
||||
`carry()` records first, then fills, so the filled series exists only as a
|
||||
return value that the store never sees again (rule 1).
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# OPT-27 - the night plan keeps the last fresh forecast
|
||||
|
||||
**Status: done on `release/1.0`, not released.** Tests: `test_opt27.py`.
|
||||
|
||||
**The defect.** A today sensor is stale after 3 h (`FRESH_S`) or when it is from
|
||||
before local midnight. Forecast.Solar stops updating in the evening, so at night
|
||||
every Forecast.Solar site had no forecast: a price mode's night cheap run needed
|
||||
1.5x the cycle cost (TARIFF-24 round 2) and Leftover solar planned nothing.
|
||||
Separately, one missed poll (`unavailable`) dropped the forecast at once.
|
||||
|
||||
**Where: `solarfc`, not the plan.** The last fresh reading of each sensor is
|
||||
kept in `solarfc.Hold` and `read_sum()` uses it when a sensor has none. A hold
|
||||
at the plan (`_wx_track`/`weather_held`) can only keep the 1.5x gap off. The
|
||||
day plan, Leftover solar's levels, Home and the Forecast tab all read
|
||||
`Controller.solar_fc`, so one kept forecast makes control and every screen agree
|
||||
(UX-53) with no second rule to keep in step. `weather_held()` is unchanged: it
|
||||
still keeps a run under way when a read is lost.
|
||||
|
||||
**The rule** (`Hold.stand_in()`). A kept reading is for one day. It covers a
|
||||
sensor that is missing or stale, never one with a bad value or unit, and:
|
||||
- for up to `FRESH_S` when the sensor is missing: a short outage, any time of day;
|
||||
- from the end of the bell (20:00 local, `BELL_TO_H`) until local midnight, if the
|
||||
reading is at most `KEPT_MAX_AGE_S` (12 h) old when used; after midnight the
|
||||
tomorrow figure is today's until the bell starts (06:00), up to 15 h old
|
||||
(OPT-34 changed this from midnight + 3 h);
|
||||
- the day it is for: a today figure only on that day, a tomorrow figure as
|
||||
tomorrow until midnight and, if the today sensors are still stale, as today's
|
||||
until 06:00 (all sensors of the sum are needed, as for any sum);
|
||||
- until the next fresh reading, which replaces it at once.
|
||||
|
||||
So a figure is never used for another day and never older than 12 hours (`KEPT_MAX_AGE_S`), except the morning case above: a 17:05 reading serves until 06:00 (12 h 55 min), a 09:05 reading only until 21:05. Until OPT-34 the figure ended at 03:00, and a charge under way lost its level there; OPT-34 closes that limit.
|
||||
Daytime is as before: a sensor that stopped is stale after 3 h, and no forecast
|
||||
all day is still no forecast (the night run still needs 1.5x).
|
||||
|
||||
**Where it is stored.** The controller's store, key `solar_fc_hold`, so an
|
||||
evening restart keeps it. It is written when a reading's figure changes or
|
||||
every 15 min (`SAVE_EVERY_S`), not on every poll. A damaged entry is dropped.
|
||||
|
||||
**The screens.** `Forecast.kept` is true when a reading older than `FRESH_S` is
|
||||
in use; the forecast line then reads "Solar forecast read 17:05, kept for
|
||||
tonight." (`home_words.solar.kept`, EN and NL). The event log says
|
||||
"read, kept from 17:05".
|
||||
|
||||
**Not changed.** Control with modes off: it does not use the forecast. The
|
||||
screens still show the line, so it says "kept", not "used for the plan". A
|
||||
forecast that stays fresh. The hold does not look at the weather, only at the clock and the day.
|
||||
@@ -0,0 +1,62 @@
|
||||
# OPT-34 - the night charge keeps its level when the kept forecast ends
|
||||
|
||||
**Status: done on `release/1.0`, not released.** Tests: `test_opt34.py`.
|
||||
|
||||
**The defect** (OPT-27 rig test, F-1). The kept evening figure ended at midnight + 3 h.
|
||||
With the sensors silent from 17:05 the Leftover solar battery's level went with
|
||||
it in the middle of the charge: A ended at 49.8 % against 79.9 % with a fresh
|
||||
figure. Whether Forecast.Solar refreshes after midnight on a live site is not
|
||||
known, so the add-on does not depend on it.
|
||||
|
||||
**The rule.**
|
||||
1. *A charge under way keeps its level* (`Controller._latch_charge()`). While
|
||||
there is a forecast (fresh or kept), each battery that charges from the grid in the
|
||||
cheap run takes its planned level into a latch (store key `night_charge_latch`).
|
||||
With no forecast, `plan_targets()` gives the latched level, and the wide
|
||||
no-forecast gap does not refuse the next slice of the run (`weather_held()`,
|
||||
as TARIFF-24 does for the run under way). While a
|
||||
forecast is there the latch follows the plan, up or down as a fresh figure moves
|
||||
it. It lasts through every cheap run on and one price slot after the last, and it
|
||||
ends at once when its end time passes, whatever the run state (it is also
|
||||
ignored at a restart if it has ended). A battery that is switched off or
|
||||
changed to another mode leaves it.
|
||||
*Night bound (manager's call, 2026-10-31):* a run carries the latch on only if
|
||||
it starts by the bell start (06:00 local) after the latch began; that run
|
||||
finishes, plus one slot. Chosen over a fixed 12 h because the night tariff
|
||||
(22:00-07:00) needs the 06:00-07:00 slice and 12 h from a late start would
|
||||
reach into the day. With dynamic prices a chain of cheap slots cannot carry a
|
||||
night's level into the next day.
|
||||
2. *The evening's tomorrow figure is today's until the bell starts*, 06:00 local
|
||||
(`BELL_FROM_H`, `solarfc._bell_start()`), not until midnight + 3 h.
|
||||
Nothing has been produced yet, so it is still the whole day.
|
||||
3. *Up to 15 h old for that case only* (`KEPT_MORNING_MAX_AGE_S`): a 17:05
|
||||
reading is 12 h 55 min old at 06:00. It must have been at most 12 h old at
|
||||
midnight, so a 09:05 reading, unusable since 21:05, does not come back for five
|
||||
minutes after midnight. Same-day figures and the daytime rule keep 12 h and 3 h.
|
||||
4. A fresh figure replaces the kept one at once, as before.
|
||||
|
||||
**Step 1 alone is enough for the owner's shape, steps 2-3 are still needed.**
|
||||
With the window put back to midnight + 3 h the owner's night still ends as a
|
||||
fresh figure ends it (A 86.9 %, B 95.0 %), because the charge had started at
|
||||
22:00. But a charge that has not started by 03:00 (the price dips at 04:00, or
|
||||
the headroom is taken) has no level to latch: A ends at 38 % against 78 %. The
|
||||
plan, the screens and a restart also need the figure itself.
|
||||
|
||||
**Screens.** "Solar forecast read {at}, kept for tonight." is still true until
|
||||
06:00 and unchanged. After the figure ends, with a charge still going on: "No
|
||||
solar forecast now. The charge under way goes on to its planned level." /
|
||||
"Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau."
|
||||
(`home_words.solar.latched`, only while a cheap run is on and a latched battery is below its level).
|
||||
|
||||
**Not used.** A figure over 15 h old; a morning figure at night (12 h); a figure
|
||||
for another day; a bad value or unit; and no latch for a charge that has not
|
||||
started, for a battery that is not charging, or after the cheap runs have ended
|
||||
(plus one slot).
|
||||
|
||||
**Not changed.** Charge sizing, the peak guard, Cheap hours, modes off, arbitrage.
|
||||
A fresh figure that LOWERS the level during a charge still lowers it, as before.
|
||||
|
||||
**Mutants** (all red in `test_opt34.py`): no latch; window back to midnight + 3 h;
|
||||
15 h without a limit; no "usable at midnight" rule; the latch without the narrow
|
||||
gap, without its save, or not kept through the next run; the window 6 h after
|
||||
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level.
|
||||
@@ -0,0 +1,166 @@
|
||||
# SAFETY-07 - the quarter-peak projection guard (with SAFETY-24 and CFG-03)
|
||||
|
||||
**Status: SHIPPED.** Tests: `test_peakguard.py`. Written down by MAINT-20 from
|
||||
the long module comment in `app/peakguard.py`, so the module can stay short.
|
||||
Later changes have their own notes: [[SAFETY-25]], [[SAFETY-32]], [[SAFETY-45]],
|
||||
[[SAFETY-79]], [[SAFETY-81]], [[SAFETY-87]], [[SAFETY-90]], [[SAFETY-125]],
|
||||
[[SAFETY-126]].
|
||||
|
||||
## The hazard
|
||||
|
||||
The capacity tariff bills the month's single worst 15-minute average offtake,
|
||||
with no averaging and no forgiveness. specs.md:583 records one 4998 W quarter
|
||||
that moved a monthly peak from 4832 W to 5013 W and billed the whole month at
|
||||
it. SAFETY-18 alarms on a frozen meter (`meter_freeze_s`, 300 s) but a frozen
|
||||
meter can rail the command in 75 s (freeze.py's table, `max_w: 5000`, meter at
|
||||
3000 W or more), against a 900 s quarter. The guard is the cap on that. It
|
||||
reads freeze.py's `looks_frozen()`; it is not a second freeze detector.
|
||||
|
||||
## The mechanism: one `lo` limit at `P_PEAK`
|
||||
|
||||
specs.md 10.1 row 2 wants pre-emptive discharge near the limit and a hard clamp
|
||||
on charging above it; 9.1 says discharge and export are never capped by the
|
||||
peak guard. A lower bound on the setpoint is the only shape that does both: a
|
||||
floor cannot stop the loop discharging harder (so 9.1 holds by structure), it
|
||||
clamps charging when above the standing charge, and it forces discharge when
|
||||
above zero.
|
||||
|
||||
- `P_PEAK` (50) is above `P_LOOP` (10) and below `P_MAINTENANCE` (60) and
|
||||
`P_SAFETY` (100). arbiter clause 2 applies only limits at or above the
|
||||
winning set's priority, so a P_SAFETY 0 W stop is not pulled off zero.
|
||||
- A limit does not out-vote another limit: clause 2 takes the most restrictive
|
||||
with no priority between limits. The floor (`lo >= 0`) and the maintenance
|
||||
charge-only shaping (`hi <= 0`) therefore resolve to `lo > hi`, a
|
||||
contradiction, which the arbiter answers with 0 W (battery parked in the
|
||||
worst quarter). The rule for two crossing limits is suppression, not
|
||||
priority: main.py drops the shaping while the guard is engaged
|
||||
(`shaping_suspended()`). The floor is clamped to `achievable_w` to close the
|
||||
only other crossing pair (the P_SAFETY rating bounds).
|
||||
- Arbitrage arrives as a SET below `P_PEAK`, so clause 2 binds it with this
|
||||
floor without any edit here (9.1: peak shaving outranks arbitrage).
|
||||
|
||||
## The floor is derived
|
||||
|
||||
With `projected = (acc + held * remaining) / 900`, the watts that must come off
|
||||
the meter are `excess_w = 900 * (projected - ceiling) / remaining`, and the
|
||||
floor is `floor_w = batt_w + excess_w`.
|
||||
|
||||
1. The claim is filed while the floor is positive, not while the excess is.
|
||||
`excess` falls to zero as soon as the battery is already removing it, and a
|
||||
guard that released there handed the actuator back and re-engaged next
|
||||
cycle. The first cut did that on 2026-08-20's billed quarter: 17
|
||||
engage/release cycles in 900 s, and the quarter still ended 49 W over.
|
||||
Thresholding `batt_w + excess` (the offtake with the battery at zero, less
|
||||
the level the quarter can afford) decays continuously, so there is no
|
||||
hysteresis constant. 10.2: an EMS that oscillates is worse than none.
|
||||
2. The gain 900/remaining is 1.0 at the start of a quarter and grows. The guard
|
||||
is gentlest when its evidence is weakest. No minimum-evidence threshold was
|
||||
added: it would delay the guard on a sustained rail from second zero.
|
||||
3. `batt_w` is the measured battery power, not the command. The bill is what
|
||||
flowed, and a floor built on the command winds up to the rail when the
|
||||
inverter cannot follow; one built on the measurement stops rising when
|
||||
delivery does.
|
||||
|
||||
## The ceiling
|
||||
|
||||
`ceiling_w = max(peak_cap_w, the month's banked peak)`. Once a month's peak
|
||||
stands at 5013 W, holding later quarters to 3500 W buys nothing. Only an
|
||||
`OBSERVED` reading may raise it; `NO_RECORD` and `NOT_OBSERVED` leave it at
|
||||
`peak_cap_w`, so a peak store that cannot be read makes the guard stricter,
|
||||
never looser. That is also why the guard does not use `headroom_w()`: a peak
|
||||
read as 0 W returns full headroom, which is a guard that is not there.
|
||||
SAFETY-79 aims `peak_margin_w` under the ceiling; the ceiling stays the real
|
||||
limit for reports and the bill.
|
||||
|
||||
## Blindness comes first
|
||||
|
||||
The projection is graded only if the quantity under it was measured. Three
|
||||
ways it was not, checked before grading:
|
||||
|
||||
- No open block: a fresh averager answers an all-zero `Projection`, which sits
|
||||
under every ceiling. That is "no quarter", not "the quarter is fine".
|
||||
- Coverage: a meter gap longer than `max_hold_s` leaves seconds the averager
|
||||
does not accumulate (holding a dead meter's value forward is how a fabricated
|
||||
peak gets onto a permanent record), so the projection reads low. The
|
||||
threshold is `max_hold_s`, not a new number. SAFETY-90 fills gaps once
|
||||
readings return.
|
||||
- Frozen: samples keep arriving, coverage is perfect and the projection is a
|
||||
confident zero while the real meter is at the rail.
|
||||
|
||||
The blind action is `floor_w = 0.0`: no importing while the billed quantity
|
||||
cannot be observed. It is one-directional (forbidding charge only lowers
|
||||
offtake), so a false blindness costs a charge and never pack energy.
|
||||
|
||||
## Why it trips where SAFETY-18 only notifies
|
||||
|
||||
The healthy-day false-engagement rate is zero (2026-08-23, 95 quarters;
|
||||
test_peakguard.py re-measures it from the committed capture). The action is
|
||||
level-triggered and unlatched: it is re-derived each cycle and releases when the
|
||||
projection drops, so a false engagement costs one cycle, not a parked battery.
|
||||
It is a limit, so every safety stop outranks it. It is the only guard whose
|
||||
trigger is the billed quantity itself.
|
||||
|
||||
## Saturation is reported, not alarmed as a bug
|
||||
|
||||
10.3 treats an out-of-range setpoint as a bug (SAFETY-03). A peak floor above
|
||||
the inverter rating is not: it is a 5900 W house on a 5000 W inverter (2026-08-20
|
||||
15:45). The floor is clamped to `achievable_w`, the decision carries
|
||||
`saturated`, and main.py raises one notification.
|
||||
|
||||
## SAFETY-24: the energy backstop the guard shipped without
|
||||
|
||||
The graded branch can force discharge (878 Wh in a day on the 2026-08-20 replay)
|
||||
and nothing below it consulted the state of charge; the BMS was the only
|
||||
backstop, which protects the pack and not the operator. `decide()` now takes
|
||||
`soc` and `soc_floor` and `discharge_permitted()` answers.
|
||||
|
||||
1. Not a trip. When it binds, the guard stops asking for more discharge and
|
||||
commands nothing; every stop in 11.2 still outranks it.
|
||||
2. The action is `floor_w = 0.0` (forbids charging, forbids nothing else). A
|
||||
`lo` limit cannot cap discharge, and a negative floor would destroy that.
|
||||
The loop is not bounded: self-consumption may still drain the pack past the
|
||||
floor. [[SAFETY-25]] measured it (2026-08-20, 19:00-22:00 UTC, 830 W mean:
|
||||
the pack ends at the BMS floor, 10.0 %, whatever `peak_soc_floor` is) and the
|
||||
owner decided on 2026-09-02 that the BMS stays the only backstop for
|
||||
demand-driven discharge, a loop-side reserve being deferred to ALLOC-01.
|
||||
3. Graded only after `wanted > 0`, where a forced discharge is asked for.
|
||||
4. An unreadable SoC is not a full battery. `soc=None` and `soc_floor=None` are
|
||||
floor-limited. Both arguments are required keyword-only, no default (TEST-14).
|
||||
5. Failing to shave is not silent: `floor_limited=True` and one notification.
|
||||
It excludes `saturated`, since it asks for nothing.
|
||||
|
||||
The action log says "the peak reserve", not "the SoC floor": `peak_soc_floor`
|
||||
(“Peak reserve”) bounds this guard only, and the wider words would contradict
|
||||
a loop that is draining the pack at that moment.
|
||||
|
||||
## CFG-03: two options, mirrored once
|
||||
|
||||
- `peak_soc_floor` is the EMS floor, not `maintenance_soc_floor` (the monthly
|
||||
recalibration's drain target); sharing a key would couple features whose
|
||||
right values differ. Default 20: the reference pack is 300 Ah at 51.2 V,
|
||||
DoD 90, 13.8 kWh usable; the replay's 878 Wh is 6.4 %; a floor 10 points above
|
||||
the 10 % cut-out leaves about 1.6 worst days of room.
|
||||
- `bms_soc_floor` is the pack's own cut-out (register 0x0560, 10 on the
|
||||
reference unit). It exists so validate.py rule 8 can refuse a
|
||||
`peak_soc_floor` at or below it.
|
||||
- Both defaults live once in `PEAK_OPTION_DEFAULTS` (TARIFF-02's pattern),
|
||||
imported by validate.py and main.py and pinned to config.yaml by the test.
|
||||
The module reads no option itself.
|
||||
- `maintenance_soc_floor` is the ambiguous name of the two. Decision: keep it,
|
||||
carry the distinction in DOCS.md; revisit if a third floor appears.
|
||||
- SAFETY-07 itself added no option: `peak_cap_w` was already validated
|
||||
`int(500,15000)`, and 14's `capacity_peak_limit_kw` stays declined (a kW key
|
||||
beside a W key is the mismatch trap).
|
||||
|
||||
## Per-pack floors (SAFETY-32) and phases
|
||||
|
||||
`bms_soc_floor` became a roster key; `pack_floor_pct()` conjoins it with the
|
||||
site's EMS floor, and rule 8 names the unit it refuses. `peak_soc_floor` stayed
|
||||
one number for the site, so with cut-outs of 10 and 18 the operator sets it
|
||||
above 18; the per-pack EMS floor is the open question in [[SAFETY-32]]. The
|
||||
`min()` over measured SoC (`Controller.pack_floor_soc`) is still right for the
|
||||
site verdict; `pack_permissions()` is the per-pack answer.
|
||||
|
||||
Nothing here reads a per-phase quantity: the sum/max/net choice is an open
|
||||
owner question, defaulted to NET inside `billed_w()`, so this file does not
|
||||
change whichever way it is answered.
|
||||
@@ -0,0 +1,47 @@
|
||||
# SAFETY-126 - a landing error on the aim is not an overrun
|
||||
|
||||
**Status: FIXED on release/1.0 (after 0.19.2).** Tests: `test_safety126.py`.
|
||||
|
||||
**Cause.** A grid charge under the peak limit is sized to land the 15-minute
|
||||
period on the aim (SAFETY-125), so the period ends 0-7 W over it. In the last
|
||||
second `peakguard.excess_w()` multiplies that by 900 / remaining (3 000 at
|
||||
0.3 s left, 90 000 at 0.01 s), so `decide()` wants tens of kW
|
||||
(`wanted > 0`, peakguard.py `decide()`). With every battery under the peak
|
||||
reserve that is floor-limited at 0 W, and `check_peak_floor_limited()`
|
||||
alarmed on it at once: SAFETY-87 graded only the case where another battery
|
||||
could give. With one battery able, the guard asked it for its rating for that
|
||||
cycle and the Batteries "Why" said "The peak limit needs power". The
|
||||
add-on's own charge is not the cause: `wanted` already counts it as
|
||||
stoppable.
|
||||
|
||||
**Rule.** Each cycle the guard wants power, `Controller._peak_landing()`
|
||||
asks `peakguard.misses_aim()`: with the batteries at 0 W from now on, does
|
||||
the period end more than `ALARM_MISS_W` (10 W) over the aim? This is
|
||||
`load_too_high()` with nothing to give, without `decide()`'s gain. If not, and
|
||||
no real miss was seen since the guard took hold, the decision is
|
||||
`within_aim`. Such a decision raises no reserve alarm (ERROR,
|
||||
notification, red row), no red "Load too high" row, no "Why" peak and
|
||||
no "Battery stopped limiting the peak" event hint. The claim, the floor and the
|
||||
commands are unchanged.
|
||||
|
||||
**Still alarms.** House load the batteries cannot cover under the reserve:
|
||||
the period would end over the aim with the batteries at 0 W. A step load
|
||||
alarms on the same cycle as before. SAFETY-87's case and SAFETY-81's 'Load too
|
||||
high' notification are unchanged.
|
||||
|
||||
**What the 10 W tolerance costs** (review, owner accepted 2026-10-31):
|
||||
- A steadily rising load alarms a little later, once the projected miss passes
|
||||
10 W (3 000 -> 4 000 W over 45 min: 56 s later). With the default 50 W
|
||||
margin the alarm still comes before the limit.
|
||||
- With `peak_margin_w` 0, a period can end up to 10 W over the limit without
|
||||
this alarm, the tolerance 'Load too high' already accepts (SAFETY-81).
|
||||
|
||||
`misses_aim()` takes the house as meter plus batteries, so an export nets
|
||||
off (`decide()`'s projection counts offtake only). A re-alarm while the loop's
|
||||
draw on held batteries covers the house is gone too (test_safety126.py part 2,
|
||||
modes off: 2 notifications, was 4).
|
||||
|
||||
**Not changed.** The one-cycle floor itself: at a period's last instant the
|
||||
guard still asks an able battery for its rating, or stops a charge, for a
|
||||
landing error. That is control, outside the alarm path. Raise a card if it
|
||||
should go.
|
||||
@@ -0,0 +1,273 @@
|
||||
# TARIFF-02 - price provider plugins, and the canonical shape (with later additions)
|
||||
|
||||
**Status: SHIPPED.** Tests: `test_price.py`, `test_price_service.py`. Written
|
||||
down by MAINT-20 from the long comments of `app/price.py` so the module can stay
|
||||
short. Related: [[TARIFF-07]], [[TARIFF-09]], [[TARIFF-10]], [[TARIFF-14]],
|
||||
[[TARIFF-15]], [[PRICE-01]].
|
||||
|
||||
## Where it lives
|
||||
|
||||
specs.md 2.2 puts this at `ems/ingest/prices/`; that layout is not being built.
|
||||
Owner decision 2026-08-30: everything ships in `goodwe_controller/app/`. The
|
||||
behaviour in specs.md 9.3 / 8.3 / 11.4 is unchanged and authoritative.
|
||||
|
||||
## The canonical format (9.3)
|
||||
|
||||
`{"prices": [{"start": "2026-01-15T00:00:00+01:00", "eur_per_kwh": 0.1834,
|
||||
"provisional": false}]}`. `start` is tz-aware at the Brussels offset for that
|
||||
instant, the price is EUR/kWh and all-in, `provisional` is per point (8.3: no
|
||||
irreversible action on a provisional price). Three fields; a fourth would change
|
||||
the wire format every store, fixture and captured body is written against.
|
||||
|
||||
## Three rules the module is shaped by
|
||||
|
||||
1. **Double-counting markup is refused, given a correct declaration.** A plugin
|
||||
returns `RawPoint`s with the figure its source published and declares in
|
||||
`capabilities().sources` which of {ENERGY, MARKUP, TAXES} it already holds.
|
||||
`assemble()` (framework only) adds the complement from `price_markup_eur_kwh`
|
||||
/ `price_taxes_eur_kwh`. A plugin cannot reach those two numbers by any route:
|
||||
`PluginOptions` raises `FrameworkOption`, and review of PR #16 wrote a
|
||||
double-counting plugin three more ways (`opts._o`, `price_option()`, and the
|
||||
public `PRICE_OPTION_DEFAULTS`), so `register()` also refuses any plugin
|
||||
whose own code mentions a framework key (`code_leaks()`). The guarantee is
|
||||
exactly this: the complement is added once *given a correct `sources`*. A
|
||||
plugin declaring `{ENERGY}` for an all-in feed comes out one markup high and
|
||||
no code can catch that. test_price.py checks that a plugin's docstring lines
|
||||
(`SOURCES:` / `The framework ADDS:`) agree with its declaration, not that
|
||||
either is true. `code_leaks()` strips docstrings before searching (they must
|
||||
name the components) and fails open on missing source (a frozen build must
|
||||
still start; CI asserts source is found for every shipped plugin).
|
||||
2. **The product type is declared, not inferred from the id.** `capabilities().
|
||||
product` is a `PriceProduct`, rides on the series, and `volatility_regime()`
|
||||
reads it (8.3: real-time products widen thresholds). A plugin id appears only
|
||||
in `PROVIDERS`, `RETIRED_PROVIDERS` and its own class (AC2). No shipped plugin
|
||||
declares `REALTIME` since [[TARIFF-07]]; `_WIDE`, `Uncertainty.REALTIME_PRODUCT`
|
||||
and `WIDEN_FACTOR`'s 2.5 are kept, and test_price.py section 10 declares
|
||||
REALTIME from a class outside this file to keep them live.
|
||||
3. **"No price data" is a type, not an empty list.** 11.4: no data means static
|
||||
profile, provisional plan, no arbitrage; all-zero prices mean arbitrage is
|
||||
merely pointless. `PriceRead.series()` refuses an empty list;
|
||||
`PriceRead.no_data(reason)` carries `.reason` and nothing else, so
|
||||
`min(read.points)`, `for p in read.points` and `.get("points", [])` raise
|
||||
`Absence`; `.match(*, series, no_data)` takes both handlers as required
|
||||
keywords. Same shape as `sim/riglock.py` after TEST-04 (read its `Absence`
|
||||
block before changing this). Two ways past it survive and are not worth code:
|
||||
`vars(read).get("points", [])`, and `bool(read)` being True for both
|
||||
variants (a falsy `no_data` would turn `if read:` into a guard by accident;
|
||||
use `.match`).
|
||||
|
||||
## Timezone without `zoneinfo`
|
||||
|
||||
The Alpine image has no `tzdata`, so `ZoneInfo("Europe/Brussels")` would raise
|
||||
on the client's Pi. Every instant is UTC and rendered at its fixed offset, so
|
||||
the October 02:00-03:00 hour that happens twice is two instants (`02:00+02:00`
|
||||
and `02:00+01:00`); a naive-local-hour key or a 24-slot array is wrong on both
|
||||
transition days (test_price.py counts points on both 2026 transitions). The EU
|
||||
rule: CEST from 01:00 UTC on the last Sunday of March to 01:00 UTC on the last
|
||||
Sunday of October. Review of PR #16 matched it against `zoneinfo` over 149 040
|
||||
hourly instants 2020-2036 with zero mismatches. Residual risk is political (EU
|
||||
abolishes the change): one edit in `brussels_offset()`.
|
||||
|
||||
## The feed range (TARIFF-09)
|
||||
|
||||
`generic_dayahead.fetch()` used to ignore `since`/`until` and request the bare
|
||||
URL, so the feed answered ONE Brussels day and 8.3 dropped everything past today
|
||||
as provisional: at 09:00 over a 48 h horizon 15 firm and 33 carried, at 23:00 1
|
||||
firm and 47 carried. `feed_days()` returns the Brussels calendar days (first,
|
||||
last, both inclusive; the API takes dates) that the half-open `[since, until)`
|
||||
touches: Brussels, never UTC (22:30 UTC in summer is already tomorrow); `until`
|
||||
exclusive, so local midnight belongs to the day before; an empty or inverted
|
||||
window collapses to one day. `api.energy-charts.info` capture:
|
||||
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`.
|
||||
`dated_feed_url()` refuses a base that already has a query string (appending
|
||||
would give `?bzn=BE?bzn=BE&start=...`). An operator's `price_feed_url` override
|
||||
is used verbatim and loses the range, on purpose: rewriting an operator's URL
|
||||
reorders and re-escapes it, and both providers declare a second host
|
||||
(`web-api.tp.entsoe.eu`) whose parameters are not `start`/`end`. The cost is
|
||||
logged at fetch time, naming the horizon consequence. The URL rule is shared by
|
||||
`generic_dayahead` and `custom_formula` (TARIFF-15), taking `plugin_id` as an
|
||||
argument so AC2 holds. The "range is used" promise is not mechanically enforced
|
||||
(a rule would be satisfied by an invented parameter name); test_price.py pins
|
||||
which plugins consume the range as a literal roster.
|
||||
|
||||
## Absence and the covering window
|
||||
|
||||
See [[TARIFF-14]] for the cover table: a point covers from its own start to the
|
||||
next point's start, never more than an hour; the last point gets the span of the
|
||||
one before; sorted at construction. `covers()` delegates to `at()` (a second copy
|
||||
of the rule is a second place to be wrong); `window_points()` grades only the
|
||||
`start` against `[since, until)` and is not a coverage query. `at()` is a linear
|
||||
scan over about 192 entries on the 30 s path; a bisect is not worth a second
|
||||
way to be wrong. A duplicated start (not reachable through `load_prices()`)
|
||||
gives the earlier entry a zero-width window.
|
||||
|
||||
## Taxes components (TARIFF-18)
|
||||
|
||||
`price_taxes_eur_kwh`'s default (0.06 before) is the sum of
|
||||
`FLEMISH_TAXES_COMPONENTS_EUR_KWH`, each row one line of the Eneco residential
|
||||
card for Flanders, September 2026, including 6 % VAT, in EUR/kWh;
|
||||
test_price.py section 1b re-adds them. It is per DSO: the distribution row runs
|
||||
5,28 c (Midden-Vlaanderen) to 6,70 c (West), so the all-in Flemish total runs
|
||||
about 0.117 to 0.131; a site outside Antwerpen types its own. The default sits
|
||||
near the low end on purpose: the scalar cancels in `max - min`, and its one
|
||||
decision is the 17 negative-price guard in arbitrage.py, which tests the
|
||||
absolute all-in sell price. Too high carries a genuinely negative hour above
|
||||
zero (export into paid-for disposal); too low only declines a trade that would
|
||||
have paid. `energiebijdrage` is a row of zero: from 1 August 2026 it is merged
|
||||
into the bijzondere accijns, and the zero row makes the double-count a visible
|
||||
decision. Moving a shipped default changes which configs start: TARIFF-17's rung
|
||||
in validate.py refuses `price_taxes_profile` together with a non-default scalar,
|
||||
comparing against the default (the Supervisor merges every schema key into the
|
||||
stored blob, 70 of 71 keys on the reference install, CHANGELOG 0.9.0), so a
|
||||
superseded shipped default (0.06) must still read as untouched, and the rung's
|
||||
sentence names both figures.
|
||||
|
||||
## One in-code mirror of option defaults (CFG-03)
|
||||
|
||||
Option defaults live in config.yaml's `options:` and `schema:` and main.py's
|
||||
`.get()` fallbacks. `PRICE_OPTION_DEFAULTS` is the single in-code mirror for the
|
||||
price options: validate.py imports it, main.py has no `o.get("price_...", X)`
|
||||
fallbacks, no dataclass carries a copy, and test_price.py section 1 reads
|
||||
config.yaml and asserts equality key for key. `static_profile` (TARIFF-03),
|
||||
`price_taxes_profile` (TARIFF-17), `price_formula` (TARIFF-15),
|
||||
`price_export_formula` (TARIFF-16) and the OPT-04 keys live there for the same
|
||||
reason, not in their own modules (a second dict would be a fourth location).
|
||||
Details:
|
||||
|
||||
- The tables / formulas are in `FRAMEWORK_KEYS`: a plugin reading one could add
|
||||
taxes a second time per hour, or fold an injection curve into a consumption
|
||||
price. `BANNED_IN_PLUGIN_CODE` derives from that set.
|
||||
- `arbitrage_soc_floor` / `arbitrage_soc_ceiling` are not the maintenance keys
|
||||
(a balancing cycle is not a trading reserve).
|
||||
- `cycle_cost_threshold_eur` is 0.10 because 10.1's name says so, not because it
|
||||
makes anything trade: on Belgian day/night numbers the static spread is about
|
||||
0.08, so a static-tariff site does not trade at this default (test_arbitrage.py
|
||||
section 7). Whether 0.10, or FCST-03's 2.5 / 1.5, is right is an owner
|
||||
decision.
|
||||
- `price_service_token` is a secret (`SECRET_OPTIONS`); `scrub()` removes secret
|
||||
values from any text built from an error.
|
||||
|
||||
## Widening (FCST-03)
|
||||
|
||||
One path, a set of active uncertainty sources (real-time product 8.3, no weather
|
||||
data 11.4). The combination rule is `max`, not a product (2.5 x 1.5 would give a
|
||||
threshold nothing on a Belgian tariff clears). The factors are policy defaults,
|
||||
not derived (no arbitrage ever ran); they differ so `max`, `sum` and `product`
|
||||
are distinguishable in a check. `widen_factor()` refuses a string (a silent 1.0
|
||||
would report a widening that is not applied).
|
||||
|
||||
## Injection price (AC4, TARIFF-16)
|
||||
|
||||
`export_price_eur_kwh()` is a separate scalar. It was said to be so because
|
||||
nothing dynamic exists to route; that was false (Eneco publishes `0,1 x BELPEX-H
|
||||
- 1,188`, ENGIE `(B x eSpot_15) - A`). The true reason: an injection curve has
|
||||
its own coefficients and VAT treatment (injection is VAT-exempt, consumption is
|
||||
6 %). TARIFF-16 added `price_export_formula`, read by `export_formula()` through
|
||||
the same parser as the consumption leg (`parse_export_formula()` is
|
||||
`parse_price_formula()` with its option name moved). `export_price_at()` falls
|
||||
through to the scalar and ignores the index, correctly. A negative answer is a
|
||||
real answer (Eneco's formula is below zero for any index under 11,88 EUR/MWh);
|
||||
nothing floors it. Nothing under `app/` calls either reader to take a decision:
|
||||
arbitrage prices the sell side as displaced import. A malformed leg is refused at
|
||||
startup by validate.py; `taxes_profile()` and `export_formula()` still handle it
|
||||
loudly at run time. `static_tariff` is imported lazily in `taxes_profile()`
|
||||
because it imports this module at module scope.
|
||||
|
||||
## Taxes at each point's own instant (TARIFF-17)
|
||||
|
||||
`taxes_at()` takes a scalar or a period table (duck-typed on `price_at`, because
|
||||
price.py must not import static_tariff.py). A non-flat component matters: ORES
|
||||
bills `dag 13,27 / nacht 7,39` c, a 5,88 c gap coinciding with the cheap spot
|
||||
hours, and flattening it understates the spread. `assemble()` must evaluate it
|
||||
per point; hoisting the lookup out of the loop collapses the feature back into a
|
||||
level (test_price.py section 24 grades which hour is cheapest, since a
|
||||
spread-magnitude assertion passes on the hoisted version). On a scalar, `0.0 +
|
||||
markup` is exactly `markup` in IEEE 754, so scalar sites get identical bytes.
|
||||
The table wins over the scalar; validate.py refuses configs where both speak.
|
||||
|
||||
## Retired providers (TARIFF-07)
|
||||
|
||||
`RETIRED_PROVIDERS` exists so `eneco_eddy` and friends are not refused as typos:
|
||||
they were shipped, the operator chose them, and every failed fetch fell back
|
||||
silently to `static_profile`. No alias to `generic_dayahead`: they published
|
||||
retail prices (`B x spot + A`) and it reads wholesale; an alias would invent the
|
||||
site's two coefficients (behind the klantenzone login; CREG tariff-card pages
|
||||
404, VREG unreachable). Each value is the id-specific half of the refusal (the
|
||||
endpoint, what it answered on 2026-09-13, why no replacement can be built);
|
||||
validate.py owns the framing. `register()` refuses at import to register a
|
||||
retired id again, because `pid in PROVIDERS` going true silences the named
|
||||
refusal; re-shipping is a decision recorded by removing it from
|
||||
`RETIRED_PROVIDERS` in the same commit. See [[TARIFF-07]].
|
||||
|
||||
## Parsers (TARIFF-08)
|
||||
|
||||
The only parser is columnar: `https://api.energy-charts.info/price?bzn=BE` gives
|
||||
parallel `unix_seconds` / `price` arrays plus `unit`. The earlier row parser read
|
||||
`doc["data"]`, a shape no feed ever published, graded by fixtures written from
|
||||
the same guess, so every fetch ended `SourceUnavailable` and 11.4 fell back to
|
||||
static silently. Two shapes, two functions: a parser that sniffs can decide
|
||||
wrong, and a wrong-but-parseable response becomes a price.
|
||||
`_points_from_columnar_json()` refuses:
|
||||
|
||||
- unequal arrays (`zip()` would truncate the evening peak, and a series missing
|
||||
the expensive hours is traded on while no-data switches arbitrage off);
|
||||
- a unit other than the declared one (EUR/kWh under the same keys would be 1000
|
||||
times too small and `MAX_PLAUSIBLE_EUR_KWH` bounds only the large side);
|
||||
- naive time: `unix_seconds` are UTC (`fromtimestamp(t, timezone.utc)`).
|
||||
Rendering at the Brussels offset is `assemble()`'s, once, for every plugin.
|
||||
|
||||
A `null` price is dropped and logged (the pairing is reliable, the source said
|
||||
it published none; `_Series.at()` answers the hole with `Absence`);
|
||||
`scenarios/fetch_epex_be.py` dropped 0 nulls in 12 months to 2026-08-31. The
|
||||
feed is quarter-hourly since Belgium's 15-minute MTU (2025-10-01); nothing
|
||||
assumes either granularity (test_price.py section 2b replays both).
|
||||
`_validate_raw()` accepts negative prices (windy-Sunday hours are the year's best
|
||||
charging; 17 keeps the regression) and rejects NaN, infinity, an implausible
|
||||
magnitude, naive datetimes, and duplicate or out-of-order starts.
|
||||
|
||||
## Transport and plugin details
|
||||
|
||||
- `check_host()` splits the scheme before parsing the host. The first version
|
||||
stripped `len("https://")`, which on `http://` ate two characters of the host
|
||||
(`pi.energy-charts.info`), so a non-TLS feed failed the allow-list and the check
|
||||
"plain http is refused" passed even with the TLS rule deleted. Found by
|
||||
mutation in review of PR #16.
|
||||
- The default transport imports aiohttp lazily so `manual_csv` works with no HTTP
|
||||
stack. With `headers` (price_service only) it answers in the safe words of
|
||||
`status_words()` and follows no redirects (the host is fixed; a redirect would
|
||||
take the token somewhere the allow-list never saw).
|
||||
- `manual_csv`: the CSV is all-in (the plugin cannot know what a typed number
|
||||
contains); `load_prices()` warns when markup / taxes are set non-zero under a
|
||||
plugin that sources them. No transport, aiohttp, network or tz database: it is
|
||||
the offline escape hatch and the TEST-01 replay ingest path.
|
||||
- `custom_formula` (TARIFF-15) declares `{ENERGY, MARKUP}`, not `ALL_IN`: a
|
||||
card's `Tariefformule` is the energy component only (Eneco tabulates network
|
||||
tariffs separately; ENGIE excludes network, transport, levies, excise), so
|
||||
`ALL_IN` would drop about 12 c/kWh. The risk left is an operator folding
|
||||
network charges into `offset`; the card self-check in custom_formula.py refuses
|
||||
that, and grades the coefficients against the card, not the card against
|
||||
reality. No expression evaluation: the string is settable over HTTP through
|
||||
the ingress config page, so `eval` would be RCE in a root container holding
|
||||
`SUPERVISOR_TOKEN`. It reuses `generic_dayahead`'s feed, URL and parser. It
|
||||
keeps `scale=1.0`, so its `RawPoint`s carry EUR/MWh in a field called
|
||||
`eur_per_kwh`: contained (destructured on the next line, never leaves the
|
||||
method; `_validate_raw()` would refuse an escape).
|
||||
- `startup_preview()` is a hook (TARIFF-15), not a line in `load_prices()`: only
|
||||
the plugin knows what went into its arithmetic, and the index beside the
|
||||
all-in figure makes a factor of 1000 obvious. It must not raise into the control
|
||||
path; the id is recorded before the hook runs so a failing preview is not
|
||||
retried every 30 minutes.
|
||||
- `load_prices()` returns, never raises, except `FrameworkOption` (a plugin trying
|
||||
to read the markup; turning it into "no price data" would hide the defect).
|
||||
- `price_provider_id()` is one spelling of the selected id because the same
|
||||
expression was written at three call sites (TARIFF-10). validate.py keeps its
|
||||
own reader on purpose: it validates the raw blob.
|
||||
- `price_provider_selected()` (TARIFF-10): the background refresh used to start
|
||||
only when `arbitrage_enabled` (ships false) was on, so a configured site
|
||||
fetched nothing (40 h soak: zero feed requests, `present: false` on all 802
|
||||
samples). It is not a trading switch: `arbitrage.enabled()` answers that.
|
||||
- `price_service` (PRICE-01): `FALLBACKS` and `KEEP_LAST` are empty/False for
|
||||
every earlier plugin, so `price_chain.py` is not used for them.
|
||||
`TOKEN_REFUSED` is the one phrase the fallback chain reads to tell "the owner
|
||||
must act" from "the server is down". `covers` lists complete days only; an hour
|
||||
missing inside a day is a hole, not a free hour.
|
||||
@@ -0,0 +1,102 @@
|
||||
# TARIFF-16 - the injection leg of a dynamic contract (`price_export_formula`)
|
||||
|
||||
**Status: SHIPPED.** Tests: `test_price.py` (17 series). Written down by
|
||||
MAINT-20 from `app/custom_formula.py` and `app/price.py`. Follows [[TARIFF-15]],
|
||||
whose parser it reuses.
|
||||
|
||||
## Why a formula, not the scalar
|
||||
|
||||
`price.py` modelled injection as one scalar. The market publishes a line a
|
||||
scalar cannot hold:
|
||||
|
||||
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
|
||||
ENGIE Dynamic (B x eSpot_15) - A
|
||||
Aspiravi (non-dynamic) 0,07 x Belpex - 2
|
||||
|
||||
1. Its own factor (Eneco 0,102 on consumption, 0,100 on injection; Aspiravi 0,07
|
||||
against about 0,10): not the consumption `B` with a sign flip.
|
||||
2. A negative offset (`- 1,188`, `- 2`); `price_markup_eur_kwh: float(0,1)`
|
||||
cannot express one.
|
||||
3. VAT-exempt ("Injectietarieven zijn vrijgesteld van btw") against 6 % on
|
||||
consumption: representable only because `vat_pct` is per spec.
|
||||
4. It goes negative with no floor: Eneco's is below zero for any BELPEX-H under
|
||||
11,88 EUR/MWh, the hours a PV household exports in. You can be charged to
|
||||
export.
|
||||
|
||||
`price_export_formula` is `custom_formula`'s parser with one argument moved
|
||||
(`option`, a row of `_LEGS`), as TARIFF-17 did for `parse_static_profile()`. A
|
||||
second parser would be a second `_finite()`, unknown-key rule, unit table and
|
||||
`to_resolution()`, diverging first on the 23- and 25-hour days. Every earlier
|
||||
call site passes no `option` and reads byte-identical sentences. The flat scalar
|
||||
`export_price_eur_kwh()` is untouched and still answers when the option is "".
|
||||
|
||||
## What it does not affect
|
||||
|
||||
`arbitrage.py` does not read the injection price at all (9.3: near-zero Belgian
|
||||
injection; it prices the sell side as displaced import), so no trade today turns
|
||||
on this number and it is not a wrong-trade fix. It changes what an operator is
|
||||
told and what a future export-aware strategy (OPT-06) rests on.
|
||||
|
||||
## The card cannot always grade it
|
||||
|
||||
Eneco's injection formula is printed with no price beside it. The `card` block
|
||||
is required on the consumption leg and optional here (`FormulaSpec.card_graded`
|
||||
is how callers ask; `card_discrepancy()` raises on an ungraded spec rather than
|
||||
returning 0.0). The self-check runs wherever a printed estimate exists, and
|
||||
validate.py says at startup that a leg is ungraded. Inventing an estimate would
|
||||
grade the arithmetic against itself (MUTATION.md habit 4).
|
||||
|
||||
## `implausible_injection()`: bounds on the coefficients, not the price
|
||||
|
||||
The consumption band (`implausible_all_in()`) cannot be reused: it adds
|
||||
`price_taxes_eur_kwh`, a consumption component, and its floor `energy >= 0.02`
|
||||
would refuse the real market's negative hours. But that floor catches a whole
|
||||
card read 100x too small, which the card check cannot see (it cancels). A band
|
||||
spanning zero cannot catch 0.00132 EUR/kWh, so the bound moves onto the
|
||||
coefficients, index-free and sign-free:
|
||||
|
||||
- pass-through `factor x UNITS[units] / 0.001` (one EUR/MWh is 0.001 EUR/kWh):
|
||||
1.00 for Eneco's injection leg, 0.70 for Aspiravi's; 100x off reads 0.01 or 100;
|
||||
- offset magnitude, on the absolute value (1,188 c = 0.01188; a hundredfold error
|
||||
is 1.188 EUR/kWh).
|
||||
|
||||
Two rungs can both answer one stimulus, so they are graded on their reasons and
|
||||
mutated together (MUTATION.md, TEST-16); test_price.py 17d builds fixtures so
|
||||
each is the only one firing. It bounds the shape of the coefficients, never
|
||||
their truth.
|
||||
|
||||
## The consumption band's asymmetry (`implausible_all_in()`)
|
||||
|
||||
The low end is graded on the energy component and the high end on the all-in
|
||||
figure. `price_taxes_eur_kwh` ships at 0.11736 (TARIFF-18; 0.06 before), nearly
|
||||
six times the low bound, so a card read 100x too small (0.0016 EUR/kWh) is
|
||||
carried over the floor by the flat tax addend alone and would pass a band applied
|
||||
to the all-in total. Raising the default made the trap wider. The ceiling is on
|
||||
the all-in figure because that is what the site is billed. It grades the card's
|
||||
own point, not every hour: live EPEX hours may fall outside (a negative hour is
|
||||
the best charging opportunity of the year, 17 keeps a regression case). It is
|
||||
called from validate.py only, since it reads the framework-owned taxes number.
|
||||
|
||||
## Smaller rules
|
||||
|
||||
- `CARD_TOLERANCE` is 0.01 in the card's units, the last digit both real cards
|
||||
print (Eneco 15,55 against 15.5471; ENGIE 7,522 against 7.5218). Every error
|
||||
class it exists for is at least two orders of magnitude bigger.
|
||||
- The card age warning (about 6 months) is never a refusal: ENGIE freezes a
|
||||
contract's coefficients, so an old card can be exactly right.
|
||||
- `units` is required with no default (a default is the factor-1000 class).
|
||||
A zero factor is a fixed tariff in a dynamic one's clothes (every spread 0,
|
||||
arbitrage never fires; 14's warning box), so the operator is pointed at the
|
||||
first-class way to say one price. A negative factor inverts the market (charge
|
||||
into the evening peak). `vat_pct: 0` is legitimate (injection is exempt; some
|
||||
cards are quoted VAT-inclusive); the bound exists for the 106 / 1.06 / 6
|
||||
confusion. An unknown key is refused, not ignored, so a user who types "expr"
|
||||
is not priced on the fields they also typed. A non-finite factor would make
|
||||
every price NaN; `True` is an `int` and would price the day at the index.
|
||||
- `to_resolution()`: the bucket is a UTC hour, exact for Belgium (CET and CEST
|
||||
are whole-hour offsets, so keying by UTC cannot collide the two 02:00s). It
|
||||
decides how many points are emitted, not how long one covers ([[TARIFF-14]]). An
|
||||
incomplete hour is averaged over what is there, not dropped or zero-filled.
|
||||
- The startup preview prints the index that went in beside the price that came
|
||||
out (`61.96 -> 0.13522`): every error this module refuses is invisible on the
|
||||
status page, and a factor of 1000 is one glance there.
|
||||
@@ -557,6 +557,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
|
||||
- No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so.
|
||||
- A battery needs its capacity (`inverters.capacity_kwh`) for a plan.
|
||||
- **At night.** Some forecasts stop updating in the evening (Forecast.Solar does). The add-on keeps the last reading of each sensor and uses it for that night's plan: from 20:00 until 06:00, or until a newer reading comes. After midnight the evening's figure for tomorrow is today's, whole, because nothing has been produced yet. A sensor that is missing for up to 3 hours is covered the same way. A figure is never used for another day, and never when it is more than 12 hours old (15 hours after midnight, for a figure that was still in use at midnight). A charge from the grid that has started keeps its level when the forecast ends, and goes on to it; the Batteries screen says so. The Batteries screen says "Solar forecast read 17:05, kept for tonight." With no reading all day, the night run still needs the wider gap.
|
||||
|
||||
**What always comes first.**
|
||||
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
|
||||
@@ -3457,7 +3458,7 @@ Charging from the grid stays blocked meanwhile. With several inverters the
|
||||
notification names the battery at its limit (SAFETY-32), each graded against
|
||||
its own cut-out level. That battery is held (weight 0 while the site gives)
|
||||
and the guard's need goes to the others, up to their rating; the alarm fires
|
||||
only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). ⚠️ **The reserve binds the peak guard only
|
||||
only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). With no battery able to give it fires at once, but only if the period would end more than 10 W over the aim with the batteries at 0 W. A grid charge the add-on runs counts as one it can stop, and the last second's landing error on the aim is not an alarm. A steadily rising load therefore alarms a little later. With `peak_margin_w` 0 a period can end up to 10 W over the limit without this alarm, as for “Load too high” (SAFETY-126). ⚠️ **The reserve binds the peak guard only
|
||||
(SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor`
|
||||
does not protect*.
|
||||
|
||||
|
||||
@@ -1,174 +1,18 @@
|
||||
"""TARIFF-15 - the customer's own tariff card, as two fixed coefficients.
|
||||
|
||||
WHAT THIS IS FOR, in one sentence: a Belgian dynamic contract prices energy as
|
||||
`B x index + A` with coefficients that exist in exactly one place - the tariff
|
||||
card in the customer's klantenzone - so the customer types those two numbers
|
||||
and the add-on does every unit conversion and the VAT multiplication itself.
|
||||
A Belgian dynamic contract prices energy as `B x index + A` with coefficients
|
||||
that exist only on the customer's card. The customer types those two numbers;
|
||||
the add-on does every unit conversion and the VAT multiplication. Fixed
|
||||
coefficients, no expression evaluation of any kind (the string is settable over
|
||||
HTTP, and an expression cannot be graded against the card). An unknown key is
|
||||
refused, never ignored. validate.py refuses to start unless the formula
|
||||
reproduces the card's printed estimate within CARD_TOLERANCE: that grades the
|
||||
coefficients against the card, not the card against reality.
|
||||
|
||||
WHY A FORMULA AND NOT FOURTEEN MORE PLUGINS (§9.2, TARIFF-07)
|
||||
=============================================================
|
||||
There is NO machine-readable source of Belgian tariff coefficients anywhere.
|
||||
CREG's consumer tariff-card pages 404, CREG Scan is a JS-only wizard, the
|
||||
annual report carries the supplier list and no per-product coefficients, and
|
||||
VREG was unreachable. Every coefficient found in research came off a supplier's
|
||||
own PDF, one of them image-only. So there is nothing to scrape and a
|
||||
per-supplier plugin would have no source for its numbers - fourteen plugins
|
||||
would be fourteen copies of the same invention, which is the defect TARIFF-07
|
||||
withdrew three plugins for. THE CUSTOMER IS THE ONLY PARTY WHO CAN SEE THEIR
|
||||
OWN COEFFICIENTS, and the card they read them from is the only artefact that
|
||||
can grade them.
|
||||
|
||||
THE SHAPE IS THE MARKET'S PUBLISHED ARITHMETIC, NOT OUR APPROXIMATION
|
||||
====================================================================
|
||||
Both suppliers whose 2026 cards were readable print the formula, the reference
|
||||
index they assumed AND the resulting price, so the arithmetic below is
|
||||
reproducible against something they published rather than against our own
|
||||
guess:
|
||||
|
||||
Eneco (0.102 x 133.9905 + 1.00) x 1.06 = 15.5471 card prints 15,55
|
||||
ENGIE (0.100 x 61.96 + 0.90) x 1.06 = 7.5218 card prints 7,522
|
||||
|
||||
Two independent suppliers, reproduced to the last printed digit, in
|
||||
c EUR/kWh. test_price.py section 13 runs both through the SHIPPED code.
|
||||
|
||||
⚠️ THE DOUBLE-COUNTING MECHANISM, AND WHY THIS DESIGN IS SAFE AT ALL
|
||||
====================================================================
|
||||
This provider declares `sources = {ENERGY, MARKUP}` and NOT `ALL_IN`. A Belgian
|
||||
card's `Tariefformule` line is THE ENERGY COMPONENT ONLY: Eneco puts network
|
||||
tariffs in one table and levies in a second, and ENGIE states in so many words
|
||||
that the formula "does not include fixed network/distribution costs, transport,
|
||||
grid levies, or excise duties". Declaring ALL_IN would lie on behalf of every
|
||||
user who typed what their card says, and would silently drop ~12 c EUR/kWh.
|
||||
|
||||
price.py already mechanises that a PLUGIN cannot double-count (`PluginOptions`,
|
||||
`FRAMEWORK_KEYS`, `code_leaks()`). This provider introduces a risk none of those
|
||||
can see, because it is not in the code: THE OPERATOR double-counting, by folding
|
||||
their network charges into `offset` while `price_taxes_eur_kwh` carries them as
|
||||
well. `code_leaks()` scans code. This is config.
|
||||
|
||||
So the card is the grader:
|
||||
|
||||
At startup validate.py computes
|
||||
`(factor x card.index_eur_per_mwh + offset) x (1 + vat_pct/100)` and
|
||||
REFUSES TO START unless it equals `card.expect_incl_vat` to within
|
||||
CARD_TOLERANCE, naming the discrepancy.
|
||||
|
||||
An `offset` inflated by network charges CANNOT PASS: the card's printed estimate
|
||||
is by construction the energy component alone, so adding ~12 c EUR/kWh moves
|
||||
15.55 to ~27 and the add-on refuses. The same check catches VAT omitted, VAT
|
||||
applied twice, a transposed digit, and a UNIT MISMATCH BETWEEN THE TWO SIDES -
|
||||
coefficients read off the formula line while the expected price is typed in
|
||||
another convention, which is the factor-10/100/1000 class as it actually
|
||||
happens.
|
||||
|
||||
⚠️ THE ONE UNIT ERROR THE CARD CHECK CANNOT SEE IS THE ONE APPLIED TO BOTH
|
||||
SIDES AT ONCE, and saying so is the difference between a guard and a slogan:
|
||||
scaling the coefficients AND the expected price by the same 100 cancels out of
|
||||
a comparison between them. That is what `implausible_all_in()` is for, and it
|
||||
is a SEPARATE refusal on a SEPARATE quantity - the card's own point, all-in,
|
||||
against the band a Belgian retail tariff can occupy.
|
||||
|
||||
⚠️ AND THE LIMIT, IN THE SAME BREATH AS THE CLAIM, exactly as price.py's trap 1
|
||||
states its own: THE CHECK GRADES THE COEFFICIENTS AGAINST THE CARD, NOT THE CARD
|
||||
AGAINST REALITY. A consistent-but-wrong pair - both numbers copied off the wrong
|
||||
product, or off last year's card - reproduces its own printed price perfectly
|
||||
and passes. Nothing here or anywhere else can catch that; what is mechanised is
|
||||
that the two coefficients and the printed estimate AGREE, which is the whole of
|
||||
the unit/VAT/decimal-point class and none of the "wrong contract" class.
|
||||
|
||||
⚠️ FIXED COEFFICIENTS. NO EXPRESSION EVALUATION OF ANY KIND.
|
||||
============================================================
|
||||
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
|
||||
`B x index + A`, so an expression buys nothing the market needs. Against that:
|
||||
|
||||
* the add-on serves `POST config/save` -> `POST /addons/self/options` on its
|
||||
own ingress page, so this string is SETTABLE OVER HTTP. `eval` on it is
|
||||
remote code execution in a root container holding SUPERVISOR_TOKEN, with
|
||||
`homeassistant_api` and `hassio_api`, driving an inverter over RS485.
|
||||
(`eval(s, {"__builtins__": {}})` is not a sandbox and never was.)
|
||||
* ⚠️ AND THE DECIDING ARGUMENT IS NOT SECURITY - AN EXPRESSION IS
|
||||
UNVERIFIABLE. Two coefficients can be graded against the card's printed
|
||||
estimate. An arbitrary expression cannot be graded against anything, so it
|
||||
makes the invisible-wrong-coefficient problem STRICTLY WORSE, which is the
|
||||
exact failure this provider exists to prevent.
|
||||
|
||||
Future shapes become NAMED FIELDS - `clamp_lo`, `factor_negative`,
|
||||
`offset_bands` - each individually refusable and each individually gradeable
|
||||
against a card. Never a grammar. `parse_price_formula()` therefore REFUSES AN
|
||||
UNKNOWN KEY rather than ignoring it, so a user who types `"expr"` is told it
|
||||
does not exist instead of being silently priced on the fields they also typed.
|
||||
test_price.py section 13f walks this module's AST and fails if `eval`, `exec`,
|
||||
`compile`, `__import__` or `literal_eval` ever appears in it.
|
||||
|
||||
WHERE THE NUMBERS COME FROM AND WHERE THEY GO
|
||||
=============================================
|
||||
`index` names the reference series. `epex_be` is the EPEX BE day-ahead curve in
|
||||
EUR/MWh, which is what `generic_dayahead` already fetches - same feed, same
|
||||
`brussels_date_range()` request, same columnar parser, same declared egress
|
||||
hosts. No new parser and no new failure mode.
|
||||
|
||||
`resolution` says which series the contract is written against:
|
||||
|
||||
* `quarter_hourly` - the 96 points the feed publishes since Belgium's
|
||||
15-minute MTU (2025-10-01), emitted as they are;
|
||||
* `hourly` - the four quarters of each hour averaged into one point.
|
||||
|
||||
⚠️ NEITHER IS AN HOURLY ASSUMPTION IN `_Series.at()`'s SENSE (TARIFF-14). The
|
||||
hourly option changes how many POINTS are emitted and their spacing; the window
|
||||
each point covers is still derived from its neighbours by `_cover_table()`, and
|
||||
nothing here tells anything downstream how long a point lasts. A feed that is
|
||||
already hourly averages one member per hour, which is the identity - so the
|
||||
option cannot silently assume the granularity it was handed.
|
||||
|
||||
`units` is NOT bureaucracy. Two suppliers publish the same shape in different
|
||||
units and the market aggregator prints both conventions in one column, so the
|
||||
add-on has to be told which one the card is in - and the unit conversion is the
|
||||
add-on's job precisely because it is one of the two steps that silently costs a
|
||||
factor of 10, 100 or 1000. The other is VAT, which is also the add-on's.
|
||||
|
||||
TARIFF-16 - THE SECOND LEG, AND WHY IT IS THIS PARSER AND NOT A SECOND ONE
|
||||
==========================================================================
|
||||
A Belgian dynamic contract has TWO published lines, not one, and the second is
|
||||
what the site is PAID for export:
|
||||
|
||||
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
|
||||
ENGIE Dynamic (B x eSpot_15) - A
|
||||
Aspiravi (non-dynamic) 0,07 x Belpex - 2
|
||||
|
||||
Same `B x index + A` shape - which is why `price_export_formula` is this
|
||||
module's parser with ONE argument moved (`option`), exactly as TARIFF-17 made
|
||||
`parse_static_profile()`'s option name a parameter rather than forking the
|
||||
period grammar. What differs between the two legs is FOUR numbers and a
|
||||
handful of sentences, and all of them are rows of `_LEGS`:
|
||||
|
||||
* its OWN factor - Eneco 0,102 on consumption against 0,100 on injection,
|
||||
Aspiravi 0,07 against ~0,10. NOT the consumption `B` with a sign flip;
|
||||
* a NEGATIVE offset, which `factor`/`offset` already allow and which
|
||||
`price_markup_eur_kwh: float(0,1)` cannot express at all;
|
||||
* `vat_pct: 0` - "Injectietarieven zijn vrijgesteld van btw" - against 6 on
|
||||
consumption, which is a SIX PERCENT ASYMMETRY between the buy leg and the
|
||||
sell leg and is representable only because `vat_pct` is PER SPEC;
|
||||
* a price that goes BELOW ZERO with no floor, in the hours a PV household
|
||||
exports. `implausible_injection()` is the band that accepts that and still
|
||||
refuses an implausible one; `implausible_all_in()` is NOT it, and its
|
||||
docstring says why.
|
||||
|
||||
⚠️ AND THE CARD CANNOT ALWAYS GRADE IT. Eneco's injection formula is printed
|
||||
with NO price beside it, so on that card there is nothing to reproduce. The
|
||||
`card` block is therefore OPTIONAL on this leg and REQUIRED on the other, the
|
||||
self-check runs wherever there is a printed estimate, and validate.py says at
|
||||
startup when a leg is ungraded. Inventing an estimate to grade against would
|
||||
be grading arithmetic against itself, which is the defect MUTATION.md habit 4
|
||||
is about; TARIFF-15 demonstrated two tariffs end to end rather than three for
|
||||
the same reason.
|
||||
|
||||
⚠️ NOTHING TRADES ON THIS TODAY, AND SAYING SO IS PART OF SHIPPING IT.
|
||||
`arbitrage.py` does not read `export_price_eur_kwh` at all - its own comment
|
||||
says so, citing §9.3's near-zero Belgian injection price - and both hours it
|
||||
compares are all-in CONSUMPTION prices. So this is not a wrong-trade defect
|
||||
and must not be described as one. What it changes is what an operator is told,
|
||||
and what a future export-aware strategy would rest on.
|
||||
"""
|
||||
The provider declares `{ENERGY, MARKUP}`, never `ALL_IN` (a card's formula is
|
||||
the energy component only). TARIFF-16 added the injection leg through this same
|
||||
parser. DECISIONS/TARIFF-15-the-card-is-the-grader.md and
|
||||
DECISIONS/TARIFF-16-the-injection-leg.md."""
|
||||
|
||||
import json
|
||||
import math
|
||||
@@ -183,13 +27,11 @@ class FormulaInvalid(Exception):
|
||||
validate.py at startup - never at 03:00 on a fetch."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The enumerations. Every one of these is a CLOSED set, and that is the point:
|
||||
# an unknown value is refused by name rather than guessed at.
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The enumerations. Each is a CLOSED set: an unknown value is refused by name,
|
||||
# not guessed.
|
||||
|
||||
# unit -> how many EUR/kWh one of it is. ⚠️ THE WHOLE FACTOR-1000 CLASS LIVES
|
||||
# HERE, so the table is tiny, explicit, and has no "default".
|
||||
# unit -> EUR/kWh per one of it. The whole factor-1000 class lives here: tiny,
|
||||
# explicit, no default.
|
||||
UNITS: dict = {
|
||||
"c_eur_per_kwh": 0.01, # eurocent per kWh - Eneco and ENGIE print this
|
||||
"eur_per_kwh": 1.0, # some aggregators print this
|
||||
@@ -198,54 +40,41 @@ UNITS: dict = {
|
||||
|
||||
RESOLUTIONS: tuple = ("hourly", "quarter_hourly")
|
||||
|
||||
# index id -> the human name of the series it refers to. One entry today; a
|
||||
# second index is a new row here plus a fetch path, not a new grammar.
|
||||
# index id -> the series it names. A second index is a new row here plus a
|
||||
# fetch path.
|
||||
INDEXES: dict = {
|
||||
"epex_be": "Belgian day-ahead price, EUR/MWh",
|
||||
}
|
||||
|
||||
# ⚠️ 0.01 IN THE CARD'S OWN UNITS, WHICH IS THE LAST DIGIT BOTH REAL CARDS
|
||||
# PRINT. Eneco prints 15,55 against a computed 15.5471 (0.0029 out) and ENGIE
|
||||
# prints 7,522 against a computed 7.5218 (0.0002 out), so the tolerance has to
|
||||
# absorb the card's own rounding and nothing more. Every error class this check
|
||||
# exists for - a factor of 10, of 100, of 1000, a missing or doubled 1.06, a
|
||||
# transposed digit, ~12 c EUR/kWh of network charge folded into `offset` - is
|
||||
# at least two orders of magnitude bigger than this.
|
||||
# 0.01 in the card's own units, the last digit both real cards print (Eneco
|
||||
# 15,55 vs 15.5471; ENGIE 7,522 vs 7.5218): the card's rounding and nothing
|
||||
# more. DECISIONS/TARIFF-16-the-injection-leg.md.
|
||||
CARD_TOLERANCE = 0.01
|
||||
|
||||
# The all-in plausibility band, EUR/kWh. `MAX_PLAUSIBLE_EUR_KWH` in price.py
|
||||
# bounds only prices that are too LARGE; this is the other end, and it is the
|
||||
# one the factor-1000 error falls off.
|
||||
# The all-in plausibility band, EUR/kWh: the low end, where a factor-1000 error
|
||||
# falls off.
|
||||
PLAUSIBLE_LO_EUR_KWH = 0.02
|
||||
PLAUSIBLE_HI_EUR_KWH = 1.00
|
||||
|
||||
# ~6 months. ⚠️ A WARNING, NEVER A REFUSAL: ENGIE freezes a contract's
|
||||
# coefficients for its duration, so a card two years old can be exactly right
|
||||
# and a site refused over one would be a site that cannot start.
|
||||
# ~6 months. A WARNING, never a refusal: ENGIE freezes a contract's
|
||||
# coefficients.
|
||||
CARD_STALE_DAYS = 183
|
||||
|
||||
_CARD_KEYS = ("month", "index_eur_per_mwh", "expect_incl_vat")
|
||||
_TOP_KEYS = ("index", "resolution", "factor", "offset", "units", "vat_pct",
|
||||
"card")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TARIFF-16 - the SECOND leg, and the one parameter that carries it
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
EXPORT_FORMULA_OPTION = "price_export_formula"
|
||||
|
||||
# ⚠️ THE PASS-THROUGH FRACTION, WHICH IS WHY THE NEXT TWO BOUNDS CAN BE
|
||||
# INDEX-FREE. One EUR/MWh IS 0.001 EUR/kWh, so `factor x UNITS[units] / 0.001`
|
||||
# is the dimensionless share of the wholesale price the contract passes
|
||||
# through - 1.00 for Eneco's injection leg (0,100 c EUR/kWh per EUR/MWh), 0.70
|
||||
# for Aspiravi's (0,07). A coefficient typed a hundred times too small or too
|
||||
# large moves this by a hundred, whatever the index and whatever the sign of
|
||||
# the price.
|
||||
# The pass-through fraction, `factor x UNITS[units] / 0.001`: index-free and
|
||||
# sign-free, so a coefficient typed 100x off moves it by 100 whatever the
|
||||
# price.
|
||||
EUR_KWH_PER_EUR_MWH = 0.001
|
||||
|
||||
# The two plausibility bounds for an INJECTION leg. ⚠️ NEITHER LOOKS AT THE
|
||||
# SIGN OF THE PRICE, AND THAT IS THE WHOLE POINT - see
|
||||
# `implausible_injection()`.
|
||||
# The two plausibility bounds for an INJECTION leg; neither looks at the sign
|
||||
# of the price.
|
||||
PASSTHROUGH_LO = 0.10 # 10% of the wholesale price
|
||||
PASSTHROUGH_HI = 10.0 # 1000% of it
|
||||
INJECTION_OFFSET_MAX_EUR_KWH = 0.20
|
||||
@@ -254,18 +83,8 @@ INJECTION_OFFSET_MAX_EUR_KWH = 0.20
|
||||
@dataclass(frozen=True)
|
||||
class _Leg:
|
||||
"""What ONE option's coefficients price, as sentences its refusals read.
|
||||
|
||||
⚠️ TARIFF-16 MADE `option` A PARAMETER RATHER THAN FORKING THE PARSER,
|
||||
exactly as TARIFF-17 made `option`/`means` parameters of
|
||||
`parse_static_profile()` rather than forking the period grammar. A second
|
||||
copy of this parser would be a second `_finite()`, a second unknown-key
|
||||
rule, a second unit table and a second `to_resolution()` - and the two
|
||||
would diverge first on the 23- and 25-hour days, where nobody looks.
|
||||
Everything that differs between the consumption leg and the injection leg
|
||||
is a STRING in this table; the arithmetic, the enumerations, the card
|
||||
comparison and the resolution rule are ONE implementation with one set of
|
||||
checks over it.
|
||||
"""
|
||||
`option` is a parameter, not a fork of the parser (TARIFF-16, as TARIFF-17 did
|
||||
for `parse_static_profile()`)."""
|
||||
|
||||
option: str
|
||||
what: str # "consumption" / "injection", for a sentence
|
||||
@@ -316,12 +135,8 @@ _LEGS = {
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormulaSpec:
|
||||
"""One contract's published arithmetic. SEVEN numbers and no code.
|
||||
|
||||
⚠️ EVERY FIELD IS A FIXED, NAMED SCALAR. There is deliberately no field
|
||||
that holds an expression, a callable, a lambda or a template, and there is
|
||||
no place to put one: `parse_price_formula()` refuses an unrecognised key.
|
||||
"""
|
||||
"""One contract's published arithmetic: SEVEN numbers and no code. Every field is
|
||||
a fixed named scalar; `parse_price_formula()` refuses an unrecognised key."""
|
||||
|
||||
index: str
|
||||
resolution: str
|
||||
@@ -332,8 +147,8 @@ class FormulaSpec:
|
||||
card_month: str = "" # "YYYY-MM", as the card is dated
|
||||
card_index_eur_per_mwh: float = None
|
||||
card_expect_incl_vat: float = None # in `units`, as the card prints it
|
||||
# TARIFF-16. Which OPTION this spec was read from, so a refusal, a log line
|
||||
# and `card_graded` name the leg the operator actually typed.
|
||||
# TARIFF-16. Which OPTION this spec was read from, so refusals name the leg
|
||||
# typed.
|
||||
option: str = "price_formula"
|
||||
|
||||
@property
|
||||
@@ -342,24 +157,14 @@ class FormulaSpec:
|
||||
|
||||
@property
|
||||
def card_graded(self) -> bool:
|
||||
"""Is there a printed estimate to grade these coefficients against?
|
||||
|
||||
⚠️ FALSE IS A REAL, SHIPPED STATE ON THE INJECTION LEG and is reported
|
||||
rather than defaulted: Eneco's injection formula is printed with no
|
||||
price beside it. Never False on `price_formula`, where `_card()`
|
||||
refuses an absent block.
|
||||
"""
|
||||
"""Is there a printed estimate to grade these coefficients against? False is a
|
||||
real state on the injection leg (Eneco prints none); never False on `price_formula`."""
|
||||
return self.card_expect_incl_vat is not None
|
||||
|
||||
@property
|
||||
def passthrough(self) -> float:
|
||||
"""The dimensionless share of the wholesale price this passes through.
|
||||
|
||||
`factor x UNITS[units] / 0.001`. 1.00 for Eneco's injection leg, 0.70
|
||||
for Aspiravi's. ⚠️ INDEX-FREE AND SIGN-FREE, which is what lets
|
||||
`implausible_injection()` bound a leg whose price legitimately goes
|
||||
below zero.
|
||||
"""
|
||||
"""The dimensionless share of the wholesale price this passes through. Index-free
|
||||
and sign-free, which lets `implausible_injection()` bound a leg that goes negative."""
|
||||
return self.factor * self.unit_to_eur_kwh / EUR_KWH_PER_EUR_MWH
|
||||
|
||||
@property
|
||||
@@ -368,46 +173,27 @@ class FormulaSpec:
|
||||
return self.offset * self.unit_to_eur_kwh
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The arithmetic. Two functions, and the SECOND one is the contract.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def in_card_units(spec: FormulaSpec, index_eur_per_mwh: float) -> float:
|
||||
"""`(factor x index + offset) x (1 + vat_pct/100)`, in the card's units.
|
||||
|
||||
This is the line the card prints, spelled exactly as the card spells it,
|
||||
and it is the only place the coefficients are combined. The card
|
||||
self-check and the price path both go through here, so a mutation to it
|
||||
cannot move the price without also moving the self-check.
|
||||
"""
|
||||
"""`(factor x index + offset) x (1 + vat_pct/100)`, in the card's units: the line
|
||||
the card prints. The only place the coefficients are combined, so the card
|
||||
check and the price path cannot diverge."""
|
||||
return ((spec.factor * float(index_eur_per_mwh) + spec.offset)
|
||||
* (1.0 + spec.vat_pct / 100.0))
|
||||
|
||||
|
||||
def price_eur_kwh(spec: FormulaSpec, index_eur_per_mwh: float) -> float:
|
||||
"""The energy+markup price for one index value, in EUR/kWh.
|
||||
|
||||
⚠️ THE UNIT CONVERSION IS THE ADD-ON'S JOB AND IT HAPPENS HERE, ONCE. The
|
||||
user types what their card prints, in the card's own units; every step
|
||||
from there to EUR/kWh is code, because those steps are exactly the ones
|
||||
that silently cost a factor of 10, 100 or 1000 when a human does them.
|
||||
"""
|
||||
"""The energy+markup price for one index value, in EUR/kWh. The unit conversion
|
||||
happens here, once: those steps are the ones that cost a factor of 10, 100 or 1000."""
|
||||
return in_card_units(spec, index_eur_per_mwh) * spec.unit_to_eur_kwh
|
||||
|
||||
|
||||
def card_discrepancy(spec: FormulaSpec) -> float:
|
||||
"""How far the coefficients land from the card's own printed estimate.
|
||||
|
||||
In the card's units, signed: positive means the formula computes MORE than
|
||||
the card prints, which is the direction an `offset` carrying network
|
||||
charges moves it.
|
||||
|
||||
⚠️ RAISES ON A SPEC WITH NO CARD RATHER THAN RETURNING 0.0 (TARIFF-16). An
|
||||
ungraded injection leg has no printed estimate, and a discrepancy of zero
|
||||
is exactly what "the coefficients reproduce the card" looks like - so a
|
||||
silent zero here would report the strongest possible result for the one
|
||||
state in which nothing was compared at all.
|
||||
"""
|
||||
"""How far the coefficients land from the card's printed estimate, in the card's
|
||||
units, signed: positive means MORE than the card prints (the direction an
|
||||
`offset` carrying network charges moves it). Raises on a spec with no card
|
||||
rather than returning 0.0 (TARIFF-16)."""
|
||||
if not spec.card_graded:
|
||||
raise FormulaInvalid(
|
||||
f"{spec.option} has no card block, so there is no printed "
|
||||
@@ -417,18 +203,10 @@ def card_discrepancy(spec: FormulaSpec) -> float:
|
||||
- spec.card_expect_incl_vat
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reading the option
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _finite(value, field: str, option: str = "price_formula") -> float:
|
||||
"""A real, finite number - or a refusal naming the field.
|
||||
|
||||
⚠️ SAME CLASS AS `_validate_raw()`'s NaN RULE, one layer earlier. A NaN
|
||||
factor makes every price NaN, every comparison False, and an arbitrage
|
||||
strategy that silently never fires; `True` is an `int` in Python and would
|
||||
price the whole day at the index.
|
||||
"""
|
||||
"""A real, finite number, or a refusal naming the field (NaN would price every hour NaN; `True` is an `int`)."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise FormulaInvalid(
|
||||
f"{option}.{field} is {value!r}, which is not a number. Write a "
|
||||
@@ -441,15 +219,9 @@ def _finite(value, field: str, option: str = "price_formula") -> float:
|
||||
|
||||
|
||||
def _card(raw, leg: _Leg = None) -> tuple:
|
||||
"""The `card` block: the estimate the coefficients are graded against.
|
||||
|
||||
⚠️ REQUIRED ON THE CONSUMPTION LEG AND OPTIONAL ON THE INJECTION ONE, and
|
||||
the asymmetry is a fact about the CARDS rather than a relaxed rule. See
|
||||
`_LEGS[EXPORT_FORMULA_OPTION].card_note`: Eneco's injection formula is
|
||||
printed with no price beside it, so on that card there is nothing to grade
|
||||
against. `("", None, None)` is the ungraded answer and
|
||||
`FormulaSpec.card_graded` is how every caller asks.
|
||||
"""
|
||||
"""The `card` block: the estimate the coefficients are graded against. Required on
|
||||
the consumption leg, optional on the injection one because Eneco prints none
|
||||
(`("", None, None)`; `FormulaSpec.card_graded`)."""
|
||||
if leg is None:
|
||||
leg = _LEGS["price_formula"]
|
||||
if raw is None:
|
||||
@@ -491,31 +263,8 @@ def _month_date(month: str):
|
||||
|
||||
|
||||
def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
"""`price_formula` -> a `FormulaSpec`, or None when unset.
|
||||
|
||||
⚠️ `option` IS TARIFF-16'S WHOLE REUSE, and it is TARIFF-17's move. It
|
||||
selects a row of `_LEGS` and nothing else: the enumerations, the
|
||||
arithmetic, `to_resolution()`, the unknown-key rule and the card
|
||||
comparison are shared, and every pre-existing call site - which passes no
|
||||
`option` at all - lands on the consumption row and reads byte-identical
|
||||
sentences. `parse_export_formula()` is a two-line wrapper, not a fork.
|
||||
|
||||
Accepts what the option can actually arrive as, exactly as
|
||||
`parse_static_profile()` does:
|
||||
|
||||
* `None` / `""` / `"null"` - unset.
|
||||
* a JSON string - what config.yaml's flat `str?` option
|
||||
carries on a Supervisor install.
|
||||
* a dict - already parsed, from a future nested
|
||||
schema, from sim/inject.py, or a check.
|
||||
|
||||
Raises `FormulaInvalid`; never returns a half-built spec.
|
||||
|
||||
⚠️ THE CARD SELF-CHECK IS PART OF PARSING, not a separate step a caller
|
||||
may forget. Both callers - validate.py's startup gate and the plugin's own
|
||||
fetch - go through this one function, so there is no route to a price that
|
||||
has not been graded against the card.
|
||||
"""
|
||||
"""`price_formula` -> a `FormulaSpec`, or None when unset. `option` selects a row of
|
||||
`_LEGS`; everything else is shared, so `parse_export_formula()` is a wrapper."""
|
||||
leg = _LEGS[option]
|
||||
if value is None:
|
||||
return None
|
||||
@@ -534,12 +283,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
f"{option} is {type(value).__name__}, not a JSON object with "
|
||||
f"{', '.join(_TOP_KEYS)}.")
|
||||
|
||||
# ⚠️ AN UNKNOWN KEY IS REFUSED, NOT IGNORED, AND THIS IS THE HALF THAT
|
||||
# KEEPS THE "NEVER A GRAMMAR" PROMISE HONEST. A future shape is a NAMED
|
||||
# field - clamp_lo, factor_negative, offset_bands - added here and
|
||||
# refusable on its own. Ignoring unknown keys would mean a user who typed
|
||||
# "expr" or "formula" gets priced on the fields they also typed, with the
|
||||
# one they believed in dropped in silence.
|
||||
# An unknown key is refused, not ignored: a future shape is a NAMED field,
|
||||
# never a grammar.
|
||||
unknown = sorted(set(value) - set(_TOP_KEYS))
|
||||
if unknown:
|
||||
raise FormulaInvalid(
|
||||
@@ -559,10 +304,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
f"{option}.resolution is {value.get('resolution')!r}. Choose one "
|
||||
f"of {list(RESOLUTIONS)}, as your tariff card says.")
|
||||
|
||||
# ⚠️ `units` IS REQUIRED AND HAS NO DEFAULT. A default here would be the
|
||||
# factor-1000 class with a friendly face: the add-on would silently assume
|
||||
# a convention the card never stated, and the card self-check would then
|
||||
# be graded in units nobody chose.
|
||||
# `units` is required with no default: a default is the factor-1000 class
|
||||
# with a friendly face.
|
||||
units = str(value.get("units", "") or "").strip()
|
||||
if units not in UNITS:
|
||||
raise FormulaInvalid(
|
||||
@@ -573,29 +316,22 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
offset = _finite(value.get("offset"), "offset", option)
|
||||
vat_pct = _finite(value.get("vat_pct"), "vat_pct", option)
|
||||
|
||||
# ⚠️ ZERO IS NOT "NO INDEXATION", IT IS A FIXED TARIFF WEARING A DYNAMIC
|
||||
# ONE'S CLOTHES. Every hour comes out at the same price, so every spread is
|
||||
# 0, arbitrage can never clear any threshold, and a strategy that never
|
||||
# fires is indistinguishable from one that is switched off (§14's warning
|
||||
# box). The add-on already has a first-class way to say "one price": point
|
||||
# the operator at it rather than let them build it by accident.
|
||||
# Zero is a fixed tariff in a dynamic one's clothes (arbitrage never
|
||||
# fires); point the operator at the first-class fixed price.
|
||||
if factor == 0.0:
|
||||
raise FormulaInvalid(
|
||||
f"{option}.factor is 0, so every hour gets the same {leg.what} "
|
||||
f"price{leg.zero_factor}. For one flat price, {leg.flat}.")
|
||||
|
||||
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
|
||||
# the dearest hour becomes the cheapest one, so the battery is told to
|
||||
# charge into the evening peak and to discharge at 03:00. That is not a
|
||||
# mispriced site, it is a site actively trading against itself.
|
||||
# A negative factor inverts the market: charge into the evening peak,
|
||||
# discharge at 03:00.
|
||||
if factor < 0.0:
|
||||
raise FormulaInvalid(
|
||||
f"{option}.factor is {factor:g}, which turns the market upside "
|
||||
f"down. {leg.neg_factor}.")
|
||||
|
||||
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
|
||||
# and some cards are already quoted VAT-inclusive. The bound exists for the
|
||||
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
|
||||
# 0 is legitimate (injection is VAT-exempt; some cards are VAT-inclusive);
|
||||
# the bound is for the 106 / 1.06 / 6 confusion.
|
||||
if not 0.0 <= vat_pct <= 30.0:
|
||||
raise FormulaInvalid(
|
||||
f"{option}.vat_pct is {vat_pct:g}. Write a percentage between 0 "
|
||||
@@ -607,13 +343,9 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
card_month=month, card_index_eur_per_mwh=card_index,
|
||||
card_expect_incl_vat=card_expect, option=option)
|
||||
|
||||
# ⚠️ THE CARD SELF-CHECK STILL RUNS WHEREVER THERE IS A CARD TO RUN IT
|
||||
# AGAINST, on either leg, with the same tolerance. What TARIFF-16 adds is
|
||||
# the honest answer for the leg where the card prints no estimate: NOT a
|
||||
# weakened comparison and NOT an estimate computed here, but two bounds on
|
||||
# the COEFFICIENTS that need no printed price - see
|
||||
# `implausible_injection()` - and a startup line saying the leg is
|
||||
# ungraded.
|
||||
# The card self-check still runs wherever there is a card, with the same
|
||||
# tolerance. Where the card prints no estimate, `implausible_injection()`
|
||||
# bounds the coefficients and a startup line says the leg is ungraded.
|
||||
if spec.card_graded:
|
||||
delta = card_discrepancy(spec)
|
||||
if abs(delta) > CARD_TOLERANCE:
|
||||
@@ -626,60 +358,15 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
|
||||
|
||||
def parse_export_formula(value):
|
||||
"""`price_export_formula` -> the INJECTION leg's `FormulaSpec`, or None.
|
||||
|
||||
TARIFF-16. THE DEFECT THIS EXISTS TO CLOSE: `price.py` modelled injection
|
||||
as ONE scalar, and the market publishes a line with four properties a
|
||||
scalar cannot hold. Verbatim off the cards:
|
||||
|
||||
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
|
||||
"Het injectietarief is dynamisch en kan per uur wijzigen"
|
||||
- identical on the January and the September 2026 cards
|
||||
ENGIE Dynamic (B x eSpot_15) - A c EUR/kWh
|
||||
Aspiravi (non-dynamic) 0,07 x Belpex - 2
|
||||
|
||||
1. ITS OWN FACTOR. Eneco uses 0,102 on consumption and 0,100 on
|
||||
injection; Aspiravi 0,07 against ~0,10 on its consumption leg. It is
|
||||
NOT the consumption `B` with a sign flip.
|
||||
2. A NEGATIVE OFFSET - `- 1,188`, `- 2`. Note that
|
||||
`price_markup_eur_kwh: float(0,1)` cannot express a negative markup
|
||||
at all, so the offset has no home on the consumption side either.
|
||||
3. VAT-EXEMPT. "Injectietarieven zijn vrijgesteld van btw", while
|
||||
consumption is at 6% - a SIX PERCENT ASYMMETRY between the buy leg
|
||||
and the sell leg, which a single scalar cannot even state.
|
||||
4. IT GOES NEGATIVE, WITH NO FLOOR. Eneco's injection price is negative
|
||||
for any BELPEX-H below 11,88 EUR/MWh, and a PV household exports
|
||||
precisely in the hours most likely to be negative. You can be charged
|
||||
to export.
|
||||
|
||||
⚠️ AND WHAT THIS DOES **NOT** AFFECT TODAY, SAID HERE SO NOBODY READS THE
|
||||
ABOVE AS A TRADING BUG. `arbitrage.py` does not read
|
||||
`export_price_eur_kwh` at all - its own comment says so, and gives §9.3's
|
||||
near-zero Belgian injection price as the reason the economically sound
|
||||
trade on this market is charge-cheap-to-displace-expensive-IMPORT. Both
|
||||
hours it compares are all-in CONSUMPTION prices. So no trade the add-on
|
||||
takes today is decided by this number, and this card is not a correction
|
||||
of a wrong trade. What it changes is what an operator is told, and what
|
||||
any future export-aware strategy would rest on: OPT-06 and any export
|
||||
logic would otherwise inherit a scalar that cannot represent the market,
|
||||
and the 6% VAT asymmetry would be invisible in the arithmetic.
|
||||
|
||||
⚠️ THE SCALAR IS NOT REPLACED. `export_price_eur_kwh()` is untouched and
|
||||
still answers for every site with a flat injection price, which is every
|
||||
site that exists today; this option ships "". That is `price.py`'s own
|
||||
prediction coming true rather than a new design - "when it does land, an
|
||||
injection curve gets a `PriceProvider` of its own and this scalar becomes
|
||||
its FIXED-product default; the seam is already the right shape for that."
|
||||
"""
|
||||
"""`price_export_formula` -> the INJECTION leg's `FormulaSpec`, or None (TARIFF-16).
|
||||
Same parser with `option` moved; the flat `export_price_eur_kwh()` still answers
|
||||
when unset. Nothing trades on it today. DECISIONS/TARIFF-16-the-injection-leg.md."""
|
||||
return parse_price_formula(value, option=EXPORT_FORMULA_OPTION)
|
||||
|
||||
|
||||
def card_refusal(spec: FormulaSpec, delta: float) -> str:
|
||||
"""The sentence an operator reads when their formula misses its own card.
|
||||
|
||||
It names both numbers and what to check. The usual causes are in DOCS.md,
|
||||
because each of these errors looks like a working tariff on the status page.
|
||||
"""
|
||||
"""The sentence an operator reads when their formula misses its own card: both
|
||||
numbers and what to check (causes are in DOCS.md)."""
|
||||
got = in_card_units(spec, spec.card_index_eur_per_mwh)
|
||||
leg = _LEGS[spec.option]
|
||||
return (
|
||||
@@ -688,53 +375,14 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
|
||||
f"{spec.card_expect_incl_vat:g}. Check {leg.card_causes}.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The two questions the PLUGIN cannot ask, because they need framework numbers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
|
||||
"""The card's own point, if its LEVEL cannot be a Belgian retail price.
|
||||
|
||||
Returns a refusal sentence or None. ⚠️ CALLED FROM validate.py AND FROM
|
||||
NOWHERE ELSE, because it reads a FRAMEWORK-owned number: a plugin that
|
||||
could see `taxes` could add it a second time, which is price.py's trap 1.
|
||||
|
||||
⚠️ THE LOW END IS GRADED ON THE ENERGY COMPONENT AND THE HIGH END ON THE
|
||||
ALL-IN FIGURE, AND THAT ASYMMETRY IS THE CHECK RATHER THAN AN OVERSIGHT.
|
||||
`price_taxes_eur_kwh` ships at 0.11736 (TARIFF-18; it was 0.06 before, and
|
||||
the argument was already decisive at that figure), which is nearly SIX
|
||||
times the low bound - so a card read a HUNDRED times too small comes out at
|
||||
0.0016 EUR/kWh of energy, is carried over the floor by the flat tax addend
|
||||
alone, and passes a band applied to the all-in total. The hundredfold error
|
||||
is precisely what this exists to catch, so the floor is applied where it
|
||||
can still see it. ⚠️ Raising the default made this trap WIDER, not
|
||||
narrower: the addend that does the carrying got bigger. The ceiling is
|
||||
applied to the all-in figure because that is the number the site is really
|
||||
billed at, and taxes only push it up.
|
||||
|
||||
⚠️ AND THE LIMIT, WITH THE CLAIM. This grades the CARD'S OWN POINT - one
|
||||
price, at the monthly index the card assumed - and not every hour of every
|
||||
day. A live EPEX hour can and should fall outside this band (a negative
|
||||
hour is the best charging opportunity of the year, and §17 keeps a
|
||||
regression case for it), so the band is deliberately NOT applied to the
|
||||
fetched series. What it catches is a formula whose whole LEVEL is wrong,
|
||||
which is the factor-of-1000 class arriving from the end
|
||||
`MAX_PLAUSIBLE_EUR_KWH` does not bound.
|
||||
|
||||
⚠️ AND IT IS THE CONSUMPTION LEG'S BAND ONLY - TARIFF-16 DELIBERATELY DID
|
||||
NOT POINT IT AT THE INJECTION LEG. Two reasons, and the second is an
|
||||
acceptance criterion rather than a preference:
|
||||
|
||||
* it adds `taxes_eur_kwh`, and `price_taxes_eur_kwh` is a CONSUMPTION
|
||||
component. Nothing is added to what a site is PAID for export, so the
|
||||
`all_in` half would be adding a number the injection leg never carries;
|
||||
* its floor is `energy >= 0.02 EUR/kWh`, and a REAL Eneco injection price
|
||||
is negative for any BELPEX-H below 11,88 EUR/MWh - the hours a PV
|
||||
household actually exports in. Applied to injection this floor would
|
||||
refuse the market, and refuse it hardest in the hours where knowing the
|
||||
number matters most. `implausible_injection()` is the injection band
|
||||
and it is deliberately NOT this function with a lower floor.
|
||||
"""
|
||||
"""The card's own point, if its LEVEL cannot be a Belgian retail price. Refusal
|
||||
sentence or None. Called from validate.py only (it reads the framework-owned
|
||||
taxes). The low end is graded on the energy component and the high end on the
|
||||
all-in figure; the consumption leg's band only. It grades the card's own point,
|
||||
not the fetched series. DECISIONS/TARIFF-16-the-injection-leg.md."""
|
||||
energy = price_eur_kwh(spec, spec.card_index_eur_per_mwh)
|
||||
all_in = energy + float(taxes_eur_kwh)
|
||||
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
|
||||
@@ -747,49 +395,10 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
|
||||
|
||||
|
||||
def implausible_injection(spec: FormulaSpec):
|
||||
"""The INJECTION leg's plausibility band. Returns a refusal or None.
|
||||
|
||||
⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, NOT REFUSED, AND NEITHER RUNG
|
||||
BELOW LOOKS AT THE SIGN OR THE LEVEL OF A PRICE. Eneco's published line
|
||||
`0,1 x BELPEX-H - 1,188` is negative for any BELPEX-H under 11,88 EUR/MWh;
|
||||
those are the midday hours a PV household exports in, so a band with a
|
||||
floor above zero - which is what `implausible_all_in()` has, for the
|
||||
consumption leg's own good reasons - would refuse the real market and
|
||||
would hide it in exactly the hours it costs money. You CAN be charged to
|
||||
export. The add-on says so rather than declining to represent it.
|
||||
|
||||
THE PROBLEM THAT LEAVES, STATED BEFORE THE SOLUTION. The consumption
|
||||
floor was not decoration: it is what catches a whole card read a hundred
|
||||
times too small, the one unit error the card self-check cannot see because
|
||||
it cancels out of a comparison between the two sides. A band that spans
|
||||
zero cannot catch it - 0.00132 EUR/kWh is inside any interval that
|
||||
contains -0.03. So the bound moves OFF the price and ONTO the
|
||||
coefficients, where it is index-free and sign-free:
|
||||
|
||||
* PASS-THROUGH. `factor x UNITS[units] / 0.001` is the dimensionless
|
||||
share of the wholesale price the contract passes through, because one
|
||||
EUR/MWh IS 0.001 EUR/kWh. Eneco's injection leg is 1.00 and Aspiravi's
|
||||
is 0.70. A factor typed 100x too small reads 0.01 and 100x too large
|
||||
reads 100, whatever the index and whatever the sign of the result.
|
||||
* OFFSET MAGNITUDE. Eneco's is 1,188 c EUR/kWh = 0.01188 EUR/kWh and
|
||||
Aspiravi's is 2 c = 0.02. A hundredfold error is 1.188 EUR/kWh, which
|
||||
is not an injection tariff in any direction. The bound is on the
|
||||
ABSOLUTE value, so a negative offset of a believable size passes and
|
||||
that is the normal case on this leg.
|
||||
|
||||
⚠️ TWO RUNGS THAT CAN BOTH ANSWER ONE STIMULUS, SO THEY ARE GRADED ON
|
||||
THEIR REASONS AND MUTATED TOGETHER (MUTATION.md, TEST-16). A whole card
|
||||
read 100x too large trips both. The fixtures in test_price.py 17d are
|
||||
built so that each rung is the ONLY one that fires on its own stimulus.
|
||||
|
||||
⚠️ AND WHAT THIS CANNOT SEE, WITH THE CLAIM (habit 13). It bounds the
|
||||
SHAPE of the coefficients, never their truth: a contract really written
|
||||
against a different index, or last year's coefficients, passes both rungs
|
||||
and reproduces any card typed beside them. Where a card DOES print an
|
||||
estimate the self-check grades the pair against it; where it does not -
|
||||
which is Eneco's injection line - these two bounds are the whole of what
|
||||
is mechanised, and validate.py says so at startup.
|
||||
"""
|
||||
"""The INJECTION leg's plausibility band. Refusal or None. A negative price is
|
||||
accepted; the bounds are on the coefficients (pass-through and offset
|
||||
magnitude), since a band spanning zero cannot catch a card 100x too small. It
|
||||
bounds the shape, not the truth. DECISIONS/TARIFF-16-the-injection-leg.md."""
|
||||
if not PASSTHROUGH_LO <= spec.passthrough <= PASSTHROUGH_HI:
|
||||
return (
|
||||
f"{spec.option}.factor passes through {spec.passthrough:.4g}x the "
|
||||
@@ -806,10 +415,7 @@ def implausible_injection(spec: FormulaSpec):
|
||||
|
||||
|
||||
def stale_card(spec: FormulaSpec, today=None):
|
||||
"""A warning when the card is older than ~6 months, or None. NEVER a
|
||||
refusal: ENGIE freezes a contract's coefficients for its duration, so an
|
||||
old card can be exactly right and refusing one would stop the add-on on a
|
||||
correctly configured site."""
|
||||
"""A warning when the card is older than ~6 months, or None. Never a refusal (ENGIE freezes coefficients)."""
|
||||
dated = _month_date(spec.card_month)
|
||||
now = today or datetime.now(timezone.utc).date()
|
||||
if dated is None or (now - dated).days <= CARD_STALE_DAYS:
|
||||
@@ -820,36 +426,14 @@ def stale_card(spec: FormulaSpec, today=None):
|
||||
f"contract changed since, copy the new figures from your supplier.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Resolution
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def to_resolution(pairs, resolution: str) -> list:
|
||||
"""`[(instant, index EUR/MWh)]` -> the series the CONTRACT is written on.
|
||||
|
||||
`quarter_hourly` hands the feed's own points back, ascending.
|
||||
`hourly` averages each hour's members into one point AT THE TOP OF THE
|
||||
HOUR.
|
||||
|
||||
⚠️ THE BUCKET IS A UTC HOUR AND THAT IS EXACT FOR BELGIUM, not an
|
||||
approximation: CET and CEST are both WHOLE-hour offsets, so a Brussels hour
|
||||
and a UTC hour start at the same instant on every day of the year including
|
||||
both transition days. Keying by a naive local hour is what collides the two
|
||||
02:00s in October; keying by the UTC instant cannot.
|
||||
|
||||
⚠️ AND THIS IS NOT `_Series.at()`'s HOURLY ASSUMPTION COMING BACK
|
||||
(TARIFF-14). It decides how many points are EMITTED, not how long one
|
||||
covers - the coverage window is still derived from the neighbours in
|
||||
`_cover_table()`. A feed that already publishes hourly points averages one
|
||||
member per hour, i.e. the identity, so this cannot silently assume the
|
||||
granularity it was handed.
|
||||
|
||||
⚠️ AN INCOMPLETE HOUR IS AVERAGED OVER WHAT IS THERE rather than dropped or
|
||||
zero-filled. A horizon that starts at 09:20 has two quarters in its first
|
||||
hour; their mean is the best statement available about that hour, and
|
||||
dropping it would leave a hole `at()` answers with `Absence` for an hour
|
||||
the feed did publish.
|
||||
"""
|
||||
`quarter_hourly` returns the feed's points, `hourly` averages each UTC hour
|
||||
(exact for Belgium: whole-hour offsets) at the top of the hour. An incomplete
|
||||
hour is averaged over what is there. It sets how many points are emitted, not
|
||||
how long one covers (TARIFF-14)."""
|
||||
ordered = sorted(pairs, key=lambda r: r[0].astimezone(timezone.utc))
|
||||
if resolution == "quarter_hourly":
|
||||
return list(ordered)
|
||||
@@ -863,28 +447,16 @@ def to_resolution(pairs, resolution: str) -> list:
|
||||
return [(k, sum(v) / len(v)) for k, v in sorted(buckets.items())]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The startup preview
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
PREVIEW_HOURS = 24
|
||||
PREVIEW_MAX_ROWS = 24
|
||||
|
||||
|
||||
def preview_lines(spec: FormulaSpec, rows) -> list:
|
||||
"""`[(instant, index EUR/MWh, all-in EUR/kWh)]` -> lines for the log.
|
||||
|
||||
THE DEFECT THIS EXISTS TO MAKE VISIBLE: every error this module refuses is
|
||||
one the operator cannot see from the status page, and the two numbers that
|
||||
would have shown it - the index that went IN and the price that came OUT -
|
||||
are never printed beside each other anywhere else. A factor of 1000 is
|
||||
obvious in one line of `61.96 -> 0.13522` and invisible in a plan.
|
||||
|
||||
⚠️ IT PRINTS A BOUNDED NUMBER OF ROWS AND SAYS SO. At `quarter_hourly` the
|
||||
next 24 h is 96 points; the cap is a row COUNT rather than "every fourth
|
||||
point", because sampling every fourth point would assume the granularity
|
||||
TARIFF-14 spent a card removing the assumption of.
|
||||
"""
|
||||
"""`[(instant, index EUR/MWh, all-in EUR/kWh)]` -> lines for the log: the index
|
||||
that went IN beside the price that came OUT. Prints a bounded row COUNT, not
|
||||
every fourth point (that would assume the granularity)."""
|
||||
if not rows:
|
||||
return []
|
||||
shown = rows[:PREVIEW_MAX_ROWS]
|
||||
|
||||
@@ -371,7 +371,8 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
|
||||
if fc is not None:
|
||||
if k == 0:
|
||||
line = hv.line_solar(fc.today.kwh, fc.today.kwh * trust,
|
||||
hv.hhmm(fc.at))
|
||||
hv.hhmm(fc.at),
|
||||
kept=getattr(fc, "kept", False))
|
||||
elif fc.tomorrow is not None:
|
||||
line = hv.day_note("tomorrow_total",
|
||||
kwh=hv._kwh(fc.tomorrow.kwh),
|
||||
|
||||
@@ -535,15 +535,29 @@ def plan_cover(c, name):
|
||||
return targets[name], max(0.0, top - floor) / 100.0 * cap * EFF
|
||||
|
||||
|
||||
def _latched_below(c) -> bool:
|
||||
"""OPT-34: no forecast, but a cheap run is on and a battery is still below
|
||||
the level its night charge keeps (`Controller.plan_targets()`), so control
|
||||
goes on charging."""
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
if rp is None or rp.cheap is None or not rp.cheap.holds(
|
||||
datetime.now(timezone.utc)):
|
||||
return False
|
||||
targets = getattr(c, "plan_targets", lambda: None)() or {}
|
||||
return any(inv.soc is not None and inv.name in targets
|
||||
and inv.soc < targets[inv.name] for inv in c.inverters)
|
||||
|
||||
|
||||
def plan_state(c):
|
||||
"""P4: `"plan"` (a day plan is made), `"learning"` (no house profile
|
||||
yet), `"no_forecast"` (set but missing or stale), or None (not set, or
|
||||
battery modes off)."""
|
||||
yet), `"no_forecast"` (set but missing or stale), `"latched"` (OPT-34:
|
||||
the same, but a night charge under way is still going on to its level),
|
||||
or None (not set, or battery modes off)."""
|
||||
if not getattr(c, "modes_on", False) or \
|
||||
getattr(c, "fc_entity", None) is None:
|
||||
return None
|
||||
if getattr(c, "solar_fc", None) is None:
|
||||
return "no_forecast"
|
||||
return "latched" if _latched_below(c) else "no_forecast"
|
||||
hp = getattr(c, "house_profile", None)
|
||||
if hp is not None and not hp.ready():
|
||||
return "learning"
|
||||
@@ -564,11 +578,12 @@ def solar_block(c, now):
|
||||
out.update(fc_kwh=round(fc.today.kwh, 1),
|
||||
count_kwh=round(fc.today.kwh * trust, 1),
|
||||
updated=hhmm(fc.at))
|
||||
if st in ("no_forecast", "learning"):
|
||||
if st in ("no_forecast", "learning", "latched"):
|
||||
out["line"] = _t("solar." + st)
|
||||
elif fc is not None:
|
||||
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
|
||||
out["updated"])
|
||||
out["updated"],
|
||||
kept=getattr(fc, "kept", False))
|
||||
dp = getattr(c, "day_plan", None)
|
||||
if st == "plan" and dp is not None and dp.uncovered_kwh >= MIN_KWH:
|
||||
out["line"] += " " + line_uncovered(dp.uncovered_kwh)
|
||||
@@ -817,9 +832,11 @@ def refusing(c, inv) -> int:
|
||||
def peak_engaged(c) -> bool:
|
||||
"""The peak guard asks the batteries for power this cycle. Not when it is
|
||||
blind or floor-limited: those are engaged with a 0 W floor (no charging
|
||||
only), so no battery gives power for it."""
|
||||
only), so no battery gives power for it. Nor when the quarter ends on
|
||||
the aim anyway (SAFETY-126, `within_aim`)."""
|
||||
pg = getattr(c, "peak_guard", None)
|
||||
return bool(pg is not None and pg.engaged and (pg.floor_w or 0.0) > 0.0)
|
||||
return bool(pg is not None and pg.engaged and (pg.floor_w or 0.0) > 0.0
|
||||
and not pg.within_aim)
|
||||
|
||||
|
||||
def battery_rows(c, shadow, running, held=False):
|
||||
@@ -1446,7 +1463,11 @@ def day_note(key, **values) -> str:
|
||||
return _t("day." + key, **values)
|
||||
|
||||
|
||||
def line_solar(fc_kwh, count_kwh, at) -> str:
|
||||
def line_solar(fc_kwh, count_kwh, at, kept=False) -> str:
|
||||
"""The forecast line. OPT-27: a reading kept past three hours (the
|
||||
evening's, used at night) says so instead of "Updated"."""
|
||||
if kept:
|
||||
return _t("solar.kept", at=at)
|
||||
return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at)
|
||||
|
||||
|
||||
@@ -1635,7 +1656,8 @@ def _samples() -> list:
|
||||
kwh="9.4 kWh")) for k in words["why"]]
|
||||
out += [("plan", today_words(k)) for k in words["today"]]
|
||||
out += [("plan", line_solar(9.2, 6.4, "10:15")),
|
||||
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning"))]
|
||||
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning")),
|
||||
("plan", _t("solar.latched"))]
|
||||
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"]]
|
||||
|
||||
+158
-12
@@ -208,9 +208,44 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
|
||||
# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and
|
||||
# how often the forecast sensors are read (they change hourly at most).
|
||||
HOUSE_PROFILE_KEY = "house_profile"
|
||||
# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an
|
||||
# evening restart keeps the figure the night plan uses.
|
||||
FC_HOLD_KEY = "solar_fc_hold"
|
||||
# OPT-34: the level of a night grid charge that has started, so it does not
|
||||
# drop when the forecast goes (`Controller._latch_charge()`).
|
||||
CHARGE_LATCH_KEY = "night_charge_latch"
|
||||
# OPT-34: a latch outlives the cheap run it was last used in by one price
|
||||
# slot, because the next run is planned at the slot edge, in the cycle after
|
||||
# the old one ended.
|
||||
CHARGE_LATCH_GRACE_S = 900.0
|
||||
FORECAST_READ_S = 60.0
|
||||
|
||||
|
||||
def _load_latch(data, now=None):
|
||||
"""`(until, {name: level %}, born)` from the store, or None if it is not a
|
||||
well-formed latch or has ended (OPT-34)."""
|
||||
try:
|
||||
until = datetime.fromisoformat(data["until"])
|
||||
born = datetime.fromisoformat(data.get("born") or data["until"])
|
||||
levels = {str(n): float(v) for n, v in data["levels"].items()}
|
||||
except (TypeError, KeyError, ValueError, AttributeError):
|
||||
return None
|
||||
if until.tzinfo is None or born.tzinfo is None or not levels or not all(
|
||||
math.isfinite(v) and 0.0 <= v <= 100.0 for v in levels.values()):
|
||||
return None
|
||||
if now is not None and now >= until:
|
||||
return None
|
||||
return until, levels, born
|
||||
|
||||
|
||||
def _night_limit(born):
|
||||
"""OPT-34: a run that starts after this cannot carry the latch on: the
|
||||
first bell start (`solarfc.BELL_FROM_H`, local) after `born`."""
|
||||
d0, d1 = solarfc._bounds(born)
|
||||
bell = solarfc._bell_start(d0)
|
||||
return bell if bell > born else solarfc._bell_start(d1)
|
||||
|
||||
|
||||
def why_raised(resolution, claims) -> bool:
|
||||
"""F2 (OPT-11 P1 test): did the limit that bound the site target push it
|
||||
ABOVE what the winner asked (the peak floor asking for more power)? Then
|
||||
@@ -947,6 +982,9 @@ class Controller:
|
||||
# SAFETY-87: the reserve alarm's hold, as SAFETY-81's above.
|
||||
self._reserve_batt_prev = None
|
||||
self._reserve_high_since = None
|
||||
# SAFETY-126: `_peak_landing()`'s last battery reading and latch.
|
||||
self._land_batt_prev = None
|
||||
self._peak_needed = False
|
||||
self._maint_shape_suspended = False
|
||||
# OPT-11. Battery modes, off by default so an install that has not
|
||||
# turned them on behaves exactly as before (SAFETY-10 section 1). Each
|
||||
@@ -995,8 +1033,14 @@ class Controller:
|
||||
self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None
|
||||
else solarfc.MISSING)
|
||||
self._fc_read_mono = None
|
||||
self.fc_hold = solarfc.Hold(
|
||||
(getattr(store, "data", None) or {}).get(FC_HOLD_KEY))
|
||||
self.day_plan = None
|
||||
self._plan_targets = None
|
||||
# OPT-34: `(until, {name: level %})` of a night charge under way.
|
||||
self._charge_latch = _load_latch(
|
||||
(getattr(store, "data", None) or {}).get(CHARGE_LATCH_KEY),
|
||||
datetime.now(timezone.utc))
|
||||
self.house_profile = None
|
||||
if self.fc_entity is not None:
|
||||
self.house_profile = dayplan.HouseProfile(
|
||||
@@ -2257,9 +2301,13 @@ class Controller:
|
||||
def weather_held(self, now) -> bool:
|
||||
"""TARIFF-24: no forecast now, but a run planned with one is under
|
||||
way: it keeps its gap to its end. The wider gap applies from the next
|
||||
run. Pure; Home asks the same (`homeview.trade_verdict()`)."""
|
||||
return (self.solar_fc is None and self._wx_free_to is not None
|
||||
and now < self._wx_free_to)
|
||||
run. OPT-34: a night charge that keeps its level (`_latch_charge()`)
|
||||
keeps the gap too, run after run, or the next slice of it would be
|
||||
refused. Pure; Home asks the same (`homeview.trade_verdict()`)."""
|
||||
return self.solar_fc is None and (
|
||||
(self._wx_free_to is not None and now < self._wx_free_to)
|
||||
or (self._charge_latch is not None
|
||||
and now < self._charge_latch[0]))
|
||||
|
||||
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
|
||||
"""The kept run plan, rebuilt at most once per price slot (15 min) or
|
||||
@@ -2344,11 +2392,73 @@ class Controller:
|
||||
|
||||
def plan_targets(self):
|
||||
"""P4: each battery's planned level, or None (no day plan). Dropped
|
||||
at once when the forecast goes (stale or missing)."""
|
||||
at once when the forecast goes (stale or missing), except the level
|
||||
of a night charge that has started (OPT-34): it keeps that level."""
|
||||
if self.solar_fc is None:
|
||||
return None
|
||||
lt = self._charge_latch
|
||||
if lt is None or datetime.now(timezone.utc) >= lt[0]:
|
||||
return None
|
||||
return dict(lt[1])
|
||||
return self._plan_targets
|
||||
|
||||
def _latch_charge(self, now, runs, targets, packs=None) -> None:
|
||||
"""OPT-34, once per cycle after `modetrade.decide()`. A night grid
|
||||
charge that has started keeps its planned level when the forecast goes
|
||||
mid-charge (the kept figure ends and the sensors are still silent).
|
||||
|
||||
While there is a forecast, fresh or kept, the latch is rebuilt each
|
||||
cycle from the plan: a battery that charges from the grid in the cheap
|
||||
run comes in with its planned level, and stays in it at whatever level
|
||||
the plan gives it, up or down as a fresh figure moves it (also when it
|
||||
has stopped, at a lowered level), until it leaves the plan. While there
|
||||
is none, `plan_targets()` gives the latched levels, so the charge goes
|
||||
on to them, and `weather_held()` keeps the narrow price gap. The latch
|
||||
lasts through every cheap run on and one price slot after the last
|
||||
(`CHARGE_LATCH_GRACE_S`), but a run is carried on only if it starts
|
||||
by the bell start of the night the latch began in (`_night_limit()`;
|
||||
the run under way then finishes). It is dropped when `now` passes its
|
||||
end whatever the run state, and a battery that is off or no longer in
|
||||
a mode with a planned level (`packs`) leaves it. A charge that never
|
||||
started has none."""
|
||||
old = lt = self._charge_latch
|
||||
tick = self.mode_tick
|
||||
run = None if runs is None else runs.cheap
|
||||
in_run = run is not None and run.holds(now)
|
||||
grace = timedelta(seconds=CHARGE_LATCH_GRACE_S)
|
||||
if lt is not None and now >= lt[0]:
|
||||
lt = None
|
||||
if lt is not None and packs is not None:
|
||||
ok = {p.name for p in packs
|
||||
if p.use and p.mode in packmode.PLAN_CHARGE_MODES}
|
||||
lv = {n: v for n, v in lt[1].items() if n in ok}
|
||||
lt = (lt[0], lv, lt[2]) if lv else None
|
||||
# A run may carry the latch on only if it starts by the night's bell.
|
||||
carry = in_run and (lt is None or run.start <= _night_limit(lt[2]))
|
||||
if in_run and self.solar_fc is not None:
|
||||
# Rebuilt from the plan: a battery already latched follows its
|
||||
# level up or down whatever its role this tick (a fresh figure may
|
||||
# lower it below the SoC), one that left the plan is dropped, and
|
||||
# a new one comes in only while it charges from the grid.
|
||||
tg = targets or {}
|
||||
lv = {n: float(tg[n]) for n in (lt[1] if lt else {}) if n in tg}
|
||||
if tick is not None and tick.action == modetrade.CHARGE:
|
||||
lv.update({n: float(tg[n]) for n, r in tick.roles.items()
|
||||
if r == modetrade.CHARGE and n in tg})
|
||||
if not lv:
|
||||
lt = None
|
||||
elif lt is None:
|
||||
lt = (run.end + grace, lv, now)
|
||||
else:
|
||||
lt = (max(run.end + grace, lt[0]) if carry else lt[0], lv,
|
||||
lt[2])
|
||||
elif lt is not None and carry:
|
||||
lt = (max(lt[0], run.end + grace), lt[1], lt[2])
|
||||
if lt != old:
|
||||
self._charge_latch = lt
|
||||
self.store.set(CHARGE_LATCH_KEY, None if lt is None else {
|
||||
"until": lt[0].isoformat(), "levels": lt[1],
|
||||
"born": lt[2].isoformat()})
|
||||
|
||||
def _mode_roles(self, now, maint_results):
|
||||
"""OPT-11 P3: each battery's role and range this tick. Grid charging
|
||||
and selling need a fresh plan that `TariffPlan.arbitrage()` allows
|
||||
@@ -2421,6 +2531,7 @@ class Controller:
|
||||
blocked=self._charge_blocked_q == quarter,
|
||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()))
|
||||
self._latch_charge(now, runs, targets, packs)
|
||||
# A charge the guard or the headroom stopped stays off this period.
|
||||
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
|
||||
# (None) in a quarter stops this tick only, so a gap cannot cost the
|
||||
@@ -2984,6 +3095,27 @@ class Controller:
|
||||
soc=min(float(p.soc) for p in able),
|
||||
soc_floor=peakguard.binding_soc_floor(able, floor))
|
||||
|
||||
def _peak_landing(self, decision):
|
||||
"""SAFETY-126: `decision` marked `within_aim` while the guard wants
|
||||
power but the batteries at 0 W would end the quarter within
|
||||
`ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). Once a real miss
|
||||
is seen, the need stands until the guard lets go. The claim and the
|
||||
commands are unchanged; only the alarms and the "Why" read it.
|
||||
DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md."""
|
||||
batt, prev = self.site_batt_w, self._land_batt_prev
|
||||
self._land_batt_prev = batt
|
||||
if not (decision.engaged and decision.blind is None
|
||||
and (decision.wanted_w or 0.0) > 0.0):
|
||||
self._peak_needed = False
|
||||
return decision
|
||||
if batt is not None and prev is not None:
|
||||
batt = min(float(batt), float(prev))
|
||||
self._peak_needed = self._peak_needed or (
|
||||
self._peak_proj is not None and peakguard.misses_aim(
|
||||
self._peak_proj, batt_w=batt, target_w=decision.target_w))
|
||||
return (decision if self._peak_needed
|
||||
else dataclasses.replace(decision, within_aim=True))
|
||||
|
||||
def _peak_reserve(self) -> frozenset:
|
||||
"""SAFETY-87: the batteries that give nothing for the peak this
|
||||
cycle: under their reserve while the others carry a peak floor."""
|
||||
@@ -3410,17 +3542,25 @@ class Controller:
|
||||
now = datetime.now(timezone.utc)
|
||||
fc, why, gaps = solarfc.read_sum(
|
||||
list(zip(self.fc_entities, states[:n])),
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now)
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now,
|
||||
hold=self.fc_hold)
|
||||
if self.fc_hold.changed:
|
||||
self.fc_hold.changed = False
|
||||
self.store.set(FC_HOLD_KEY, self.fc_hold.dump())
|
||||
# A member is named only in a sum of several; one entity reads as
|
||||
# before. Logged once per episode: on a change, never every read.
|
||||
gaps = tuple(g for g in gaps if len(
|
||||
self.fc_entities if g[0] == "today"
|
||||
else self.fc_tomorrow_entities) > 1)
|
||||
was_kept = bool(self.solar_fc is not None and self.solar_fc.kept)
|
||||
if (fc is None) != (self.solar_fc is None) \
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps:
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps \
|
||||
or bool(fc is not None and fc.kept) != was_kept:
|
||||
self.log_event(
|
||||
"solar forecast: " + ("read" if fc is not None else
|
||||
f"none ({why})")
|
||||
"solar forecast: " + (
|
||||
"none (" + str(why) + ")" if fc is None else
|
||||
f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept
|
||||
else "read")
|
||||
+ "".join(f", {d} {e} not usable ({w})"
|
||||
for d, _i, e, w in gaps),
|
||||
hint=messages.hint(
|
||||
@@ -4832,6 +4972,7 @@ class Controller:
|
||||
if self.peak_guard is not None:
|
||||
self.peak_guard = self._peak_on_able(self.peak_guard,
|
||||
maint_results)
|
||||
self.peak_guard = self._peak_landing(self.peak_guard)
|
||||
# SITE, and peakguard.claim() says so on the object it builds. The
|
||||
# quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION,
|
||||
# which is what the capacity tariff bills; there is one quarter-hour
|
||||
@@ -4856,6 +4997,7 @@ class Controller:
|
||||
self.log_event(_pg.explain(), hint=messages.hint(
|
||||
"peak_off" if not _pg.engaged else
|
||||
"peak_blind" if _pg.blind else
|
||||
"peak_on" if _pg.within_aim else
|
||||
"peak_reserve" if _pg.floor_limited else
|
||||
"peak_saturated" if _pg.saturated else "peak_on"))
|
||||
|
||||
@@ -6153,6 +6295,9 @@ class Controller:
|
||||
limited = self._reserve_short(g)
|
||||
else:
|
||||
self._reserve_batt_prev = self._reserve_high_since = None
|
||||
# SAFETY-126: nothing can give, so it alarms at once, but only
|
||||
# when the quarter really misses the aim (`_peak_landing()`).
|
||||
limited = limited and not g.within_aim
|
||||
was, self._peak_floor_limited = self._peak_floor_limited, limited
|
||||
if not limited or was:
|
||||
return
|
||||
@@ -7030,12 +7175,13 @@ class Controller:
|
||||
if _pg is None:
|
||||
out.append({"ok": True, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_waiting()})
|
||||
elif _pg.saturated:
|
||||
elif _pg.saturated and not _pg.within_aim:
|
||||
out.append({"ok": False, "warn": False, "id": "peak_guard",
|
||||
"runs": True,
|
||||
"text": messages.row_peak_saturated(_pg.ceiling_w)})
|
||||
elif _pg.floor_limited and (not (_pg.floor_w or 0.0) > 0.0
|
||||
or self._peak_floor_limited):
|
||||
elif _pg.floor_limited and (
|
||||
not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim)
|
||||
or self._peak_floor_limited):
|
||||
# SAFETY-24. NOT ok, like saturation: in both states the money
|
||||
# is going to be spent and a human is the only actuator left. A
|
||||
# separate row from saturation because the two ask for opposite
|
||||
|
||||
+105
-858
File diff suppressed because it is too large
Load Diff
+250
-1228
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,15 @@
|
||||
"""PRICE-01 - where the prices come from when the first source does not answer.
|
||||
|
||||
A provider that names `FALLBACKS` (app/price.py) is read through this module
|
||||
instead of straight from `load_prices()`. The order is fixed:
|
||||
A provider that names `FALLBACKS` (app/price.py) is read through here: first the
|
||||
provider (the owner's price server), then each id in `FALLBACKS`, then the
|
||||
prices the history store kept from the last fetch that worked. A step counts
|
||||
only if its answer holds a price for NOW (a server answering 200 with
|
||||
yesterday's prices is as useless as one that is down). Other providers never
|
||||
come here (`uses_chain()` is False).
|
||||
|
||||
1. the provider itself (the owner's price server)
|
||||
2. each id in its `FALLBACKS` (energy-charts.info)
|
||||
3. the prices the history store kept from the last fetch that worked
|
||||
|
||||
A step counts only if its answer holds a price for NOW. A server that answers
|
||||
200 with yesterday's prices is as useless to a battery as one that is down, so
|
||||
it is skipped, with the same log line.
|
||||
|
||||
Every other provider never comes here (`uses_chain()` is False for it), so
|
||||
`generic_dayahead`, `manual_csv`, `custom_formula` and `off` take the road they
|
||||
always took.
|
||||
|
||||
THE GATE. `gate_for()` is the maintenance charge rule for a site on such a
|
||||
provider: the current 15-minute price is at or below today's average. It is the
|
||||
rule the Home Assistant pair (`price_now_entity`, `price_avg_entity`) gave, read
|
||||
from the provider's own prices, so moving a site from the pair to the provider
|
||||
does not change WHEN the charge runs. DECISIONS/PRICE-01-own-price-server.md
|
||||
says how equal, and where it is not.
|
||||
"""
|
||||
`gate_for()` is the maintenance charge rule for such a site: the current
|
||||
15-minute price is at or below today's average, as the Home Assistant pair gave.
|
||||
DECISIONS/PRICE-01-own-price-server.md says how equal, and where it is not."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
@@ -74,13 +62,8 @@ class Resolved:
|
||||
|
||||
|
||||
class PriceWatch:
|
||||
"""Which step the prices come from, and one log line per change of it.
|
||||
|
||||
The line is written when the step CHANGES and not before. Nothing repeats
|
||||
while a source stays down, and nothing is written for a refresh that
|
||||
changes nothing. The reasons (a status code, a timeout) go on a DEBUG line
|
||||
beside it, with the token taken out.
|
||||
"""
|
||||
"""Which step the prices come from, and one log line per change of it (nothing
|
||||
repeats while a source stays down). Reasons go on a DEBUG line, token removed."""
|
||||
|
||||
def __init__(self):
|
||||
self.step = None
|
||||
@@ -123,20 +106,14 @@ async def resolve(opts: dict, since: datetime, until: datetime, transport,
|
||||
history, stamp: datetime, watch: PriceWatch,
|
||||
load) -> Resolved:
|
||||
"""Walk the chain and return the first step that has a price for `stamp`.
|
||||
|
||||
`load` is the caller's `load_prices`, passed in so that `static_tariff.py`
|
||||
stays the one module that fetches prices. Never raises
|
||||
for a source condition: a read that gets nowhere is a `no_data` whose
|
||||
reason names every step, so the §11.4 row applies exactly as for a single
|
||||
provider that failed.
|
||||
"""
|
||||
`load` is the caller's `load_prices`, so static_tariff.py stays the one module
|
||||
that fetches. Never raises for a source condition: `no_data` names every step."""
|
||||
pid = provider_id(opts)
|
||||
cls = PROVIDERS[pid]
|
||||
reasons = []
|
||||
for step, sid in enumerate((pid,) + tuple(cls.FALLBACKS)):
|
||||
# A fallback is asked for ITS OWN feed. The operator's feed override is
|
||||
# for the first provider and would send energy-charts' request to a
|
||||
# URL it was never meant for.
|
||||
# A fallback is asked for ITS OWN feed; the operator's override is for
|
||||
# the first provider.
|
||||
o = opts if step == 0 else dict(opts, price_provider=sid,
|
||||
price_feed_url="")
|
||||
read = await load(o, since, until, transport)
|
||||
@@ -158,8 +135,8 @@ async def resolve(opts: dict, since: datetime, until: datetime, transport,
|
||||
series=lambda r: "price_history: it has no price for now",
|
||||
no_data=lambda r: r.reason))
|
||||
watch.note(STEP_NONE, reasons, opts)
|
||||
# The reason is short on purpose: it is printed on the status page and in
|
||||
# a WARNING every refresh. What each step said is on the DEBUG line.
|
||||
# Short on purpose: it is on the status page and in a WARNING every
|
||||
# refresh.
|
||||
return Resolved(PriceRead.no_data(
|
||||
"no price source has a price for now"), STEP_NONE,
|
||||
tuple(scrub(r, opts) for r in reasons))
|
||||
@@ -171,13 +148,9 @@ async def resolve(opts: dict, since: datetime, until: datetime, transport,
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gate:
|
||||
"""What the 30 s tick takes from the standing plan.
|
||||
|
||||
`covered` is whether a published price covers now. When it is False,
|
||||
`cheap` is False and the charge waits: no price is not a cheap price.
|
||||
`since` is when the cover ended, for the status line, or None when nothing
|
||||
was ever published.
|
||||
"""
|
||||
"""What the 30 s tick takes from the standing plan. `covered` False means `cheap`
|
||||
is False and the charge waits (no price is not a cheap price). `since` is when
|
||||
the cover ended, or None."""
|
||||
|
||||
cheap: bool
|
||||
covered: bool
|
||||
@@ -189,24 +162,16 @@ NO_PLAN = Gate(False, False, None, "No prices yet")
|
||||
|
||||
|
||||
def _round5(value: float) -> float:
|
||||
# The Home Assistant template rounded the average to 5 decimals, and a
|
||||
# price to 5 as well. Both sides are compared as it did, so a quarter that
|
||||
# sits within 0.00001 EUR/kWh of the average gets the same answer.
|
||||
# The Home Assistant template rounded both sides to 5 decimals; so do we,
|
||||
# for the same answer.
|
||||
return round(float(value), 5)
|
||||
|
||||
|
||||
def gate_for(published, now: datetime) -> Gate:
|
||||
"""The charge gate for `now`, from the series the chain published.
|
||||
|
||||
THE RULE (owner's decision, PRICE-01): the current 15-minute price is at or
|
||||
below the average of today's prices, today being the Brussels day. This is
|
||||
what `now_p <= avg_p` was on the Home Assistant pair. It is NOT
|
||||
`TariffPlan.cheap_now()`, which is strictly below the mean of the firm
|
||||
hours of the whole plan, and it is not meant to be.
|
||||
|
||||
`published` is a series or None. With no price for now there is no gate
|
||||
answer: the charge waits.
|
||||
"""
|
||||
"""The charge gate for `now`, from the series the chain published: the current
|
||||
15-minute price is at or below the average of today's (Brussels day) prices,
|
||||
as `now_p <= avg_p` was on the HA pair (owner, PRICE-01). Not
|
||||
`TariffPlan.cheap_now()`. With no price for now the charge waits."""
|
||||
if published is None:
|
||||
return NO_PLAN
|
||||
if not published.covers(now):
|
||||
|
||||
@@ -1,163 +1,16 @@
|
||||
"""FCST-03 - the hours the feed has not published yet, and what may not rest on them.
|
||||
|
||||
WHERE THIS LIVES. specs.md §2.2 puts price ingest under `ems/ingest/prices/`.
|
||||
That layout does not exist and is not being built; the product owner's decision
|
||||
of 2026-08-30 is that everything ships in `goodwe_controller/app/`. The
|
||||
BEHAVIOUR §8.3 describes is unchanged and still authoritative - only the path
|
||||
moved. Read `ems/...` as `app/...`.
|
||||
|
||||
§8.3, in full, because every line below is one clause of it:
|
||||
|
||||
"Day-ahead prices arrive once daily. Until they do, unknown hours use
|
||||
yesterday's profile and are flagged `provisional: true` - and NO
|
||||
IRREVERSIBLE ACTION MAY BE TAKEN ON A PROVISIONAL PRICE. Real-time /
|
||||
imbalance products are treated as high-volatility: arbitrage thresholds
|
||||
widen."
|
||||
|
||||
Three separate things, and this module owns the first two. The third - the
|
||||
widening arithmetic - lives in price.py beside the product enum it is derived
|
||||
from (`Uncertainty`, `widen_factor`, `required_spread`), and is spent by
|
||||
`static_tariff.TariffPlan.arbitrage()`, because that is where the verdict this
|
||||
repo already refuses to hand out casually is produced. FCST-03 added a refusal
|
||||
to that existing ladder rather than a second mechanism beside it.
|
||||
|
||||
|
||||
THE DEFECT THIS MODULE IS SHAPED BY: A COPY THAT KEEPS THE WRONG FLAG
|
||||
=====================================================================
|
||||
A point filled from yesterday and a point published this morning are the SAME
|
||||
SHAPE. Three floats and a bool; only the bool tells them apart. And the natural
|
||||
way to write a carry-forward fill is:
|
||||
|
||||
PricePoint(start=t, eur_per_kwh=y.eur_per_kwh, provisional=y.provisional)
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
which copies yesterday's `False` onto today's guess and produces exactly the
|
||||
thing the ticket exists to prevent: STALE DATA PRESENTED AS CURRENT. It reads
|
||||
fine, it type-checks, it round-trips through §9.3's JSON, and every price on
|
||||
the status page looks like a price. This is the meter-side SAFETY-18 defect one
|
||||
layer up: the reading arrives on time, looks fresh, and is wrong - and a
|
||||
freshness check that only asks "did something arrive" cannot see it.
|
||||
|
||||
So the flag is not passed through anywhere in this file:
|
||||
|
||||
* A CARRIED POINT IS BUILT BY `_carried()`, WHICH HAS NO PARAMETER FOR THE
|
||||
FLAG. `provisional=True` is a literal in one place. There is no argument a
|
||||
caller can pass, no keyword to forget, and no variable that could hold
|
||||
yesterday's value at the moment the point is constructed.
|
||||
* A PUBLISHED POINT IS NEVER COPIED AT ALL. It is passed through BY IDENTITY -
|
||||
the object the provider produced is the object in the output series - so
|
||||
there is no copy that could lose or gain a flag. `filled.points[i] is
|
||||
fetched.points[j]` is a checkable property, and test_price_forecast.py
|
||||
checks it. EVERY published point in the window is passed through, not one
|
||||
per hour: see the sub-hourly note below.
|
||||
* `_audit()` RE-DERIVES the expected flag for every output point from the set
|
||||
of hours the feed actually published, and RAISES if any point disagrees.
|
||||
It is a post-condition, not a filter: it never fixes a flag, because a
|
||||
module that quietly corrects its own provenance is one whose provenance
|
||||
cannot be trusted.
|
||||
|
||||
⚠️ IT GRADES THE POINTS THAT ARE EMITTED AND NOTHING ABOUT THE ONES THAT
|
||||
ARE NOT (FCST-05). `_audit()` is a provenance check, NOT a completeness
|
||||
check: fed FCST-04's own decimation defect - four published points in an
|
||||
hour, only the last emitted - it ACCEPTS, and it accepts an empty output
|
||||
too. Measured, and the FCST-03 audit it replaced was equally blind, so
|
||||
this is a limit rather than a regression. What actually guards against a
|
||||
decimated series is the point COUNT and the pass-through IDENTITY checks
|
||||
in `test_price_forecast.py` (section on the quarter-hourly feed) and
|
||||
`test_price_history.py`; see `_audit()`'s own docstring for why the
|
||||
completeness rule is not expressible from its two arguments.
|
||||
* and test_price_forecast.py walks THIS FILE'S AST and fails if any
|
||||
`PricePoint(...)` in it passes anything but a literal for `provisional`, so
|
||||
the pass-through idiom cannot come back in a later edit and pass review by
|
||||
looking ordinary.
|
||||
|
||||
⚠️ "PUBLISHED" DOES NOT MEAN "FIRM". §9.3's canonical format carries
|
||||
`provisional` PER POINT, not per series, precisely because a real-time /
|
||||
imbalance product's current-interval print is settled after the fact and is
|
||||
genuinely provisional while it is published. Such a point is published AND
|
||||
provisional, and the audit's rule for a published point is "the flag is
|
||||
whatever the feed said", never "False". A provenance check that forced
|
||||
published points to False would be a bug that looks like a guarantee.
|
||||
|
||||
⚠️ NO SHIPPED PLUGIN EMITS ONE TODAY (TARIFF-07 withdrew the only declarer of
|
||||
`PriceProduct.REALTIME`), and that is exactly why this paragraph stays. The
|
||||
rule is a property of the FORMAT, not of any supplier; weakening it to
|
||||
"published implies False" while nothing exercises it is how the first real
|
||||
imbalance feed would arrive mispriced and flagged firm.
|
||||
|
||||
|
||||
THE FILL, AND WHY IT KEYS ON THE WALL CLOCK
|
||||
===========================================
|
||||
For every whole hour in the requested horizon that the feed has not published,
|
||||
take yesterday's value for THE SAME BRUSSELS WALL-CLOCK HOUR - not `t - 86400
|
||||
s`. On the two days a year that matter those are different instants, and the
|
||||
difference is not academic: a 23-hour day has no 02:00 at all and a 25-hour day
|
||||
has two, so a fill that subtracts a fixed 24 h shifts every carried hour by one
|
||||
across a transition and misprices the whole day in a way nothing reports.
|
||||
|
||||
On the 25-hour day the two 02:00 hours - `02:00+02:00` and `02:00+01:00` - both
|
||||
key to local hour 2 and are BOTH filled from yesterday's single 02:00 value.
|
||||
That is deliberate: they are both guesses, they are both flagged, and nothing
|
||||
irreversible may rest on either. If yesterday itself was a 25-hour day, the
|
||||
first of its two 02:00 points is the one used, deterministically.
|
||||
|
||||
An hour with no published price AND no yesterday value is LEFT OUT of the
|
||||
series. It is not filled with a neighbour, a mean, or a zero. §11.4 applies to
|
||||
that hour - `_Series.at()` raises `Absence` for it - and the caller that wanted
|
||||
a tariff for the whole horizon falls back to the static profile (TARIFF-03),
|
||||
which is the row the spec actually writes for "no price data". If NOTHING can
|
||||
be produced, the result is `PriceRead.no_data(reason)` and not an empty series,
|
||||
because an empty series is not constructible on purpose (price.py trap 3).
|
||||
|
||||
⚠️ A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR, and the hourly walk
|
||||
below is about WHICH HOURS NEED FILLING - never about what a published hour is
|
||||
allowed to contain. BELGIAN DAY-AHEAD ITSELF SETTLES PER QUARTER since the
|
||||
15-minute MTU took effect on 2025-10-01 - `generic_dayahead` reads 96 points a
|
||||
day, not 24 - `_validate_raw()` accepts any strictly-increasing spacing, and a
|
||||
published hour may therefore hold four points. So EVERY published point inside
|
||||
the window is passed through, in order, and a carried point is added only for a
|
||||
whole hour with NO published coverage at all.
|
||||
|
||||
The alternative was measured rather than reasoned about, because FCST-04's
|
||||
first caller shipped it: keying the published set as `{_utc_hour(p.start): p
|
||||
for p in r.points}` reads perfectly ordinary and keeps the LAST point of each
|
||||
hour, dropping the other three. On a 96-point quarter-hourly feed - all firm,
|
||||
fully published, nothing to carry - the series came out 15 points long, the
|
||||
arbitrage spread collapsed from 0.143 to 0.014 EUR/kWh, and the cheapest
|
||||
"hour" became a `:45` point whose `at()` window runs into the next hour. A feed
|
||||
losing 84 % of its own prints is not a windowing effect and is not something a
|
||||
provisional flag would ever have reported.
|
||||
|
||||
|
||||
AC2: THE IRREVERSIBLE-ACTION GATE
|
||||
=================================
|
||||
"The distinction should be an explicit property of the action, not a judgement
|
||||
call at each call site" - so an irreversible action is a value, `Irreversible`,
|
||||
carrying the number of hours it COMMITS. `price_permits()` looks at exactly
|
||||
those hours and answers with a `Gate` variant that carries the evidence on the
|
||||
permitting arm and a reason on the blocking one, the `_Outcome` shape price.py
|
||||
borrowed from `sim/riglock.py`.
|
||||
|
||||
The shipped example is the maintenance drain (§12): starting one commits hours
|
||||
of forced discharge that cannot be undone if the price turns out to have been
|
||||
yesterday's. Its commitment window is DERIVED from `maintenance.DRAIN_TIMEOUT_MIN`
|
||||
rather than typed here, so the gate and the phase machine cannot drift apart.
|
||||
|
||||
⚠️ WHAT DOES NOT GO THROUGH THIS GATE, AND WHY THAT MATTERS MORE THAN WHAT
|
||||
DOES. The maintenance cycle is a CALENDAR obligation (§12.2: a cycle that
|
||||
silently never fires is indistinguishable from one that is not due yet), and a
|
||||
site with `price_provider: off` - the shipped default - has no price series at
|
||||
all. Routing every maintenance start through a gate that blocks on `no_data`
|
||||
would stop the monthly cycle on every default install, permanently and
|
||||
silently: the exact failure §12 opens by naming. So `begin_phase()` is the
|
||||
entry point for a start that is being JUSTIFIED BY A PRICE, main.py's existing
|
||||
calendar path is untouched by this ticket, and OPT-04/MAINT-05 own the decision
|
||||
of which one a site runs. Nothing here disables anything that runs today.
|
||||
|
||||
Reversible actions - ordinary self-consumption modulation, the loop that
|
||||
actually holds the grid at zero - are not gated at all and cannot be: §6 says
|
||||
absent machinery must not disable the loop.
|
||||
"""
|
||||
specs.md 8.3: unknown hours use yesterday's profile and are flagged
|
||||
`provisional: true`, and no irreversible action may be taken on a provisional
|
||||
price. This module owns the fill and the gate; the widening arithmetic is in
|
||||
price.py. The flag is never passed through: a carried point is built only by
|
||||
`_carried()` (no flag parameter), a published point is passed by identity, and
|
||||
`_audit()` raises on a mismatch. The fill keys on the Brussels wall-clock hour,
|
||||
and a sub-hourly feed is never decimated. An hour with neither a published
|
||||
price nor a yesterday value is left out, never filled (`at()` raises `Absence`,
|
||||
11.4 applies). `begin_phase()` is the price-justified entry point only; the
|
||||
calendar path is untouched. All of it:
|
||||
DECISIONS/FCST-03-the-hours-not-yet-published.md."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
@@ -172,13 +25,8 @@ _LOG = logging.getLogger("goodwe.price.forecast")
|
||||
|
||||
|
||||
class Provenance(Enum):
|
||||
"""Where a point in a filled series came from. INTERNAL - §9.3's wire format
|
||||
carries the boolean and nothing else, so this never leaves the module.
|
||||
|
||||
It exists so `_audit()` has a vocabulary to state its post-condition in, and
|
||||
so the two cases have names in the log rather than being "the ones with the
|
||||
flag" and "the others".
|
||||
"""
|
||||
"""Where a point in a filled series came from. INTERNAL: the wire format carries
|
||||
only the boolean. It gives `_audit()` and the log names for the two cases."""
|
||||
PUBLISHED = "published" # the provider printed this hour. Flag: the
|
||||
# feed's own - a real-time print may be
|
||||
# published AND provisional (§9.2).
|
||||
@@ -186,9 +34,7 @@ class Provenance(Enum):
|
||||
# Flag: ALWAYS True, by construction.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The fill
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _utc_hour(instant: datetime) -> datetime:
|
||||
u = instant.astimezone(timezone.utc)
|
||||
@@ -196,16 +42,9 @@ def _utc_hour(instant: datetime) -> datetime:
|
||||
|
||||
|
||||
def _published_by_hour(read: PriceRead) -> dict:
|
||||
"""UTC hour -> the provider's OWN points that start inside it, in order.
|
||||
|
||||
⚠️ A TUPLE PER HOUR, NOT A POINT, AND THAT IS THE WHOLE OF THE SUB-HOURLY
|
||||
RULE. `{_utc_hour(p.start): p for p in r.points}` reads identically, is one
|
||||
character shorter, and silently keeps one point per hour - the last one
|
||||
written. The mapping answers two DIFFERENT questions here: "does this hour
|
||||
need filling at all?" is `u in published`, and "what does the output carry
|
||||
for it?" is all of `published[u]`. Collapsing them is how a quarter-hourly
|
||||
feed loses three quarters of its prints.
|
||||
"""
|
||||
"""UTC hour -> the provider's OWN points that start inside it, in order. A tuple
|
||||
per hour, not a point: a dict of `{hour: p}` silently keeps the last one and
|
||||
decimates a quarter-hourly feed."""
|
||||
out: dict = {}
|
||||
read.match(
|
||||
series=lambda r: [out.setdefault(_utc_hour(p.start), []).append(p)
|
||||
@@ -215,13 +54,8 @@ def _published_by_hour(read: PriceRead) -> dict:
|
||||
|
||||
|
||||
def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
|
||||
"""A point filled from yesterday's profile. §8.3: ALWAYS provisional.
|
||||
|
||||
⚠️ THERE IS NO `provisional` PARAMETER, AND THAT IS THE MECHANISM. This is
|
||||
the only constructor of a carried point, the flag is a literal, and no
|
||||
caller - including a future one editing in a hurry - has anything to pass,
|
||||
default, or copy from the point being carried.
|
||||
"""
|
||||
"""A point filled from yesterday's profile. 8.3: ALWAYS provisional. There is no
|
||||
`provisional` parameter: the flag is a literal, nothing to pass or copy."""
|
||||
return PricePoint(start=brussels(start_utc),
|
||||
eur_per_kwh=round(float(eur_per_kwh), 6),
|
||||
provisional=True)
|
||||
@@ -230,88 +64,15 @@ def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
|
||||
def _audit(points, published) -> None:
|
||||
"""Post-condition: every flag matches the point's TRUE provenance (AC1).
|
||||
|
||||
`published` maps a UTC hour to the TUPLE of points the provider produced
|
||||
for it (`_published_by_hour()`). An hour ABSENT from that mapping is an hour
|
||||
the feed did not print, and a carried point is the only thing that may
|
||||
stand in it.
|
||||
`published` maps a UTC hour to the TUPLE of points the provider produced for it.
|
||||
PUBLISHED: the point must be one of `published[u]` by identity, emitted once,
|
||||
flag whatever the feed said (never forced to False). CARRIED: the hour must not
|
||||
be in `published`, the flag exactly True, no second point in that hour.
|
||||
|
||||
The rule, in the two forms an output point can take:
|
||||
|
||||
* PUBLISHED - the point must BE one of `published[u]`, by identity, and
|
||||
may be emitted only once. Its flag is whatever the feed said, never
|
||||
forced to False: an imbalance print is published AND provisional (§9.2,
|
||||
TARIFF-02), and a check that "corrected" it would be a bug wearing a
|
||||
guarantee.
|
||||
* CARRIED - the hour must not appear in `published` AT ALL, the flag must
|
||||
be exactly True, and that hour may hold no second point.
|
||||
|
||||
⚠️ WHAT REPLACED "AT MOST ONE POINT PER HOUR", AND WHAT THAT DID AND DID
|
||||
NOT CHANGE. This audit began as FCST-03's `seen` set: any repeated
|
||||
`_utc_hour` raised, beside an identity rule `p is not src`. A bare hour
|
||||
count cannot express a quarter-hourly feed, where four distinct published
|
||||
points share one hour legitimately - so the count was replaced by the two
|
||||
rules above, which subsume it.
|
||||
|
||||
⚠️ AND IT IS NOT STRICTER. FCST-04's version of this paragraph claimed two
|
||||
strengthenings and BOTH WERE FALSE; FCST-05 measured it. A differential
|
||||
over the whole domain this audit can express - every published mask over 3
|
||||
hours crossed with every output series of length 0-3 drawn from
|
||||
{published, published-provisional, carried, carried-unflagged,
|
||||
copy-of-published} - found ZERO divergence in EITHER direction:
|
||||
|
||||
OLD raised, NEW accepted (WEAKER): 0
|
||||
OLD accepted, NEW raised (STRONGER): 0
|
||||
|
||||
The two specific claims, one at a time:
|
||||
|
||||
* "so does the same published point emitted twice" - the OLD `seen`
|
||||
count already raised on that, because emitting one point twice is one
|
||||
HOUR twice. Preserved by the identity/`emitted` rules, not added.
|
||||
* "a carried point may no longer share an hour with a published one,
|
||||
which the count never checked at all" - true of the COUNT, and the old
|
||||
IDENTITY rule raised on it anyway: a carried point in a published hour
|
||||
took the `else` branch and failed `p is not src`. The message was about
|
||||
a copy rather than about a carry, which is the only thing that moved.
|
||||
|
||||
The one real behavioural change is the intended RELAXATION - several
|
||||
distinct published points in one hour, which the old audit could not
|
||||
express at all and refused. That relaxation is why this was rewritten, and
|
||||
it stays. A right decision with a fabricated reason is still a defect here.
|
||||
|
||||
⚠️ AND THE REAL LIMIT, WHICH IS NOT A REGRESSION AND IS NOT SMALL: THIS
|
||||
AUDIT DOES NOT CHECK COMPLETENESS. It grades every point that IS emitted
|
||||
and says nothing about the ones that are not. Fed the H1 decimation
|
||||
directly - four published points in an hour, output emitting only the last
|
||||
- it ACCEPTS; so does two of four, and so does an EMPTY output. So `_audit`
|
||||
is NOT what stops H1 recurring. What stops it is the point COUNT and the
|
||||
IDENTITY checks in `test_price_history.py` and `test_price_forecast.py`,
|
||||
which compare the emitted series against the feed's own prints.
|
||||
|
||||
It is not made complete here, and the reason is structural rather than
|
||||
effort: `published` is `_published_by_hour(read)` over the WHOLE read,
|
||||
unwindowed, while `fill_from_yesterday()` emits only [since, until). A
|
||||
published point outside the window is legitimately absent, so "every
|
||||
published point appears" is not a true post-condition of these two
|
||||
arguments - it would need the window passed in, which is a different
|
||||
signature and a different check.
|
||||
|
||||
⚠️ AND THAT IS MEASURED, NOT ASSUMED. FCST-05 built the rule - raise unless
|
||||
every point of every `published[u]` is emitted, +162 B - and ran it. It
|
||||
takes the 1 h, 12 h and 48 h HORIZON checks in test_price_forecast.py RED,
|
||||
on the shipped fill and a healthy feed: at an off-the-hour instant the
|
||||
feed's already-elapsed prints are outside [since, until) and are correctly
|
||||
dropped, and the rule calls that a provenance failure. (The differential
|
||||
beside those checks scores the same rule as 49 STRONGER cases, so it is a
|
||||
real change of behaviour and not a no-op.) The rule as stated is therefore
|
||||
not merely unimplemented, it is WRONG - and stating a false post-condition
|
||||
is MUTATION.md habit 16's defect: an instrument that asserts a completeness
|
||||
it does not have is worse than no instrument.
|
||||
|
||||
⚠️ RAISES, NEVER REPAIRS. A module that silently corrects its own
|
||||
provenance is one whose provenance means nothing; the only safe response to
|
||||
"this point's flag disagrees with where it came from" is to refuse to emit
|
||||
the series at all.
|
||||
"""
|
||||
Provenance, NOT completeness: it accepts a decimated or empty output, because
|
||||
`published` is unwindowed while the fill emits [since, until). The point count
|
||||
and identity checks in the tests guard that. Raises, never repairs.
|
||||
DECISIONS/FCST-03-the-hours-not-yet-published.md."""
|
||||
carried_hours = set()
|
||||
emitted = set()
|
||||
for p in points:
|
||||
@@ -351,37 +112,12 @@ def _audit(points, published) -> None:
|
||||
def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
since: datetime, until: datetime,
|
||||
stamp: datetime) -> PriceRead:
|
||||
"""§8.3: fill the hours the feed has not published with yesterday's profile.
|
||||
"""8.3: fill the hours the feed has not published with yesterday's profile.
|
||||
|
||||
Every argument is required and keyword-only past the first. In particular
|
||||
`yesterday` has NO DEFAULT: a fill with no yesterday to fill from is a
|
||||
different situation from a fill that found nothing useful in one, and a
|
||||
default `None` would make the two arrive looking identical.
|
||||
|
||||
Returns a `PriceRead`:
|
||||
|
||||
* a series, carrying the provider's own objects - ALL of them, which on
|
||||
a quarter-hourly feed is four inside one hour - for the hours it
|
||||
published, and one `_carried()` point for each remaining whole hour,
|
||||
ordered by instant;
|
||||
* `no_data` when neither source can produce a single hour - never an
|
||||
empty series, which is not constructible (price.py trap 3).
|
||||
|
||||
⚠️ THE WINDOW IS WHOLE HOURS AT BOTH ENDS. `since` is floored to the hour
|
||||
that CONTAINS it, because the current hour is still a tradeable hour; and
|
||||
so is `until`, which is the half FCST-04's first draft left out. With
|
||||
`since` floored and `until` compared raw, a 48 h horizon asked for at 08:17
|
||||
emitted 49 hours - the hour of `until` was still `< until` - and the count
|
||||
was one too many for every `now` that is not exactly on the hour, which is
|
||||
every `now` a running controller has.
|
||||
|
||||
⚠️ IDEMPOTENT AND RE-RUNNABLE, WHICH IS THE §5.4 LATE-PUBLICATION CONTRACT.
|
||||
Day-ahead publication at 14-16 h CET can be late or fail, so the fetcher
|
||||
retries and this runs again against the fresher read. Published hours always
|
||||
win over carried ones, so a re-run REPLACES a carried point with the real
|
||||
print and the hour becomes firm - the flag and the number move together
|
||||
because the whole series is rebuilt rather than patched in place.
|
||||
"""
|
||||
`yesterday` has no default. Returns a series (every published point, by
|
||||
identity, plus one `_carried()` point per remaining whole hour) or `no_data`,
|
||||
never an empty series. The window is whole hours at both ends. Idempotent and
|
||||
re-runnable (5.4): published hours win and the series is rebuilt."""
|
||||
for name, value in (("since", since), ("until", until), ("stamp", stamp)):
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError(f"{name} must be tz-aware; a naive instant is "
|
||||
@@ -390,7 +126,7 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
published = _published_by_hour(read)
|
||||
|
||||
# Yesterday's profile, keyed by BRUSSELS WALL-CLOCK HOUR. First point wins
|
||||
# when yesterday itself was a 25-hour day and printed local hour 2 twice.
|
||||
# when yesterday itself was a 25-hour day.
|
||||
by_local_hour = {}
|
||||
yesterday.match(
|
||||
series=lambda r: [by_local_hour.setdefault(brussels(p.start).hour, p)
|
||||
@@ -424,14 +160,8 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
_audit(out, published)
|
||||
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
|
||||
if carried:
|
||||
# ⚠️ THREE QUANTITIES, THREE NAMES (TARIFF-11). This line read
|
||||
# "%d of %d hours ... ; %d published" with `carried` (a count of
|
||||
# carried HOURS - the fill emits exactly one point per unpublished
|
||||
# hour) over `len(out)` (a count of POINTS, four per hour wherever the
|
||||
# feed printed) and `len(out) - carried` (POINTS again) under the same
|
||||
# noun. On the Belgian quarter-hourly feed that reads "33 of 141
|
||||
# hours" about a 48-hour horizon. The fill's unit is the HOUR and the
|
||||
# feed's is the POINT; nothing derived from one answers for the other.
|
||||
# TARIFF-11: carried hours, total hours and published points are three
|
||||
# different quantities.
|
||||
total_h = len({_utc_hour(p.start) for p in out})
|
||||
_LOG.info("%d of %s use yesterday's prices, marked as estimated. "
|
||||
"The feed published %s over the other %s.",
|
||||
@@ -446,17 +176,12 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
return PriceRead.series(out, product, provider, stamp)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# AC2: irreversibility as a property of the ACTION, and the gate that reads it
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Irreversible:
|
||||
"""An action that cannot be undone once started, and how far it commits.
|
||||
|
||||
`commits_h` is the horizon the action ties up - the hours whose prices must
|
||||
therefore be firm before it may be justified by a price at all.
|
||||
"""
|
||||
"""An action that cannot be undone once started. `commits_h` is the horizon it
|
||||
ties up: those hours must be firm before a price may justify it."""
|
||||
|
||||
name: str # the §10.2 reason code
|
||||
commits_h: float
|
||||
@@ -473,25 +198,16 @@ class Irreversible:
|
||||
f"reversible one and does not belong here.")
|
||||
|
||||
|
||||
# ⚠️ DERIVED FROM THE PHASE MACHINE'S OWN BOUNDS, NOT TYPED HERE. maintenance.py
|
||||
# already states how long each phase may run before it is a fault; a second copy
|
||||
# of those numbers here would be a place for them to drift apart, and the drift
|
||||
# would show up as a gate that checked the wrong hours.
|
||||
# Derived from the phase machine's own bounds, not typed here, so the gate
|
||||
# cannot drift from it.
|
||||
MAINTENANCE_DRAIN = Irreversible("MAINTENANCE_DRAIN", DRAIN_TIMEOUT_MIN / 60.0)
|
||||
MAINTENANCE_CHARGE = Irreversible("MAINTENANCE_CHARGE", CHARGE_TIMEOUT_MIN / 60.0)
|
||||
|
||||
|
||||
class Gate(_Outcome):
|
||||
"""May an irreversible action proceed on the price evidence available?
|
||||
|
||||
* `permitted` - carries `.action` and `.hours`, the firm points that were
|
||||
actually checked. The evidence exists only on this arm.
|
||||
* `blocked` - carries `.reason`. Nothing else; `.hours` raises `Absence`.
|
||||
|
||||
No `__bool__`, both `match()` arms required and keyword-only, for the reason
|
||||
price.py's `_Outcome` gives: `if gate:` must not look like a guard while
|
||||
being none.
|
||||
"""
|
||||
`permitted` carries `.action` and `.hours` (the firm points checked); `blocked`
|
||||
carries `.reason` only. No `__bool__`; both `match()` arms are required."""
|
||||
|
||||
def match(self, *, permitted, blocked):
|
||||
return {"permitted": permitted, "blocked": blocked}[self.kind](self)
|
||||
@@ -541,12 +257,7 @@ class _Blocked(Gate):
|
||||
|
||||
|
||||
def committed_hours(now: datetime, action: Irreversible) -> tuple:
|
||||
"""The whole hours `action` started at `now` would run through.
|
||||
|
||||
Split out because the gate and its caller must agree on what "the hours it
|
||||
commits" means, and because a window that quietly excluded the last hour
|
||||
would be a gate that permits an action on an hour it never looked at.
|
||||
"""
|
||||
"""The whole hours `action` started at `now` would run through (the gate and its caller must agree)."""
|
||||
if not isinstance(action, Irreversible):
|
||||
raise TypeError(f"action must be an Irreversible, not {action!r}")
|
||||
if now.tzinfo is None or now.utcoffset() is None:
|
||||
@@ -562,13 +273,9 @@ def committed_hours(now: datetime, action: Irreversible) -> tuple:
|
||||
|
||||
def price_permits(read: PriceRead, now: datetime,
|
||||
action: Irreversible) -> Gate:
|
||||
"""§8.3's rule, as a value: may this irreversible action rest on this price?
|
||||
|
||||
Blocks when the read observed nothing (§11.4 - an action justified by a
|
||||
price nobody has is an action justified by nothing), when any hour it
|
||||
commits has no price at all, and when any of those hours is provisional.
|
||||
Names the offending hour, so an operator reading the log knows which one.
|
||||
"""
|
||||
"""8.3's rule as a value: may this irreversible action rest on this price?
|
||||
Blocks on no data (11.4), an hour with no price, or a provisional hour, and
|
||||
names the offending hour."""
|
||||
hours = committed_hours(now, action)
|
||||
return read.match(
|
||||
series=lambda r: _walk(r, hours, action),
|
||||
@@ -600,18 +307,10 @@ def _walk(series, hours, action) -> Gate:
|
||||
|
||||
def begin_phase(maint, now: datetime, read: PriceRead,
|
||||
action: Irreversible) -> Gate:
|
||||
"""Start a maintenance phase BECAUSE OF A PRICE, or refuse and say why.
|
||||
|
||||
The AC2 example made real: `maint.force_start(now)` - the call that moves
|
||||
the phase machine out of `idle` and commits the pack to hours of forced
|
||||
discharge - happens on the permitting arm and cannot happen on the other
|
||||
one, because the blocking arm has no code path to it.
|
||||
|
||||
⚠️ READ THE MODULE DOCSTRING'S LAST WARNING BEFORE WIRING THIS IN. This is
|
||||
the price-justified entry point, not the calendar one; a site with no price
|
||||
provider must keep starting its monthly cycle the way it does today, or
|
||||
§12's opening sentence comes true - a cycle that silently never fires.
|
||||
"""
|
||||
"""Start a maintenance phase BECAUSE OF A PRICE, or refuse and say why. The
|
||||
price-justified entry point, not the calendar one: a site with no provider must
|
||||
keep starting its monthly cycle as today (12). The blocking arm has no path to
|
||||
`maint.force_start(now)`."""
|
||||
gate = price_permits(read, now, action)
|
||||
return gate.match(permitted=lambda g: _go(maint, now, g),
|
||||
blocked=lambda g: _no(g))
|
||||
|
||||
@@ -1,185 +1,24 @@
|
||||
"""FCST-04 - the day the carry-forward carries, kept somewhere a restart cannot reach.
|
||||
"""FCST-04 - the day the carry-forward carries, kept where a restart cannot reach.
|
||||
|
||||
WHAT WAS MISSING, AND WHY IT WAS INVISIBLE. FCST-03 shipped
|
||||
`price_forecast.fill_from_yesterday()` complete and checked, and shipped the
|
||||
consumers that were written to expect it - `TariffPlan.firm_points()`,
|
||||
`cheap_now()`'s refusal of a provisional hour, and `arbitrage()`'s refusal 3,
|
||||
which drops provisional hours from the trade set instead of refusing the whole
|
||||
series. Every one of those was live. The FILL ITSELF HAD NO CALLER: nothing in
|
||||
`app/` referenced `fill_from_yesterday`, so no series ever contained a carried
|
||||
point and every one of those consumers was exercising its all-firm branch.
|
||||
`price_forecast.fill_from_yesterday()` needs a yesterday that survives a
|
||||
restart; this module is that store and the one caller of the fill. Four rules:
|
||||
|
||||
The defect that produced is not an error anywhere. `load_prices()` returns
|
||||
whatever the feed published and nothing widens it to the requested horizon, so
|
||||
on a dynamic site between midnight and the 14-16 h CET day-ahead publication
|
||||
the series covers the rest of TODAY and nothing more - typically 10-24 of the
|
||||
48 hours `price_horizon_h` asked for. `TariffPlan.arbitrage()` then computes
|
||||
`max - min` over that truncated window and reports a spread. The number is
|
||||
real, the window is half the one the operator configured, and NOTHING SAYS SO:
|
||||
tomorrow evening's peak - the hour a morning charge would be sold into - is
|
||||
simply not in the series, so the spread it would have created cannot clear a
|
||||
threshold it was never compared against. A missing hour is not a wrong price;
|
||||
it is an hour the optimiser never learns exists.
|
||||
1. It records what the PROVIDER published, never what the fill produced
|
||||
(feeding a fill back in rots the numbers behind a true `provisional` flag).
|
||||
`carry()` is the only public entry point and `_remember()` has exactly one
|
||||
caller; test_price_history.py parses every shipped module for the name and
|
||||
grades the order by driving it.
|
||||
2. "Yesterday" is the Brussels day before the instant asked about, not the
|
||||
newest record; any other date is `no_data` naming both dates.
|
||||
3. A store that cannot answer says so in the type: `yesterday()` returns a
|
||||
`PriceRead`, never an empty series.
|
||||
4. Hours accumulate within a day (merge by instant, the newer price wins), so a
|
||||
feed that serves only the remaining hours cannot overwrite a full day.
|
||||
|
||||
So this module is the half FCST-03 left out, and it is deliberately the SMALL
|
||||
half: the fill's arithmetic, its DST rule and its provenance audit all stay in
|
||||
`price_forecast.py`, untouched. What lives here is the one thing that
|
||||
arithmetic needs and could not have: A YESTERDAY THAT SURVIVES A RESTART.
|
||||
|
||||
|
||||
WHY PERSISTENCE, AND NOT A VARIABLE ON THE REFRESH TASK
|
||||
======================================================
|
||||
An in-memory "the last full day I saw" costs nothing and is wrong exactly when
|
||||
it matters. The window the fill exists for is midnight -> publication; an
|
||||
add-on restarted at 08:00 - a config change, an HA update, a power cut - would
|
||||
come up with no yesterday at all and spend that whole window on the truncated
|
||||
series, which is the pre-FCST-03 behaviour arriving silently on the mornings
|
||||
after an operator touched something. The fill's own value is a function of
|
||||
surviving a restart, so the day it carries has to.
|
||||
|
||||
⚠️ AND NOT IN `/data/state.json`. That file holds the maintenance history and
|
||||
SAFETY-02's sign latch, and `Store.set()` rewrites the whole of it on every
|
||||
write. The tariff refresh runs every `TARIFF_REFRESH_S` (1800 s), so folding a
|
||||
price cache into it would put 48 extra whole-file rewrites a day through the
|
||||
file whose corruption reads as "no maintenance ever ran" and "never tripped".
|
||||
The cache is worth having; it is not worth putting anywhere near that. Separate
|
||||
file, separate blast radius.
|
||||
|
||||
⚠️ AND THE DIRECTORY IS NOT CREATED, which is `tariff.PeakStore`'s rule and its
|
||||
reason applies unchanged: `/data` is mounted by the Supervisor and always
|
||||
exists on a real install, so `makedirs` here could only ever succeed where the
|
||||
durable volume is MISSING - manufacturing a cache on the container's ephemeral
|
||||
layer that writes fine, reads fine, and is thrown away on the next restart.
|
||||
That is a yesterday that silently forgets, which is worse than one that says it
|
||||
cannot answer.
|
||||
|
||||
⚠️ AND THAT RULE IS CARRIED OUT IN `_remember()`, NOT ONLY IN `_save()`. FCST-04
|
||||
first shipped it half-done: `_save()` short-circuited on `not self.ok` while
|
||||
`_remember()` filled `self._days` anyway, so a store with no durable volume
|
||||
answered `words` = "unavailable" and `yesterday()` = A SERIES - an in-memory
|
||||
yesterday that works all day and is gone on the next restart, which is exactly
|
||||
the silently-forgetting cache this rule exists to refuse. A store that cannot
|
||||
persist now records nothing, and both of its answers say the same thing.
|
||||
|
||||
⚠️ AND "CANNOT PERSIST" IS ONE QUESTION, ASKED ONCE, ABOUT THE OUTCOME -
|
||||
FCST-06. The paragraph above enforced that rule against `not self.ok`, which is
|
||||
one SYMPTOM of not persisting: the durable volume is missing. There is a second,
|
||||
and FCST-06 measured it. With `ok=True` and the directory present, `_save()` can
|
||||
still fail - a read-only remount (`[Errno 30] Read-only file system`), a full
|
||||
volume (`[Errno 28] No space left on device`) - and it logs, records
|
||||
`last_error` and returns. `_remember()` had already populated `self._days`, so
|
||||
`words` read `'2026-01-14 (24 points over 24 h); 2026-01-15 (24 points over
|
||||
24 h) [could not write (...)]'` while the file on disk held `{}`, and
|
||||
`yesterday()` answered 24 REAL POINTS for the rest of the process's life. Gone
|
||||
on the next restart, and nothing said it would be - the same silently-forgetting
|
||||
cache, reached by the condition the rule did not name.
|
||||
|
||||
⚠️ SO IT IS NOT TWO RULES, AND IT IS DELIBERATELY NOT TWO TESTS. "Can this
|
||||
store persist?" CANNOT BE ANSWERED BEFORE THE ATTEMPT - a directory that existed
|
||||
and was writable a moment ago is not a write that completed - so it is answered
|
||||
AFTER it, once, by the only thing holding the evidence: `_save()` returns
|
||||
whether the file now holds what memory holds. A missing volume is one of its
|
||||
False cases and an `OSError` from the write is another, and `_remember()` KEEPS
|
||||
ONLY WHAT THAT ONE ANSWER SAYS WAS KEPT. There is no `not self.ok` test in
|
||||
`_remember()` any more, because there is nothing left for a second test to add -
|
||||
and a third symptom nobody has thought of is how this defect arrived both times.
|
||||
|
||||
⚠️ AND WHAT IS KEPT IS THE LAST STATE THAT IS ON DISK, NOT AN EMPTY ONE. The
|
||||
MERGE is undone, not the store. A site whose volume fills at 14:00 goes on
|
||||
answering the yesterday it wrote at 13:30 - that record survives a restart, so
|
||||
answering it is not a promise the store cannot keep. What it stops doing is
|
||||
adding to it. The invariant the rule was always describing, stated once:
|
||||
`self._days` NEVER HOLDS A POINT THE FILE DOES NOT. (Not "mirrors the file" -
|
||||
`_usable_days()` drops records the file still holds. The only direction that can
|
||||
invent a yesterday is the one named.)
|
||||
|
||||
|
||||
THE FOUR RULES THIS FILE IS SHAPED BY
|
||||
=====================================
|
||||
|
||||
1. IT RECORDS WHAT THE PROVIDER PUBLISHED, NEVER WHAT THE FILL PRODUCED.
|
||||
Feeding a filled series back in is the defect that makes the whole mechanism
|
||||
rot: day 2 carries day 1's guesses, day 3 carries day 2's carry of them, and
|
||||
after a week of a dead feed the "yesterday" being carried forward is a
|
||||
fossil of the last real publication with nothing anywhere reporting its age.
|
||||
Every point still carries `provisional=True`, so the flag never lies - what
|
||||
decays is the NUMBER, silently, behind a flag that is telling the truth.
|
||||
|
||||
⚠️ NO VALUE-LEVEL CHECK CAN CATCH THAT, and saying so is more useful than a
|
||||
guarantee that would not hold. A carried point and a published-provisional
|
||||
point (a real-time product's current interval, §9.2) are the same three
|
||||
fields with
|
||||
the same flag; the difference is provenance, and provenance is not in the
|
||||
value. So the mechanism is ORDERING, not validation: `carry()` is the only
|
||||
public entry point, it remembers the argument and returns the fill, and the
|
||||
filled series exists only as a return value that `carry()` itself never
|
||||
sees again. `_remember()` is private, is called from exactly one place, and
|
||||
`test_price_history.py` PARSES every shipped module under `app/`, `tools/`,
|
||||
`scenarios/` and `estop/` and fails if any of them uses the NAME - an
|
||||
attribute, a bare name, a def, or the string constant a `getattr` needs. By
|
||||
name rather than by substring, so a comment about the rule does not fail it
|
||||
and a call from `tools/` cannot pass unseen.
|
||||
|
||||
⚠️ AND THE ORDER IS GRADED BY DRIVING IT, NOT BY READING THE SOURCE. The
|
||||
first version of that check compared positions in `ast.walk`, which is
|
||||
breadth-first: `self._remember(...)` is a statement and `self.yesterday(...)`
|
||||
is an ARGUMENT of the fill, one level deeper, so `_remember` came out first
|
||||
whichever statement was written first - shipped and reversed scored the
|
||||
same. What grades it now is the behaviour: a feed carrying yesterday
|
||||
alongside today, into an EMPTY store, must fill the whole horizon, and
|
||||
reversed it fills nothing.
|
||||
|
||||
2. "YESTERDAY" MEANS YESTERDAY, NOT "THE NEWEST DAY I HAVE".
|
||||
`newest()` is the natural accessor and it is the one that decays: an add-on
|
||||
down from Friday to Monday would answer Friday's prices to a fill that flags
|
||||
them as one day old. The lookup is BY DATE - the Brussels day before the
|
||||
Brussels day of the instant asked about - and a record for any other date is
|
||||
NO_RECORD with a reason naming both dates. A three-day-old profile is not a
|
||||
worse yesterday; it is not a yesterday.
|
||||
|
||||
3. A STORE THAT CANNOT ANSWER SAYS SO, IN THE TYPE.
|
||||
`yesterday()` returns a `PriceRead`: a `series` when a full record exists,
|
||||
`no_data(reason)` for every other outcome - no file, no record for that
|
||||
date, an unreadable file, a record that parses to nothing. There is no
|
||||
empty-series answer and no `.get(..., [])`-shaped accessor, for
|
||||
`price.py`'s reason: an empty series is how "the cache is broken" gets to
|
||||
wear the shape of "yesterday was free". `fill_from_yesterday()` takes a
|
||||
`PriceRead` and handles `no_data` on its own, so the degraded case needs no
|
||||
branch at the call site - it fills nothing and the horizon stays short,
|
||||
which is exactly the behaviour that shipped before this ticket.
|
||||
|
||||
4. HOURS ACCUMULATE WITHIN A DAY; THEY DO NOT REPLACE IT.
|
||||
A feed that serves only the REMAINING hours of the day (a legitimate shape -
|
||||
nothing in the plugin contract promises a whole day) would, under
|
||||
replace-the-record, overwrite a complete 24-hour profile with the two points
|
||||
left at 22:00 and hand the next morning a yesterday with two hours in it. So
|
||||
a record is MERGED by instant: a later read updates the hours it carries and
|
||||
leaves the rest, and a revised price for an hour already held wins because
|
||||
it is the newer statement about the same hour.
|
||||
|
||||
⚠️ KEYED BY INSTANT, NOT BY LOCAL HOUR, AND THAT IS THE DST DECISION. Storing
|
||||
`hour -> price` would collapse the two 02:00s of a 25-hour October day into
|
||||
one, inventing a second and different collapsing rule beside the one
|
||||
`fill_from_yesterday()` already documents ("the first of its two 02:00 points
|
||||
is the one used, deterministically"). Storing the instants keeps both, in
|
||||
order, and lets the fill's own rule be the only one in the system.
|
||||
|
||||
WHAT THIS DOES NOT DO, ON PURPOSE
|
||||
=================================
|
||||
It does not extend the fill to a feed that published NOTHING. `resolve_tariff()`
|
||||
applies `carry()` only on a `series` read; a `no_data` read still falls to
|
||||
`_degrade()` and §11.4's row - "No price data -> fall back to the static
|
||||
profile, mark the plan provisional, no arbitrage" - which is the fallback the
|
||||
operator configured `static_profile` FOR. Preferring yesterday's feed over it
|
||||
would change a documented degradation without being asked, and it would buy
|
||||
nothing measurable: a fully-carried series is 100 % provisional, so
|
||||
`arbitrage()` finds fewer than two firm hours and refuses, and `cheap_now()` is
|
||||
False, exactly as on the §11.4 fallback. The two differ only in which numbers
|
||||
the status page shows. `fill_from_yesterday()` handles a `no_data` read anyway,
|
||||
and that branch is therefore not reached from production - it stays checked,
|
||||
and turning it on is a product decision, not a wiring one. See
|
||||
DECISIONS/FCST-04-the-day-that-is-carried.md.
|
||||
"""
|
||||
Rows are keyed by instant, not local hour (the DST rule stays in the fill). A
|
||||
store that cannot persist records nothing (FCST-06). It does not extend the fill
|
||||
to a feed that published nothing: that stays 11.4's static fallback.
|
||||
DECISIONS/FCST-04-the-day-that-is-carried.md (section 5 has the store details)."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
@@ -194,89 +33,31 @@ from .price_forecast import fill_from_yesterday
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price.history")
|
||||
|
||||
# How many Brussels days of published prices the SORT-ORDER cap keeps. The fill
|
||||
# needs exactly one - yesterday - and rule 2 refuses anything older, so this is
|
||||
# not a horizon: it is the number of days a record may sit unused before
|
||||
# pruning removes it, and it is small because a bigger cache would only hold
|
||||
# days no lookup can ask for. Three covers today, yesterday, and the day a
|
||||
# fetch spanning midnight writes ahead of itself.
|
||||
#
|
||||
# ⚠️ IT IS A CAP, NOT THE RETENTION RULE, AND THREE HAS NO SLACK. The steady
|
||||
# state is already {yesterday, today, tomorrow}; a feed that publishes to D+2,
|
||||
# or ONE stray point in the wrong year - which sorts LAST and is therefore
|
||||
# immortal under a newest-N cap - pushes yesterday out of a three-slot store
|
||||
# and keeps it out, permanently and silently. So `_prune()` PINS the two days a
|
||||
# lookup can actually ask for on top of the cap; see its docstring. The store
|
||||
# is then bounded at KEEP_DAYS + 2 records rather than at KEEP_DAYS.
|
||||
# Brussels days kept: a sort-order cap, not a horizon (the fill needs only
|
||||
# yesterday).
|
||||
KEEP_DAYS = 3
|
||||
|
||||
SCHEMA = 1
|
||||
|
||||
|
||||
def _bdate(instant: datetime) -> str:
|
||||
"""The Brussels calendar date of an instant, as `YYYY-MM-DD`.
|
||||
|
||||
⚠️ BRUSSELS, NOT UTC. 23:30 UTC in July is already tomorrow in Belgium, and
|
||||
a cache keyed on the UTC date would file the last 90-120 minutes of every
|
||||
day under the wrong one - so a lookup on the following morning would find a
|
||||
"yesterday" missing its own evening hours, which are the expensive ones.
|
||||
"""
|
||||
"""The Brussels calendar date of an instant, as `YYYY-MM-DD` (not UTC: 23:30 UTC in July is tomorrow)."""
|
||||
return brussels(instant).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _yesterday_of(instant: datetime) -> str:
|
||||
"""The Brussels date of the day BEFORE `instant`'s Brussels day.
|
||||
|
||||
⚠️ THE ARITHMETIC IS ON THE LOCAL DATE, NOT ON THE INSTANT, AND THAT IS A
|
||||
DST DEFECT THIS FILE SHIPPED. `brussels()` renders at the FIXED offset in
|
||||
force at that instant, so `brussels(stamp) - timedelta(days=1)` subtracts 24
|
||||
ABSOLUTE hours from a wall clock - and the Brussels day before a
|
||||
spring-forward is 23 hours long. Measured at 2026-03-30T00:30+02:00, the
|
||||
first hour of the morning after the March transition: it returned
|
||||
2026-03-28, so the fill carried a TWO-DAY-OLD profile flagged as yesterday's
|
||||
(or, with only the last three days held, answered `no_data` naming a date
|
||||
that is not yesterday).
|
||||
|
||||
A calendar day minus one day is `date` arithmetic, and a `date` has no
|
||||
offset to lose. The 25-hour October day is right for the same reason.
|
||||
"""
|
||||
Arithmetic on the local `date`, not the instant: subtracting 24 absolute hours
|
||||
was wrong after the spring-forward. DECISIONS/FCST-04-the-day-that-is-carried.md."""
|
||||
return (brussels(instant).date() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _point_of(row):
|
||||
"""One stored row -> a `PricePoint`, or `None` if the row is not one.
|
||||
|
||||
THE ONE VALIDATOR, AND THE ASYMMETRY IT CLOSES (FCST-05's MEDIUM). This
|
||||
file used to hold TWO standards for what a stored point is. The READ path
|
||||
(`_parse_points()`) was properly defensive - every field checked, a
|
||||
malformed row dropped and reported. The WRITE path was
|
||||
`{q["start"]: q for q in raw if isinstance(q, dict) and "start" in q}`
|
||||
followed by `sorted(..., key=lambda q: q["start"])`, which trusts
|
||||
`q["start"]` to be a hashable, sortable string it never checked. Measured,
|
||||
with the malformed row under the day the feed is currently writing to:
|
||||
|
||||
start is an int -> TypeError: '<' not supported between instances
|
||||
of 'str' and 'int'
|
||||
start is a dict -> TypeError: cannot use 'dict' as a dict key
|
||||
start is null -> TypeError: '<' not supported between 'str' and
|
||||
'NoneType'
|
||||
start is a list -> TypeError: cannot use 'list' as a dict key
|
||||
|
||||
⚠️ AND THE BLAST RADIUS IS PERMANENT, WHICH IS WHY IT IS NOT MERELY UNTIDY.
|
||||
`_remember()` is reached through `carry()` -> `resolve_tariff()` ->
|
||||
`run_tariff_refresh`, which catches `Exception` - so the add-on stays UP
|
||||
and EVERY refresh raises forever. The plan retires after
|
||||
`TARIFF_MAX_AGE_S`, and arbitrage and the cheap window are off from then
|
||||
on. There is no self-heal, because `_remember()` raises before it reaches
|
||||
`_save()`. `_usable_days()` only required `rec["points"]` to be a LIST; the
|
||||
elements were unchecked on the write path, and the existing fixture put its
|
||||
malformed row under a day the feed was NOT writing to, so `_remember()`
|
||||
never touched it.
|
||||
|
||||
So both paths call this, and neither of them has a standard of its own.
|
||||
Returning `None` rather than raising is the contract: a caller decides
|
||||
whether a bad row is dropped (both of them do) and how it is reported.
|
||||
"""
|
||||
The one validator for both the read and the write path (FCST-05): a malformed
|
||||
row used to raise out of `carry()` and fail every refresh for good. Returns
|
||||
`None` rather than raising; the caller decides how a bad row is reported."""
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
try:
|
||||
@@ -293,33 +74,8 @@ def _point_of(row):
|
||||
|
||||
def _held(rec) -> tuple:
|
||||
"""`(points, whole hours they cover)` for a day record. `(0, 0)` otherwise.
|
||||
|
||||
⚠️ POINTS AND HOURS ARE TWO NUMBERS, AND FCST-04's STATUS ROW PRINTED THE
|
||||
FIRST UNDER THE SECOND'S NAME. The row was `f"{d} ({_n_points(rec)} h)"`,
|
||||
which counts POINTS: on the quarter-hourly feed this add-on already
|
||||
supports it read `'2026-01-13 (96 h)'` - 96 "h" for a 24-hour day. That is
|
||||
the points/hours conflation H1 was raised for, on the row H1's own ticket
|
||||
shipped. Both numbers are reported now, so an operator can see a day that
|
||||
is 24 points over 24 h and a day that is 96 points over 24 h and tell them
|
||||
apart, and neither number is wearing the other's unit.
|
||||
|
||||
⚠️ TOLERANT, AND IT IS THE SECOND LINE OF DEFENCE, NOT THE FIRST. `_load()`
|
||||
drops a malformed day record and says so, so this should never see one. It
|
||||
is written this way because `words` is read from `Controller.__init__` and
|
||||
from every status render: a status LINE that raises takes the add-on down,
|
||||
and "the cache is broken" must never be able to mean "the controller does
|
||||
not start". That is exactly what FCST-04 shipped - `{"days": {"2026-01-14":
|
||||
"oops"}}` gave `ok=True`, `last_error=None`, and an `AttributeError` out of
|
||||
`words`, `yesterday()` and `resolve_tariff()` alike, with no self-heal
|
||||
because `_remember()` raised before it could rewrite the file.
|
||||
|
||||
⚠️ AND IT COUNTS ONLY ROWS THAT ARE POINTS. A row `_point_of()` refuses is
|
||||
not held prices, and a row count that included it would report a store as
|
||||
healthier than it is - which is the second half of FCST-05's MEDIUM: the
|
||||
status row said `'2026-01-15 (1 h)'` about a record whose single row was
|
||||
killing every tariff refresh. What is dropped is named in `last_error`, and
|
||||
`words` prints that beside the counts.
|
||||
"""
|
||||
Two numbers, each in its own unit (96 points over 24 h on a quarter-hourly
|
||||
feed). Total, and counts only rows that are points."""
|
||||
if not isinstance(rec, dict):
|
||||
return (0, 0)
|
||||
raw = rec.get("points")
|
||||
@@ -337,15 +93,7 @@ def _held(rec) -> tuple:
|
||||
|
||||
|
||||
def _row_of(day: str, points: int, hours: int) -> str:
|
||||
"""One day's cell of the status row: what is held, in BOTH of its units.
|
||||
|
||||
⚠️ THE UNIT IS SPELT OUT ON EACH NUMBER. FCST-04's cell was
|
||||
`f"{day} ({points} h)"` - the point count wearing the hour's name, which on
|
||||
a quarter-hourly feed reads `'2026-01-13 (96 h)'` for a 24-hour day. What
|
||||
an operator needs from this row is whether tomorrow morning's fill has a
|
||||
whole day to carry, and that is the HOUR figure; the point count says how
|
||||
finely it is priced. Neither answers for the other.
|
||||
"""
|
||||
"""One day's cell of the status row, with the unit spelt out on each number."""
|
||||
# UX-10: the day as the page writes dates ("26 Sep"), not ISO.
|
||||
return (f"{points} price{'' if points == 1 else 's'} over {hours} "
|
||||
f"hour{'' if hours == 1 else 's'} for {day_words(day)}")
|
||||
@@ -373,21 +121,8 @@ def _bad_rows(rec) -> int:
|
||||
|
||||
class PriceHistory:
|
||||
"""The published price series of the last few Brussels days, in /data.
|
||||
|
||||
⚠️ `ok` IS ABOUT THE FILE, NOT ABOUT THE DATA. A healthy store with no
|
||||
record for yesterday is `ok` and answers `no_data` - a normal state on a
|
||||
site's first day, and a different thing from a store that could not be
|
||||
opened. Both are visible: `ok`/`last_error` describe the file, and
|
||||
`yesterday()`'s reason describes the answer.
|
||||
|
||||
⚠️ AND `ok` IS NOT "CAN PERSIST" (FCST-06). It says the directory was
|
||||
there when the store opened, which a full volume and a read-only remount
|
||||
both leave true. The one thing that answers "can this store persist" is
|
||||
`_save()`'s return value, and `self._days` NEVER HOLDS A POINT THE FILE DOES
|
||||
NOT, because `_remember()` undoes a merge that answer refused. That is what
|
||||
makes `words` and `yesterday()` describe the same store in EVERY degraded
|
||||
state rather than only in the ones somebody enumerated.
|
||||
"""
|
||||
`ok` is about the file, not the data: a healthy store with no record for
|
||||
yesterday is `ok` and answers `no_data`. `ok`/`last_error` describe the file."""
|
||||
|
||||
def __init__(self, path: str = "/data/price_history.json"):
|
||||
self.path = path
|
||||
@@ -396,15 +131,14 @@ class PriceHistory:
|
||||
self._days: dict = {}
|
||||
self._open()
|
||||
|
||||
# -- the file ------------------------------------------------------------
|
||||
# the file
|
||||
def _open(self) -> None:
|
||||
parent = os.path.dirname(self.path)
|
||||
# See the module note: NOT created. A cache on the ephemeral layer is
|
||||
# one that forgets without saying so.
|
||||
# NOT created (see FCST-04 section 5): a cache on the ephemeral layer
|
||||
# forgets silently.
|
||||
if parent and not os.path.isdir(parent):
|
||||
self.last_error = f"no durable volume at {parent}"
|
||||
# INFO, not ERROR: on a dev host or in a test container there is no
|
||||
# /data and nothing is wrong.
|
||||
# INFO, not ERROR: a dev host or test container has no /data.
|
||||
_LOG.info("No price history is kept (%s), so missing hours are "
|
||||
"not filled from yesterday's prices.", self.last_error)
|
||||
return
|
||||
@@ -421,32 +155,11 @@ class PriceHistory:
|
||||
"after that", self.path)
|
||||
return
|
||||
except (ValueError, OSError) as err:
|
||||
# A corrupt cache must not stop the controller, and must not be
|
||||
# silently re-created either: the record is discarded, the reason
|
||||
# is kept, and `yesterday()` reports it rather than answering
|
||||
# "no record", which would read as a normal first day.
|
||||
#
|
||||
# ⚠️ `ValueError`, NOT `json.JSONDecodeError`, AND THAT IS
|
||||
# FCST-05's HIGH. Reading this file has two failure families: the
|
||||
# BYTES (`OSError`) and the CONTENT - and the content family has at
|
||||
# least two members, because `open(..., encoding="utf-8")` decodes
|
||||
# before `json` ever sees a character. `UnicodeDecodeError` is
|
||||
# `ValueError -> UnicodeError -> UnicodeDecodeError`; it is NOT a
|
||||
# `json.JSONDecodeError` and it is NOT an `OSError`
|
||||
# (`issubclass(UnicodeDecodeError, OSError)` is False). So ONE
|
||||
# non-UTF-8 byte in /data/price_history.json escaped `_load()` ->
|
||||
# `_open()` -> `PriceHistory.__init__`, which is unguarded at
|
||||
# main.py's `Controller.__init__`, AND THE ADD-ON DID NOT START.
|
||||
# Measured: `UnicodeDecodeError: 'utf-8' codec can't decode byte
|
||||
# 0xff in position 39`. Valid-UTF-8-invalid-JSON, a UTF-8 BOM and a
|
||||
# non-dict day record all degraded correctly - it was the one byte
|
||||
# class left uncovered, which is the same class as the defect the
|
||||
# `_usable_days()` rework was raised for.
|
||||
#
|
||||
# The tuple names the FAMILY rather than the two members known
|
||||
# today, because "the file's content is not readable as JSON" is
|
||||
# exactly what this handler says, and a third `ValueError` subclass
|
||||
# nobody has thought of is how this defect arrived the first time.
|
||||
# A corrupt cache must not stop the controller nor be silently re-
|
||||
# created: the record is discarded and the reason kept, so
|
||||
# `yesterday()` reports it. `ValueError` is the family caught, not
|
||||
# `json.JSONDecodeError` (one non-UTF-8 byte raised
|
||||
# `UnicodeDecodeError` and the add-on did not start, FCST-05).
|
||||
self.last_error = f"unreadable ({err})"
|
||||
_LOG.warning("Price history at %s is %s, so it starts empty. "
|
||||
"Missing hours cannot be filled in until a full day "
|
||||
@@ -454,10 +167,7 @@ class PriceHistory:
|
||||
self.path, self.last_error)
|
||||
return
|
||||
found = blob.get("version") if isinstance(blob, dict) else None
|
||||
# ⚠️ THE TYPE IS PART OF THE VERSION. `1.0 == 1` in Python, so a float
|
||||
# written by some other tool would pass a bare `!=` and be read as
|
||||
# schema 1 - a version check that cannot tell 1 from 1.0 is one that
|
||||
# would not notice a writer this build has never met.
|
||||
# The type is part of the version: `1.0 == 1` in Python.
|
||||
if not isinstance(blob, dict) or isinstance(found, bool) \
|
||||
or not isinstance(found, int) or found != SCHEMA:
|
||||
self.last_error = f"version {found!r}, not {SCHEMA}"
|
||||
@@ -474,35 +184,8 @@ class PriceHistory:
|
||||
|
||||
def _usable_days(self, days: dict) -> dict:
|
||||
"""The day records that are shaped like day records. The rest are dropped.
|
||||
|
||||
⚠️ THE ASYMMETRY THAT PRODUCED THE DEFECT: `_parse_points()` below is
|
||||
carefully defensive about every field of a POINT, and `_load()` was not
|
||||
defensive about the record holding them at all. `{"version": 1, "days":
|
||||
{"2026-01-14": "oops"}}` therefore loaded as `ok=True`,
|
||||
`last_error=None` - and then `rec.get(...)` raised `AttributeError` out
|
||||
of `words`, `yesterday()` and `resolve_tariff()`. `PriceHistory(...)`
|
||||
and `.words` are both unguarded in `Controller.__init__`, so THE ADD-ON
|
||||
DID NOT START; and it never self-healed, because `_remember()` raised on
|
||||
the same record before it could reach `_save()`.
|
||||
|
||||
A broken cache is a short horizon. It is not a controller that will not
|
||||
regulate a battery, and §6's rule - absent machinery must not disable
|
||||
the loop - does not stop applying because the machinery is a JSON file.
|
||||
|
||||
Dropped, not repaired: an unusable record has nothing in it to keep, and
|
||||
the next `_remember()` writes a fresh one under that date.
|
||||
|
||||
⚠️ AND THE ROWS INSIDE A KEPT RECORD ARE COUNTED, NOT ONLY THE RECORD
|
||||
(FCST-05). This checked that `rec["points"]` is a LIST and stopped
|
||||
there, so a record holding one row with a `start` of `7` loaded as
|
||||
healthy: `last_error=None`, and `words` said `'2026-01-15 (1 h)'` about
|
||||
a store whose next `_remember()` would raise on that row and go on
|
||||
raising forever. The rows are NOT dropped here - `_parse_points()` is
|
||||
the read path's one filter and stays so - but they are COUNTED, so the
|
||||
store stops reporting health it does not have from the moment it is
|
||||
opened. `_remember()` drops them on the next refresh and `_save()`
|
||||
rewrites the file, after which a restart clears the note.
|
||||
"""
|
||||
A broken cache is a short horizon, never a controller that will not start.
|
||||
Rows inside a kept record are counted, not dropped (`_parse_points()` filters)."""
|
||||
kept, dropped, malformed = {}, [], {}
|
||||
for key, rec in days.items():
|
||||
if not isinstance(key, str) or not isinstance(rec, dict) \
|
||||
@@ -524,9 +207,8 @@ class PriceHistory:
|
||||
+ ", ".join(f"{day_words(d)} ({n})"
|
||||
for d, n in sorted(malformed.items())))
|
||||
if notes:
|
||||
# Recorded, not silent: `yesterday()` and `words` both report
|
||||
# `last_error`, and a cache that quietly discards half a file reads
|
||||
# to an operator exactly like a normal first day.
|
||||
# Recorded, not silent: a cache that quietly discards half a file
|
||||
# reads like a first day.
|
||||
self.last_error = " and ".join(notes)
|
||||
_LOG.warning("Price history at %s: %s. The add-on starts anyway, "
|
||||
"and fills in only what the rest holds.",
|
||||
@@ -535,27 +217,7 @@ class PriceHistory:
|
||||
|
||||
def _save(self) -> bool:
|
||||
"""Write `self._days` out. True only if the file now holds it.
|
||||
|
||||
⚠️ THE RETURN VALUE IS THE "CAN THIS STORE PERSIST?" PREDICATE, AND
|
||||
IT IS THE ONLY ONE (FCST-06). Both ways of not persisting end here and
|
||||
are the same answer: no durable volume returns False without touching
|
||||
the disk, an `OSError` out of the write returns False after recording
|
||||
why. `_remember()` acts on that one answer, so the second place that
|
||||
used to test the first symptom on its own - and missed the second - has
|
||||
nothing left to test. See the module docstring's fifth warning.
|
||||
|
||||
⚠️ AND IT REPORTS, IT DOES NOT RAISE. §6 and this module's opening: a
|
||||
broken cache must not be a broken controller. An add-on that will not
|
||||
start because /data filled up is a worse defect than the one FCST-06
|
||||
fixed, so a failed write is logged, kept in `last_error` - where `words`
|
||||
and `yesterday()` both read it - and RETURNED, never thrown.
|
||||
|
||||
⚠️ THE TEMP FILE IS REMOVED ON EVERY FAILING PATH. A write that dies
|
||||
between `mkstemp` and `os.replace` used to leave its temp behind, and
|
||||
the refresh task runs 48 times a day: a persistent write failure - a
|
||||
full volume being the likeliest cause - leaked 48 inodes a day onto the
|
||||
volume that was already the problem.
|
||||
"""
|
||||
The one "can this store persist?" answer (FCST-06), asked after the attempt."""
|
||||
if not self.ok:
|
||||
return False
|
||||
tmp = None
|
||||
@@ -563,8 +225,7 @@ class PriceHistory:
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path))
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump({"version": SCHEMA, "days": self._days}, fh, indent=1)
|
||||
# Atomic: a power cut mid-write must not leave a truncated file
|
||||
# that parses as an empty day.
|
||||
# Atomic: a power cut mid-write must not leave a truncated file.
|
||||
os.replace(tmp, self.path)
|
||||
tmp = None # it IS the file now; do not unlink
|
||||
except OSError as err:
|
||||
@@ -579,45 +240,24 @@ class PriceHistory:
|
||||
pass # failed cleanup is not a fault
|
||||
return True
|
||||
|
||||
# -- the one public entry point -----------------------------------------
|
||||
# the one public entry point
|
||||
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
|
||||
stamp: datetime, remember: bool = True) -> PriceRead:
|
||||
"""Remember what this read published, then §8.3-fill the missing hours.
|
||||
|
||||
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
|
||||
recorded FIRST, so a feed that hands back yesterday alongside today
|
||||
refreshes yesterday's record from live data before it is consumed as
|
||||
the profile; and the filled series - the one thing that must never be
|
||||
recorded - exists only as this method's return value, which this object
|
||||
never sees again. See rule 1, including how that order is graded.
|
||||
|
||||
Returns whatever `fill_from_yesterday()` returns: a series over the
|
||||
whole hours of [since, until), carrying EVERY point the provider
|
||||
published inside it - by identity, and four of them in one hour on a
|
||||
quarter-hourly feed - plus one carried point for each remaining whole
|
||||
hour FOR WHICH YESTERDAY HELD THAT WALL-CLOCK HOUR. An hour neither
|
||||
source can price is left out (§11.4 for that hour), and `no_data` comes
|
||||
back when that is every hour.
|
||||
"""
|
||||
"""Remember what this read published, then 8.3-fill the missing hours.
|
||||
Recorded FIRST, so the filled series exists only as the return value (rule 1).
|
||||
Returns `fill_from_yesterday()`'s series, or `no_data` when no hour can be priced."""
|
||||
if remember:
|
||||
# PRICE-01: False only for a read that CAME FROM this store
|
||||
# (`stored()`), which is already filed; filing it again would
|
||||
# rewrite the same file for nothing, and nothing else may pass it.
|
||||
# PRICE-01: False only for a read that came from this store,
|
||||
# already filed.
|
||||
self._remember(read, stamp)
|
||||
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
|
||||
since=since, until=until, stamp=stamp)
|
||||
|
||||
# -- the halves, private because rule 1 is an ordering ------------------
|
||||
# the halves, private because rule 1 is an ordering
|
||||
def _remember(self, read: PriceRead, stamp: datetime) -> None:
|
||||
"""File a provider read's points under their own Brussels days.
|
||||
|
||||
⚠️ PRIVATE, AND CALLED FROM EXACTLY ONE PLACE. A caller that can reach
|
||||
this can record a filled series, which is the decay in rule 1. There is
|
||||
no value-level defence against that - a carried point and a published
|
||||
provisional point are the same three fields - so the defence is that
|
||||
nothing outside this file names it, and `test_price_history.py` checks
|
||||
the tree for that rather than trusting the underscore.
|
||||
"""
|
||||
Private, called from exactly one place: no value-level defence can tell a
|
||||
carried point from a published-provisional one, so the defence is the ordering."""
|
||||
points = read.match(series=lambda r: r.points, no_data=lambda _r: ())
|
||||
if not points:
|
||||
return
|
||||
@@ -626,23 +266,9 @@ class PriceHistory:
|
||||
provider = read.match(series=lambda r: r.provider,
|
||||
no_data=lambda _r: None)
|
||||
|
||||
# ⚠️ THE STATE THIS READ STARTED FROM, KEPT SO IT CAN BE PUT BACK
|
||||
# (FCST-06). Everything below merges into `self._days` IN PLACE, and
|
||||
# whether any of it may be KEPT is not knowable until `_save()` has
|
||||
# tried - so the merge runs, and it is undone if the file did not take
|
||||
# it. There is no `not self.ok` branch above any more: a store with no
|
||||
# durable volume is one of the answers `_save()` gives, not a separate
|
||||
# symptom with a test of its own. Module docstring, fifth warning.
|
||||
#
|
||||
# ⚠️ DEEP, NOT `dict(self._days)`. A one-level copy shares the day
|
||||
# RECORDS, and the loop below rebinds keys on the ones it touches - so
|
||||
# the "previous" state would hand back records the merge had already
|
||||
# edited, and a store with a good yesterday on disk would go on
|
||||
# answering the merge of it that never reached the file. It would also
|
||||
# be right only for as long as nobody makes this loop mutate a `points`
|
||||
# list or a row in place. A copy that is right by construction outlives
|
||||
# that, and the store holds at most KEEP_DAYS + 2 records, 48 times a
|
||||
# day.
|
||||
# The state this read started from, kept so it can be put back
|
||||
# (FCST-06): the merge runs in place and is undone if `_save()` did not
|
||||
# take it. Deep copy, since the loop rebinds the day records.
|
||||
before = copy.deepcopy(self._days)
|
||||
|
||||
touched = set()
|
||||
@@ -651,29 +277,11 @@ class PriceHistory:
|
||||
day = _bdate(p.start)
|
||||
rec = self._days.setdefault(
|
||||
day, {"product": product, "provider": provider, "points": []})
|
||||
# Rule 4: MERGE by instant. A later read updates the hours it
|
||||
# carries and leaves the rest; a revised price for an hour already
|
||||
# held wins, because it is the newer statement about that hour.
|
||||
#
|
||||
# ⚠️ EVERY HELD ROW GOES THROUGH `_point_of()` (FCST-05's MEDIUM).
|
||||
# This was `{q["start"]: q for q in raw if isinstance(q, dict) and
|
||||
# "start" in q}` sorted by `q["start"]` - a key and a sort key that
|
||||
# trusted a value read off disk to be a hashable, sortable string.
|
||||
# An `int`, `null`, a `dict` or a `list` there raised `TypeError`
|
||||
# out of `carry()` on the day the feed was writing to, and
|
||||
# `run_tariff_refresh` caught it, so the add-on stayed up while
|
||||
# EVERY refresh failed for good. A row that is not a point is now
|
||||
# dropped and reported here, exactly as the read path drops it, and
|
||||
# `_save()` below writes the record back clean - which is the
|
||||
# self-heal the raising version could never reach.
|
||||
#
|
||||
# ⚠️ AND THE MERGE KEY IS THE VALIDATED INSTANT, NOT ITS SPELLING.
|
||||
# Aware datetimes hash and compare by instant, so this is rule 4 as
|
||||
# rule 4 is written. The old string key merged by SPELLING, and the
|
||||
# old lexicographic sort put the second 02:00 of the 25-hour
|
||||
# October day (`+01:00`) BEFORE the first (`+02:00`). Only the
|
||||
# stored order was affected - `_parse_points()` re-sorts by instant
|
||||
# on the way out - but the store now holds what it says it holds.
|
||||
# Rule 4: MERGE by instant; a later read updates the hours it
|
||||
# carries. Every held row goes through `_point_of()` and the key is
|
||||
# the validated instant, not its spelling (FCST-05). A row that is
|
||||
# not a point is dropped and reported, and `_save()` writes the
|
||||
# record back clean.
|
||||
raw = rec.get("points")
|
||||
held = {}
|
||||
for q in (raw if isinstance(raw, list) else ()):
|
||||
@@ -689,13 +297,8 @@ class PriceHistory:
|
||||
touched.add(day)
|
||||
|
||||
if junked:
|
||||
# Said, not swallowed. `words` prints `last_error`, so a store that
|
||||
# has just discarded rows off disk stops reporting plain health -
|
||||
# and after `_save()` below the file is clean, so a restart clears
|
||||
# this. `_save()` overwrites it if the write itself fails, which is
|
||||
# the more urgent fact of the two - and on that path the drop is
|
||||
# undone with the rest of the merge, so those rows are still in the
|
||||
# file and the next write that completes drops them again.
|
||||
# Said, not swallowed: `words` prints `last_error`. A failed
|
||||
# `_save()` overwrites it and undoes the drop.
|
||||
self.last_error = (
|
||||
f"{messages.count(sum(junked.values()), 'bad price')} dropped "
|
||||
f"from " + ", ".join(f"{day_words(d)} ({n})"
|
||||
@@ -705,48 +308,19 @@ class PriceHistory:
|
||||
self.path, self.last_error)
|
||||
self._prune(stamp)
|
||||
if not self._save():
|
||||
# ⚠️ A STORE THAT CANNOT PERSIST RECORDS NOTHING - the rule, asked
|
||||
# ONCE as a question about the outcome instead of tested twice
|
||||
# against two symptoms of it. Keeping the merge here is exactly the
|
||||
# cache the module docstring refuses: `yesterday()` would answer a
|
||||
# series the file does not contain, all day, until a restart
|
||||
# dropped it without a word - and `words` and `yesterday()` would
|
||||
# be describing two different stores. The reason is already in
|
||||
# `last_error` (from `_save()`, or from `_open()` when there is no
|
||||
# volume at all), so both answers now describe what is on disk.
|
||||
#
|
||||
# It does NOT raise, and §6 is why: a broken cache must not be a
|
||||
# broken controller. `carry()` still returns the fill, the horizon
|
||||
# is as short as the feed makes it, and the add-on regulates.
|
||||
# A store that cannot persist records nothing (FCST-06):
|
||||
# `_save()`'s answer decides what is kept, so `yesterday()` never
|
||||
# answers a series the file does not hold.
|
||||
self._days = before
|
||||
return
|
||||
_LOG.debug("price history: %d point(s) filed under %s; %d day(s) held",
|
||||
len(points), ", ".join(sorted(touched)), len(self._days))
|
||||
|
||||
def _prune(self, stamp: datetime) -> None:
|
||||
"""Drop records no lookup can reach. A sort-order cap, plus two pins.
|
||||
|
||||
⚠️ NOTHING IS DELETED BY ARITHMETIC AGAINST THE CLOCK. A clock that
|
||||
jumped - the failure mode WITNESS-03 spent a session on - would
|
||||
otherwise delete every record in one pass and leave the fill with
|
||||
nothing on the morning after, which is the one morning it is for.
|
||||
Deleting on a bad clock is unrecoverable; holding a few days too many
|
||||
is not, so the cap is `sorted(...)[-KEEP_DAYS:]` and stays that way.
|
||||
|
||||
⚠️ AND A CAP ALONE IS DEFEATED BY ONE POINT IN THE WRONG YEAR. Sorting
|
||||
is lexicographic on the date string, so a stray far-future record sorts
|
||||
LAST and is immortal - it consumes a slot for good, and with the steady
|
||||
state already {yesterday, today, tomorrow} filling all three, yesterday
|
||||
is what falls out. A feed that publishes to D+2 does the same thing
|
||||
without any stray at all. So the two days `yesterday()` can ACTUALLY be
|
||||
asked for are pinned on top of the cap.
|
||||
|
||||
`stamp` is READ here, which it was not before: the pin is the only use
|
||||
of it, and it is additive. Pinning can only RETAIN a record, never
|
||||
delete one, so a jumped `stamp` pins two dates that are not held and
|
||||
changes nothing - the WITNESS-03 property is preserved by construction
|
||||
rather than by remembering to check it.
|
||||
"""
|
||||
"""Drop records no lookup can reach: a sort-order cap plus two pins. Nothing is
|
||||
deleted by arithmetic against the clock (a jumped clock would delete everything,
|
||||
WITNESS-03). The pin of the two dates `yesterday()` can be asked for only
|
||||
retains, so a far-future record cannot push yesterday out."""
|
||||
if len(self._days) <= KEEP_DAYS:
|
||||
return
|
||||
keep = set(sorted(self._days)[-KEEP_DAYS:])
|
||||
@@ -756,11 +330,7 @@ class PriceHistory:
|
||||
|
||||
def yesterday(self, stamp: datetime) -> PriceRead:
|
||||
"""The published series of the Brussels day BEFORE `stamp`'s, or no_data.
|
||||
|
||||
⚠️ THE DATE IS COMPUTED, NOT LOOKED UP. See rule 2: `newest()` would
|
||||
answer Friday's prices to a Monday fill, and the fill would flag them as
|
||||
one day old because that is the only thing its flag can say.
|
||||
"""
|
||||
Computed, not looked up (rule 2)."""
|
||||
if stamp.tzinfo is None or stamp.utcoffset() is None:
|
||||
raise ValueError(
|
||||
f"yesterday({stamp!r}): a naive datetime is not an instant, "
|
||||
@@ -803,19 +373,9 @@ class PriceHistory:
|
||||
stamp)
|
||||
|
||||
def stored(self, stamp: datetime) -> PriceRead:
|
||||
"""PRICE-01: every price this store holds, as one series, or no_data.
|
||||
|
||||
This is the LAST step of the price server's fallback (price_chain.py):
|
||||
the answer of the last fetch that worked, kept on disk, so a site can
|
||||
plan for as long as the days it holds last. It is a read of what was
|
||||
PUBLISHED and filed, in the order they happened, and it is never the
|
||||
carried fill (rule 1 holds: `carry(..., remember=False)` is how this
|
||||
read is filled, and it is not filed again).
|
||||
|
||||
The prices are the all-in ones stored when they were fetched. They are
|
||||
not worked out again, so a change of markup or taxes shows from the
|
||||
next fetch that works, not in these.
|
||||
"""
|
||||
"""PRICE-01: every price this store holds, as one series, or no_data. The last
|
||||
step of the price server's fallback (price_chain.py): what was PUBLISHED and
|
||||
filed, never what a fill produced."""
|
||||
if stamp.tzinfo is None or stamp.utcoffset() is None:
|
||||
raise ValueError(f"stored({stamp!r}): a naive datetime is not an "
|
||||
f"instant")
|
||||
@@ -833,25 +393,12 @@ class PriceHistory:
|
||||
return PriceRead.series(points, PriceProduct.DAY_AHEAD,
|
||||
"price_history", stamp)
|
||||
|
||||
# -- observability -------------------------------------------------------
|
||||
# observability
|
||||
@property
|
||||
def words(self) -> str:
|
||||
"""One line for the status page. Says what is held, or why nothing is.
|
||||
|
||||
⚠️ THIS MUST NOT RAISE, EVER. It is read once from `Controller.__init__`
|
||||
and again on every status render, and neither call site guards it - so
|
||||
an exception here is an add-on that does not start. It went out that
|
||||
way: a day record that was a string instead of a dict raised
|
||||
`AttributeError` from the `len(...)` below. `_load()` now drops such a
|
||||
record and `_held()` is total, so this has two independent reasons not
|
||||
to fail on a broken file.
|
||||
|
||||
⚠️ AND IT COUNTED POINTS AND CALLED THEM HOURS (FCST-05's LOW). The row
|
||||
was `f"{d} ({_n_points(rec)} h)"`; on the quarter-hourly feed this
|
||||
add-on already supports that reads `'2026-01-13 (96 h)'` for a 24-hour
|
||||
day. Both numbers are printed now - see `_held()` - because they are
|
||||
two different facts and only one of them was ever being shown.
|
||||
"""
|
||||
Must not raise, ever: it runs from `Controller.__init__` and every status
|
||||
render, unguarded. Prints points and hours separately (FCST-05)."""
|
||||
if not self.ok:
|
||||
return f"unavailable - {self.last_error}"
|
||||
if not self._days:
|
||||
@@ -859,26 +406,20 @@ class PriceHistory:
|
||||
return f"empty{why} - nothing to carry forward yet"
|
||||
held = ", ".join(_row_of(d, *_held(self._days[d]))
|
||||
for d in sorted(self._days, key=str))
|
||||
# A file that PARTLY loaded says so, or the dropped days read as days
|
||||
# that were simply never written.
|
||||
# A file that partly loaded says so, or dropped days read as never
|
||||
# written.
|
||||
return held + (f" [{self.last_error}]" if self.last_error else "")
|
||||
|
||||
|
||||
def _parse_points(raw) -> tuple:
|
||||
"""Canonical dicts -> PricePoints, and a phrase naming what was dropped.
|
||||
|
||||
⚠️ A MALFORMED POINT IS DROPPED, NOT DEFAULTED, and the drop is reported.
|
||||
`float(row.get("eur_per_kwh", 0))` is the shape of the whole family of
|
||||
defects this repo has been removing: a cache that lost a field would answer
|
||||
"that hour was free" to a fill that flags it as an ordinary guess.
|
||||
"""
|
||||
"""Canonical dicts -> PricePoints, and a phrase naming what was dropped. A
|
||||
malformed point is dropped, not defaulted: a lost field must not read as a free hour."""
|
||||
if not isinstance(raw, list):
|
||||
return (), "the `points` field is not a list"
|
||||
out, dropped = [], 0
|
||||
for row in raw:
|
||||
# ⚠️ `_point_of()` IS THE ONE VALIDATOR, and this loop used to BE it.
|
||||
# The write path had its own, weaker standard; see `_point_of()` for
|
||||
# what that cost. One standard, two callers, no asymmetry to re-open.
|
||||
# `_point_of()` is the one validator; this loop used to be a weaker
|
||||
# one.
|
||||
pt = _point_of(row)
|
||||
if pt is None:
|
||||
dropped += 1
|
||||
@@ -889,20 +430,8 @@ def _parse_points(raw) -> tuple:
|
||||
|
||||
|
||||
class NoHistory:
|
||||
"""The explicit "this caller keeps no history" - a NAME, not a `None`.
|
||||
|
||||
⚠️ IT EXISTS SO THE PARAMETER CAN BE REQUIRED. `history=None` meaning "skip
|
||||
the fill" is the shape this repo keeps removing: a caller who forgets the
|
||||
argument gets the truncated-horizon behaviour of the bug this ticket is
|
||||
about, silently, and the code reads as though the fill is happening. With
|
||||
no default there is nothing to forget, and a caller with no durable volume
|
||||
- a check, a replay harness - has to say `NoHistory()` and be visible in
|
||||
the diff.
|
||||
|
||||
It carries out the ordering faithfully: it records nothing and carries
|
||||
nothing, so `carry()` returns the provider's read unchanged and the plan is
|
||||
exactly what it was before FCST-04.
|
||||
"""
|
||||
"""The explicit "this caller keeps no history": a NAME, not `None`, so the
|
||||
parameter can be required. It records nothing and carries nothing."""
|
||||
|
||||
ok = False
|
||||
last_error = "no history is kept by this caller"
|
||||
|
||||
@@ -28,6 +28,20 @@ A reading older than `FRESH_S`, or from before local midnight, is stale,
|
||||
and a stale or missing forecast is no forecast: the day plan is off and
|
||||
every mode acts as in P3 option A.
|
||||
|
||||
OPT-27: but the last fresh reading of each sensor is kept (`Hold`), so the
|
||||
night plan is not made blind. Forecast.Solar's sensors stop changing in the
|
||||
evening and api.forecast.solar is sometimes out for a poll. A kept reading
|
||||
stands in for its sensor (a) for up to `FRESH_S` when the sensor is missing
|
||||
or unavailable, as a sensor that merely stopped updating is already
|
||||
tolerated, and (b) from the end of the bell (`BELL_TO_H`) until local
|
||||
midnight, if it is at most `KEPT_MAX_AGE_S` old when used.
|
||||
Never beyond that, and never for another day: a today figure is only today's,
|
||||
a tomorrow figure is tomorrow's until midnight and then, until the bell starts
|
||||
(`BELL_FROM_H`, 06:00 local), today's (OPT-34: at most `KEPT_MORNING_MAX_AGE_S`
|
||||
old, and it must have been usable at midnight).
|
||||
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md,
|
||||
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
|
||||
|
||||
OPT-17: each day may instead name a list (`pv_forecast_entities`,
|
||||
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
|
||||
not usable makes that day's sum unusable, never a partial sum.
|
||||
@@ -50,6 +64,19 @@ FRESH_S = 3 * 3600.0
|
||||
# The bell a state-only forecast is spread over, in local hours.
|
||||
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
|
||||
|
||||
# OPT-27: a kept reading serves the night plan from the end of the bell until
|
||||
# local midnight, and a reading older than this when used is not kept (a
|
||||
# morning figure must not drive the night).
|
||||
KEPT_MAX_AGE_S = 12 * 3600.0
|
||||
# OPT-34: the evening's "tomorrow" figure, once midnight has passed, is today's
|
||||
# until the bell starts (`BELL_FROM_H`): nothing has been produced yet, so it
|
||||
# is still the whole day. A 17:05 reading is 12 h 55 min old at 06:00, so this
|
||||
# case may be older than `KEPT_MAX_AGE_S`, up to this, but only a figure that
|
||||
# was usable at midnight (at most `KEPT_MAX_AGE_S` old then), so one that has
|
||||
# not been used all evening does not come back at 00:00.
|
||||
KEPT_MORNING_MAX_AGE_S = 15 * 3600.0
|
||||
SAVE_EVERY_S = 900.0
|
||||
|
||||
# Why there is no forecast (the page words are in ui_text `home_words`).
|
||||
NOT_SET, MISSING, UNIT, STALE, BAD = (
|
||||
"not_set", "missing", "unit", "stale", "bad")
|
||||
@@ -75,6 +102,10 @@ class Forecast:
|
||||
today: Day
|
||||
tomorrow: Day | None
|
||||
at: datetime # when the sensor last reported
|
||||
# OPT-27: a reading kept past `FRESH_S` is in it (the evening's figure at
|
||||
# night). The screens say so. Last, so `Forecast(today, tomorrow, at)` is
|
||||
# as before.
|
||||
kept: bool = False
|
||||
|
||||
def between(self, t0: datetime, t1: datetime,
|
||||
since: datetime | None = None) -> float | None:
|
||||
@@ -246,6 +277,112 @@ def _reported(state):
|
||||
return max(ts) if ts else None
|
||||
|
||||
|
||||
class Hold:
|
||||
"""OPT-27: the last fresh reading of each forecast sensor, per day it is
|
||||
for. `read_sum()` remembers every fresh reading here and, when a sensor
|
||||
has none now, asks it (`stand_in()`).
|
||||
|
||||
Keyed `(kind, entity, day0)`: `kind` is "today" (energy still to come on
|
||||
`day0`) or "tomorrow" (all of `day0`), and `day0` is the day the figure is
|
||||
FOR. A today figure therefore never answers for another day, and a
|
||||
tomorrow figure answers for its own day only: tomorrow until midnight,
|
||||
then today until the bell starts (OPT-34). Only the newest reading of a
|
||||
key is kept, so the next fresh reading ends the hold at once.
|
||||
|
||||
`dump()` / `load()` are what the controller keeps in its store, so an
|
||||
evening restart does not lose it (`load()` drops what is bad).
|
||||
`changed` is set when `dump()` would differ."""
|
||||
|
||||
def __init__(self, data=None):
|
||||
self._r = {}
|
||||
self._saved = {} # key -> the `at` last flagged for saving
|
||||
self.changed = False
|
||||
if isinstance(data, dict):
|
||||
self.load(data)
|
||||
|
||||
def remember(self, kind, entity, day: "Day", at: datetime) -> None:
|
||||
key = (kind, entity, day.day0)
|
||||
old = self._r.get(key)
|
||||
if old is not None and at <= old[1]:
|
||||
return
|
||||
self._r[key] = (day, at)
|
||||
# A sensor that reports every minute with the same figure must not
|
||||
# rewrite the store every minute: the saved age may lag by
|
||||
# `SAVE_EVERY_S` (the bounds are hours).
|
||||
mark = self._saved.get(key)
|
||||
if old is None or old[0].kwh != day.kwh or mark is None \
|
||||
or (at - mark).total_seconds() >= SAVE_EVERY_S:
|
||||
self._saved[key] = at
|
||||
self.changed = True
|
||||
|
||||
def forget_before(self, day0: datetime) -> None:
|
||||
"""Drop what is for a day before `day0` (the local midnight now)."""
|
||||
gone = [k for k in self._r if k[2] < day0]
|
||||
for k in gone:
|
||||
del self._r[k]
|
||||
self._saved.pop(k, None)
|
||||
if gone:
|
||||
self.changed = True
|
||||
|
||||
def stand_in(self, kind, entity, day0, now, *, as_today=False):
|
||||
"""`(Day, at, kept)` for a sensor that has no fresh reading, or None.
|
||||
|
||||
The kept reading must be for `day0`, and either (a) at most `FRESH_S`
|
||||
old, or (b) used at night: from the end of the bell to local midnight,
|
||||
at most `KEPT_MAX_AGE_S` old. `kept` is true when (b) was needed,
|
||||
or `as_today` (OPT-34: a tomorrow figure standing in for today, after
|
||||
midnight and until the bell starts, `KEPT_MORNING_MAX_AGE_S`)."""
|
||||
rec = self._r.get((kind, entity, day0))
|
||||
if rec is None:
|
||||
return None
|
||||
day, at = rec
|
||||
age = (now - at).total_seconds()
|
||||
if age <= FRESH_S and not as_today:
|
||||
return day, at, False
|
||||
if as_today:
|
||||
ok = (day0 <= now < _bell_start(day0)
|
||||
and age <= KEPT_MORNING_MAX_AGE_S
|
||||
and (day0 - at).total_seconds() <= KEPT_MAX_AGE_S)
|
||||
return (day, at, True) if ok else None
|
||||
if age > KEPT_MAX_AGE_S:
|
||||
return None
|
||||
# A tomorrow sensor is asked with day0 = the next midnight, which is
|
||||
# in the future all day, so the evening ends at that day's end.
|
||||
evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1]
|
||||
return (day, at, True) if evening else None
|
||||
|
||||
def dump(self) -> dict:
|
||||
return {"v": 1, "r": [
|
||||
[kind, ent, d.day0.isoformat(), d.kwh, at.isoformat(),
|
||||
[[s.isoformat(), e.isoformat(), k] for s, e, k in d.periods]]
|
||||
for (kind, ent, _d0), (d, at) in sorted(
|
||||
self._r.items(), key=lambda kv: (kv[0][0], kv[0][1],
|
||||
kv[0][2]))]}
|
||||
|
||||
def load(self, data: dict) -> None:
|
||||
rows = data.get("r") if isinstance(data, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return
|
||||
for row in rows:
|
||||
try:
|
||||
kind, ent, d0, kwh, at, per = row
|
||||
day0, at = _parse_time(d0), _parse_time(at)
|
||||
kwh = float(kwh)
|
||||
periods = tuple((_parse_time(s), _parse_time(e), float(k))
|
||||
for s, e, k in per)
|
||||
ok = (kind in ("today", "tomorrow") and isinstance(ent, str)
|
||||
and day0 is not None and at is not None
|
||||
and math.isfinite(kwh) and kwh >= 0
|
||||
and all(s is not None and e is not None and e > s
|
||||
and math.isfinite(k) and k >= 0
|
||||
for s, e, k in periods))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if ok:
|
||||
self._r[(kind, ent, day0)] = (Day(day0, kwh, periods), at)
|
||||
self._saved[(kind, ent, day0)] = at
|
||||
|
||||
|
||||
def entities(opts: dict, *, tomorrow=False) -> tuple:
|
||||
"""The forecast entities one day names, in configured order: the list
|
||||
if it has any entry, else the single key, else (). A repeated id is
|
||||
@@ -267,6 +404,15 @@ def _bounds(now):
|
||||
return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _bell_start(day0):
|
||||
"""The moment the bell starts on the local day that begins at `day0`
|
||||
(`BELL_FROM_H` local, so 06:00 on a 23 h or 25 h day too)."""
|
||||
here = day0.astimezone(BILLING_TZ)
|
||||
t = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ) \
|
||||
+ timedelta(hours=BELL_FROM_H)
|
||||
return t.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _today(state, day0, now):
|
||||
"""`(Day, at, None)` or `(None, None, why)` for one today sensor."""
|
||||
today, why = _day(state, day0, now, today=True)
|
||||
@@ -282,14 +428,14 @@ def _today(state, day0, now):
|
||||
|
||||
|
||||
def _tomorrow(state, day0, day1, now):
|
||||
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
|
||||
"""`(Day, at, None)` or `(None, None, why)` for one tomorrow sensor."""
|
||||
t, why = _day(state, day1, now, today=False)
|
||||
if t is None:
|
||||
return None, why
|
||||
return None, None, why
|
||||
t_at = _reported(state)
|
||||
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
|
||||
return None, STALE
|
||||
return t, None
|
||||
return None, None, STALE
|
||||
return t, t_at, None
|
||||
|
||||
|
||||
def read(today_state, tomorrow_state, now: datetime, *,
|
||||
@@ -305,7 +451,7 @@ def read(today_state, tomorrow_state, now: datetime, *,
|
||||
return None, why
|
||||
tomorrow = None
|
||||
if tomorrow_state is not None:
|
||||
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
tomorrow, _at, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
return Forecast(today, tomorrow, at), None
|
||||
|
||||
|
||||
@@ -321,7 +467,12 @@ def _sum(days):
|
||||
return Day(days[0].day0, sum(d.kwh for d in days), periods)
|
||||
|
||||
|
||||
def read_sum(today, tomorrow, now: datetime):
|
||||
# Why a sensor may be covered by a kept reading: it has none now, or it is
|
||||
# too old. A bad value or a wrong unit is a set-up fault and is never covered.
|
||||
_COVERABLE = (MISSING, STALE)
|
||||
|
||||
|
||||
def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
|
||||
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
|
||||
`(entity, state)`, one per configured member, in configured order.
|
||||
|
||||
@@ -334,29 +485,67 @@ def read_sum(today, tomorrow, now: datetime):
|
||||
`gaps` is `((day, position, entity, why), ...)` for every unusable
|
||||
member (`day` "today" or "tomorrow", `position` 1-based), so the page
|
||||
and the log can name it. With one member per day the forecast is what
|
||||
`read()` gives (test_opt17.py proves it)."""
|
||||
`read()` gives (test_opt17.py proves it).
|
||||
|
||||
OPT-27: with a `hold`, every fresh reading is remembered in it, and a
|
||||
member that is missing or stale is covered by its last fresh reading
|
||||
where `Hold.stand_in()` allows it (the forecast then has `kept` set when
|
||||
that took a reading older than `FRESH_S`). After midnight, if the today
|
||||
side is still short, the tomorrow figures read before midnight are
|
||||
today's, until the bell starts (`stand_in()`). Without a `hold` this is
|
||||
exactly as before."""
|
||||
if not today:
|
||||
return None, NOT_SET, ()
|
||||
day0, day1 = _bounds(now)
|
||||
gaps, days, ats, why0 = [], [], [], None
|
||||
if hold is not None:
|
||||
hold.forget_before(day0)
|
||||
gaps, days, ats, why0, kept = [], [], [], None, False
|
||||
for i, (ent, st) in enumerate(today, 1):
|
||||
d, at, why = _today(st, day0, now)
|
||||
if d is not None and hold is not None:
|
||||
hold.remember("today", ent, d, at)
|
||||
elif hold is not None and why in _COVERABLE:
|
||||
r = hold.stand_in("today", ent, day0, now)
|
||||
if r is not None:
|
||||
d, at, k = r
|
||||
kept = kept or k
|
||||
if d is None:
|
||||
gaps.append(("today", i, ent, why))
|
||||
why0 = why0 or why
|
||||
else:
|
||||
days.append(d)
|
||||
ats.append(at)
|
||||
tdays = []
|
||||
tdays, tats, tkept = [], [], False
|
||||
for i, (ent, st) in enumerate(tomorrow, 1):
|
||||
t, why = _tomorrow(st, day0, day1, now)
|
||||
t, t_at, why = _tomorrow(st, day0, day1, now)
|
||||
if t is not None and hold is not None:
|
||||
hold.remember("tomorrow", ent, t, t_at)
|
||||
elif hold is not None and why in _COVERABLE:
|
||||
r = hold.stand_in("tomorrow", ent, day1, now)
|
||||
if r is not None:
|
||||
t, t_at, k = r
|
||||
tkept = tkept or k
|
||||
if t is None:
|
||||
gaps.append(("tomorrow", i, ent, why))
|
||||
else:
|
||||
tdays.append(t)
|
||||
tats.append(t_at)
|
||||
if why0 is not None and hold is not None and tomorrow and all(
|
||||
g[3] in _COVERABLE for g in gaps if g[0] == "today"):
|
||||
# Past midnight the sensors may still show yesterday's "left today".
|
||||
# The figure each tomorrow sensor gave before midnight is for this
|
||||
# day; all of them are needed, as for any sum.
|
||||
subs = [hold.stand_in("tomorrow", ent, day0, now, as_today=True)
|
||||
for ent, _st in tomorrow]
|
||||
if all(r is not None for r in subs):
|
||||
days = [r[0] for r in subs]
|
||||
ats = [r[1] for r in subs]
|
||||
gaps = [g for g in gaps if g[0] != "today"]
|
||||
why0, kept = None, True
|
||||
gaps = tuple(gaps)
|
||||
if why0 is not None:
|
||||
return None, why0, gaps
|
||||
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
|
||||
kept = kept or (tday is not None and tkept)
|
||||
# The oldest member's report is when the sum was last fully known.
|
||||
return Forecast(_sum(days), tday, min(ats)), None, gaps
|
||||
return Forecast(_sum(days), tday, min(ats), kept), None, gaps
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
"tops_up": "It charges from the grid to {pct}. That covers {kwh} of expensive hours the sun will not.",
|
||||
"no_forecast": "No solar forecast, so it charges from solar only."
|
||||
},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"today": {
|
||||
"not_planned": "Not planned yet.",
|
||||
"no_runs": "No cheap or expensive hours left today.",
|
||||
|
||||
@@ -300,7 +300,7 @@
|
||||
"tops_up": "Ze laadt van het net tot {pct}. Dat dekt {kwh} dat de zon in dure uren niet levert.",
|
||||
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
|
||||
},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"today": {
|
||||
"not_planned": "Nog niet gepland.",
|
||||
"no_runs": "Vandaag geen goedkope of dure uren meer.",
|
||||
|
||||
@@ -671,17 +671,44 @@ check("Cheap hours with a sun forecast charges as with none (P3)",
|
||||
charge and abs(max(r["soc"]["b"] for r in rows_nf) - top) < 1.0
|
||||
and top > 80.0)
|
||||
|
||||
# The forecast goes away mid-run: Leftover solar stops at once (P3 option A).
|
||||
# The forecast goes away mid-run: a charge under way goes on to its level
|
||||
# (OPT-34; it stopped at once, P3 option A, before). With no charge under way
|
||||
# the levels go with the forecast (below, and test_opt27.py).
|
||||
# OPT-27: a sensor that is unavailable is covered by its last fresh reading
|
||||
# for up to 3 h (`solarfc.Hold`), so "lost" here is a hold that never covers;
|
||||
# the next scenario is the same loss with the hold (test_opt27.py).
|
||||
DROP = UTC0 + timedelta(hours=1, minutes=30)
|
||||
|
||||
|
||||
class _NoHold(solarfc.Hold):
|
||||
def stand_in(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
def seed_profile_no_hold(c):
|
||||
seed_profile(c)
|
||||
c.fc_hold = _NoHold()
|
||||
|
||||
|
||||
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
|
||||
soc0={"a": 60.0, "b": 10.0})
|
||||
soc0={"a": 60.0, "b": 10.0}, patch=seed_profile_no_hold)
|
||||
invariants("forecast lost mid-run", rows, c)
|
||||
before = [r for r in rows if r["now"] < DROP and r["action"]
|
||||
== arbitrage.CHARGE]
|
||||
late = [r for r in rows if r["now"] > DROP + timedelta(
|
||||
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE]
|
||||
check("forecast lost mid-run: Leftover solar was charging, and stops within "
|
||||
"a forecast read (60 s) and a cycle", before and not late)
|
||||
check("forecast lost mid-run (OPT-34): Leftover solar was charging, and goes "
|
||||
"on charging past a forecast read (60 s) and a cycle, to its level",
|
||||
before and late)
|
||||
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
|
||||
soc0={"a": 60.0, "b": 10.0})
|
||||
invariants("forecast unavailable mid-run, covered", rows, c)
|
||||
check("forecast unavailable mid-run (OPT-27): the last reading covers it, so "
|
||||
"Leftover solar goes on charging past the point where it was lost",
|
||||
[r for r in rows if r["now"] < DROP and r["action"]
|
||||
== arbitrage.CHARGE]
|
||||
and [r for r in rows if r["now"] > DROP + timedelta(
|
||||
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE])
|
||||
|
||||
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
|
||||
# charge, so B covers less of the evening - a money risk, never a safety one.
|
||||
@@ -774,12 +801,25 @@ _m1 = c._run_mark("charge", _run, _packs, lambda p: True, ((B, 40.0),))
|
||||
_m2 = c._run_mark("charge", _run, _packs, lambda p: False, ((B, 50.0),))
|
||||
check("a level the hysteresis moved opens the run to that battery again",
|
||||
_m1 and not _m2)
|
||||
latch, c._charge_latch = c._charge_latch, None
|
||||
c.solar_fc = None
|
||||
check("the levels are dropped as soon as the forecast goes, even before "
|
||||
"the plan is rebuilt", c._plan_targets and c.plan_targets() is None)
|
||||
check("with no charge under way, the levels are dropped as soon as the "
|
||||
"forecast goes, even before the plan is rebuilt",
|
||||
c._plan_targets and c.plan_targets() is None)
|
||||
check("a lost forecast says so: no forecast, Leftover solar solar only",
|
||||
homeview.solar_block(c, datetime.now(timezone.utc))["state"]
|
||||
== "no_forecast" and homeview.day_key(c, B) == "no_forecast")
|
||||
c._charge_latch = latch
|
||||
# the latch ends at its end time; this replay ran in September, so ask at a
|
||||
# moment inside it
|
||||
_real_dt = rp.m.datetime
|
||||
rp.m.datetime = type("_DT", (datetime,), {"now": classmethod(
|
||||
lambda cls, tz=None: latch[0] - timedelta(hours=1))})
|
||||
check("OPT-34: with a charge under way (B charged in this run), the level "
|
||||
"stays: plan_targets() gives it (the line: test_opt34.py)",
|
||||
latch is not None and c.plan_targets() is not None
|
||||
and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B])
|
||||
rp.m.datetime = _real_dt
|
||||
check("the Why line wins over 'saves its power' only while holding",
|
||||
homeview.battery_why(modes_on=True, mode="leftover_solar", off=False,
|
||||
reading=True, phase="idle", shadow=False,
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
"""Runnable check for OPT-27. `python3 test_opt27.py`
|
||||
|
||||
Forecast.Solar's sensors stop changing in the evening, so at night every
|
||||
Forecast.Solar site was "no forecast": a price mode's night cheap run needed
|
||||
1.5x the cycle cost and Leftover solar bought nothing. And one missed poll
|
||||
(`unavailable`) dropped the forecast at once. `solarfc.Hold` now keeps the last
|
||||
fresh reading of each sensor, for the day it is FOR:
|
||||
|
||||
* up to `FRESH_S` when the sensor is missing or unavailable (a short outage);
|
||||
* from the end of the bell (20:00) until local midnight, if the reading is at
|
||||
most `KEPT_MAX_AGE_S` (12 h) old when used (the night plan); after midnight
|
||||
the evening's tomorrow figure is today's until the bell starts (06:00, OPT-34,
|
||||
test_opt34.py); never beyond, never for another day.
|
||||
|
||||
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
|
||||
|
||||
1. `solarfc` alone: the evening, the outage, the bounds, the day a figure is
|
||||
for, restart (dump/load), and no hold = as before.
|
||||
2. The real Controller on a day/night tariff (0.34/0.26, gap 0.08, cycle cost
|
||||
0.07), forecast sensors that stop updating at 17:05 local:
|
||||
a. the 22:00 night run is planned with the cycle-cost gap and charges;
|
||||
b. no forecast all day: the night run still needs 1.5x and does not charge;
|
||||
c. an evening restart keeps it (a new Controller on the saved store);
|
||||
d. a one-hour outage at 18:47 does not drop it;
|
||||
e. from 06:00 (the bell) the figure is not used, before it it is (OPT-34).
|
||||
3. The screens: the forecast line says when it was read and that it is kept
|
||||
for tonight (EN and NL), and Home agrees with control.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file): see the
|
||||
DECISIONS note and the dev report.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, modetrade, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
|
||||
|
||||
|
||||
def at(day, hh, mm=0):
|
||||
"""Local time on 2026-11-`day` (CET) as UTC."""
|
||||
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def st(kwh, when, *, unit="kWh"):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
|
||||
"last_updated": when.isoformat(),
|
||||
"last_reported": when.isoformat()}
|
||||
|
||||
|
||||
GONE = {"state": "unavailable", "attributes": {}}
|
||||
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. solarfc: the hold")
|
||||
D1_READ = at(3, 17, 5) # the last fresh reading, 17:05
|
||||
EVE = at(3, 22, 0)
|
||||
|
||||
|
||||
def rs(today, tomorrow, now, hold):
|
||||
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
|
||||
|
||||
|
||||
h = solarfc.Hold()
|
||||
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 17, 10), h)
|
||||
check("a fresh reading is a forecast, not kept", fc is not None
|
||||
and not fc.kept and fc.tomorrow.kwh == 12.0 and fc.today.kwh == 0.4)
|
||||
check("it is remembered (the hold is flagged for saving)", h.changed)
|
||||
fc_no, why_no, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, None)
|
||||
check("with no hold, 22:00 is no forecast (as before)",
|
||||
fc_no is None and why_no == solarfc.STALE)
|
||||
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, h)
|
||||
print(f" 22:00, sensors still show 17:05: {fc and fc.kept=}, {gaps=}")
|
||||
check("22:00 with the sensors stale since 17:05: the 17:05 figures are used",
|
||||
fc is not None and fc.kept and fc.tomorrow.kwh == 12.0
|
||||
and fc.at == D1_READ and gaps == ())
|
||||
check("... today has no sun left in it, tomorrow's is counted from 06:00",
|
||||
fc is not None and fc.between(EVE, at(4, 7, 0)) is not None
|
||||
and 0.0 < fc.between(EVE, at(4, 7, 0)) < 0.5
|
||||
and fc.today.kwh == 0.4)
|
||||
fc2, _w, _g = rs(GONE, GONE, EVE, h)
|
||||
check("the same when the sensors are unavailable at 22:00",
|
||||
fc2 is not None and fc2.kept and fc2.tomorrow.kwh == 12.0)
|
||||
# A new fresh reading replaces the old one at once.
|
||||
fc3, _w, _g = rs(st(0.0, at(3, 22, 20)), st(14.5, at(3, 22, 20)),
|
||||
at(3, 22, 30), h)
|
||||
check("a newer fresh reading is used and is not 'kept'",
|
||||
fc3 is not None and not fc3.kept and fc3.tomorrow.kwh == 14.5)
|
||||
fc3b, _w, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 23, 0), h)
|
||||
check("... and later the newest reading is the one kept, not the old one",
|
||||
fc3b is not None and fc3b.tomorrow.kwh == 14.5)
|
||||
|
||||
# A short outage: unavailable for one or two polls, any time of day.
|
||||
h2 = solarfc.Hold()
|
||||
rs(st(1.1, at(3, 18, 5)), st(12.0, at(3, 18, 5)), at(3, 18, 6), h2)
|
||||
for label, now in (("18:47", at(3, 18, 47)), ("19:20", at(3, 19, 20)),
|
||||
("19:47", at(3, 19, 47)), ("20:55", at(3, 20, 55))):
|
||||
fo, _w, _g = rs(GONE, GONE, now, h2)
|
||||
check(f"unavailable at {label}: the 18:05 forecast is still used",
|
||||
fo is not None and fo.tomorrow.kwh == 12.0)
|
||||
fo, _w, _g = rs(GONE, GONE, at(3, 20, 55), solarfc.Hold())
|
||||
check("(nothing was ever read: still no forecast)", fo is None)
|
||||
# Daytime is as before: an outage longer than FRESH_S drops it.
|
||||
h3 = solarfc.Hold()
|
||||
rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h3)
|
||||
d_in = rs(GONE, GONE, at(4, 11, 50), h3)[0]
|
||||
d_out = rs(GONE, GONE, at(4, 12, 10), h3)[0]
|
||||
d_out2 = rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 14, 0), h3)[0]
|
||||
check("daytime: an outage under 3 h is covered, one over 3 h is not",
|
||||
d_in is not None and not d_in.kept and d_out is None)
|
||||
check("daytime: a sensor that stopped is stale after 3 h, as before "
|
||||
"(the hold does not start before the bell ends)", d_out2 is None)
|
||||
|
||||
# Round 2: today fresh, tomorrow's sensor stopped or down in the daytime.
|
||||
h9 = solarfc.Hold()
|
||||
rs(st(5.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h9)
|
||||
for label, tm in (("stale", st(12.0, at(4, 9, 5))), ("unavailable", GONE)):
|
||||
fd = rs(st(3.0, at(4, 13, 55)), tm, at(4, 14, 0), h9)[0]
|
||||
check(f"14:00, today fresh, tomorrow {label} since 09:05 (over 3 h): "
|
||||
"no tomorrow, nothing kept",
|
||||
fd is not None and fd.tomorrow is None and not fd.kept)
|
||||
fn = rs(st(0.1, at(4, 20, 55)), tm, at(4, 21, 0), h9)[0]
|
||||
check(f"21:00, tomorrow {label}: the 09:05 figure covers it, kept",
|
||||
fn is not None and fn.tomorrow is not None
|
||||
and fn.tomorrow.kwh == 12.0 and fn.kept)
|
||||
fs = rs(st(3.0, at(4, 10, 55)), tm, at(4, 11, 0), h9)[0]
|
||||
check(f"11:00, tomorrow {label} for under 3 h: still covered, not kept",
|
||||
fs is not None and fs.tomorrow is not None and not fs.kept)
|
||||
|
||||
# The bound: the bell start (06:00), and the day a figure is for.
|
||||
fm = rs(st(0.0, at(3, 23, 0)), st(0.0, at(3, 23, 0)), at(4, 1, 0), h2)[0]
|
||||
check("00:30 is covered by the 18:05 tomorrow figure, as TODAY's",
|
||||
rs(GONE, GONE, at(4, 0, 30), h2)[0] is not None)
|
||||
fmid = rs(GONE, GONE, at(4, 2, 59), h2)[0]
|
||||
print(f" 02:59: {fmid and (fmid.today.kwh, fmid.tomorrow, fmid.kept)}")
|
||||
check("02:59: tomorrow's 12.0 kWh is now today's, there is no tomorrow",
|
||||
fmid is not None and fmid.today.kwh == 12.0 and fmid.tomorrow is None
|
||||
and fmid.kept and fmid.today.day0 == at(4, 0, 0))
|
||||
check("... its sun is counted on the day it is for (06:00-20:00 on the 4th)",
|
||||
fmid is not None and fmid.between(at(4, 3, 0), at(4, 7, 0)) is not None
|
||||
and fmid.between(at(4, 3, 0), at(4, 7, 0)) > 0.0)
|
||||
check("03:01: still used (OPT-34: until the bell starts at 06:00)",
|
||||
rs(GONE, GONE, at(4, 3, 1), h2)[0] is not None
|
||||
and rs(GONE, GONE, at(4, 5, 59), h2)[0] is not None)
|
||||
check("06:00 (the bell starts): not used any more",
|
||||
rs(GONE, GONE, at(4, 6, 0), h2)[0] is None)
|
||||
check("... and not the next evening either (past 06:00)",
|
||||
rs(GONE, GONE, at(4, 22, 0), h2)[0] is None)
|
||||
fe = rs(st(0.1, at(4, 21, 50)), GONE, at(4, 22, 0), h2)[0]
|
||||
check("... the next evening, with today fresh and tomorrow's sensor down, "
|
||||
"there is no tomorrow: the 4th's figure is not the 5th's",
|
||||
fe is not None and fe.today.kwh == 0.1 and fe.tomorrow is None)
|
||||
# Never today's figure for tomorrow.
|
||||
h4 = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h4)
|
||||
# Next day 00:30: today's sensor is fresh again (4.0), tomorrow's is gone.
|
||||
fx = rs(st(4.0, at(4, 0, 20)), GONE, at(4, 0, 30), h4)[0]
|
||||
check("00:30: today is the fresh 4.0, and there is NO tomorrow "
|
||||
"(the 3rd's 'today' 3.0 and 'tomorrow' 12.0 are not the 5th's)",
|
||||
fx is not None and fx.today.kwh == 4.0 and fx.tomorrow is None
|
||||
and not fx.kept)
|
||||
fy = rs(st(4.0, at(4, 0, 20)), st(9.0, at(4, 0, 20)), at(4, 0, 30), h4)[0]
|
||||
check("00:30 with both fresh: tomorrow is the fresh 9.0, not the old 12.0",
|
||||
fy is not None and fy.tomorrow.kwh == 9.0 and not fy.kept)
|
||||
# A tomorrow sensor that is down while today's is fresh, in the evening: the
|
||||
# hold gives the tomorrow figure for tomorrow, never today's figure.
|
||||
h5 = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h5)
|
||||
fz = rs(st(0.2, at(3, 21, 30)), GONE, at(3, 21, 40), h5)[0]
|
||||
check("21:40: today fresh, tomorrow down: tomorrow is the kept 12.0 (not "
|
||||
"today's 0.2 or 3.0)", fz is not None and fz.tomorrow.kwh == 12.0
|
||||
and fz.today.kwh == 0.2 and fz.kept)
|
||||
# A set-up fault is never covered.
|
||||
h6 = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h6)
|
||||
fb = rs(st("lots", at(3, 21, 30)), st(12.0, at(3, 21, 30)),
|
||||
at(3, 21, 40), h6)
|
||||
fu = rs(st(0.2, at(3, 21, 30), unit="W"), st(12.0, at(3, 21, 30)),
|
||||
at(3, 21, 40), h6)
|
||||
check("a bad value or a wrong unit is not covered by the hold",
|
||||
fb[0] is None and fb[1] == solarfc.BAD and fu[0] is None
|
||||
and fu[1] == solarfc.UNIT)
|
||||
# Several sensors: all or nothing.
|
||||
h7 = solarfc.Hold()
|
||||
two_t = [("sensor.t1", st(1.0, at(3, 17, 0))),
|
||||
("sensor.t2", st(2.0, at(3, 17, 0)))]
|
||||
two_m = [("sensor.m1", st(5.0, at(3, 17, 0))),
|
||||
("sensor.m2", st(6.0, at(3, 17, 0)))]
|
||||
solarfc.read_sum(two_t, two_m, at(3, 17, 5), h7)
|
||||
gone2 = [("sensor.t1", GONE), ("sensor.t2", GONE)]
|
||||
f2 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
|
||||
at(3, 22, 0), h7)
|
||||
check("three or two sensors: the kept figures are summed",
|
||||
f2[0] is not None and f2[0].tomorrow.kwh == 11.0
|
||||
and f2[0].today.kwh == 3.0 and f2[2] == ())
|
||||
h8 = solarfc.Hold()
|
||||
solarfc.read_sum(two_t[:1] + [("sensor.t2", GONE)], two_m, at(3, 17, 5), h8)
|
||||
f3 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
|
||||
at(3, 22, 0), h8)
|
||||
check("one sensor never read fresh: no partial sum, the gap is named",
|
||||
f3[0] is None and any(g[:3] == ("today", 2, "sensor.t2") for g in f3[2]))
|
||||
# Restart: what the store holds comes back.
|
||||
saved = json.loads(json.dumps(h.dump()))
|
||||
hr = solarfc.Hold(saved)
|
||||
fr = rs(GONE, GONE, at(3, 23, 0), hr)[0]
|
||||
check("dump/load through JSON: the kept figures come back (40 min old: "
|
||||
"inside FRESH_S, so not 'kept' in the screens' sense)",
|
||||
fr is not None and not fr.kept and fr.tomorrow.kwh == 14.5
|
||||
and fr.at == at(3, 22, 20))
|
||||
junk = solarfc.Hold({"r": [["today", 7, "x", "y", "z", []],
|
||||
["tomorrow", T_ENT, "2026-11-04T00:00:00+00:00",
|
||||
-1, "2026-11-03T16:00:00+00:00", []],
|
||||
"bad", None]})
|
||||
check("a damaged store entry is dropped, not raised",
|
||||
rs(GONE, GONE, at(3, 22, 0), junk)[0] is None
|
||||
and solarfc.Hold(None).dump()["r"] == [] and solarfc.Hold(5).changed
|
||||
is False)
|
||||
hp = solarfc.Hold()
|
||||
rs(st(8.0, at(3, 9, 0), ), st(12.0, at(3, 9, 0)), at(3, 9, 5), hp)
|
||||
periods = [{"period_start": f"2026-11-04T{hh:02d}:00:00+01:00",
|
||||
"pv_estimate": 1.0} for hh in range(8, 16)]
|
||||
s_per = st(10.0, at(3, 17, 0))
|
||||
s_per["attributes"]["detailedForecast"] = periods
|
||||
rs(st(0.0, at(3, 17, 0)), s_per, at(3, 17, 5), hp)
|
||||
hq = solarfc.Hold(json.loads(json.dumps(hp.dump())))
|
||||
fq = rs(GONE, GONE, at(3, 22, 0), hq)[0]
|
||||
check("an hourly split (Solcast shape) survives the store",
|
||||
fq is not None and fq.tomorrow.periods
|
||||
and abs(fq.between(at(4, 8, 0), at(4, 16, 0)) - 7.5) < 1e-6)
|
||||
|
||||
# Round 3. F1: a kept reading is at most KEPT_MAX_AGE_S old when used.
|
||||
hz = solarfc.Hold()
|
||||
rs(st(0.4, at(3, 0, 5)), st(12.0, at(3, 0, 5)), at(3, 0, 10), hz)
|
||||
print(f" 00:05 reading, sensors dead: 19:59 "
|
||||
f"{rs(GONE, GONE, at(3, 19, 59), hz)[0]}, 20:00 "
|
||||
f"{rs(GONE, GONE, at(3, 20, 0), hz)[0]}")
|
||||
check("F1: a 00:05 reading with the sensors dead all day is not kept at "
|
||||
"20:00 (20 h old): no forecast",
|
||||
rs(GONE, GONE, at(3, 20, 0), hz)[0] is None
|
||||
and rs(GONE, GONE, at(3, 23, 0), hz)[0] is None)
|
||||
check("... nor after midnight until 03:00 (as today's, 26 h old)",
|
||||
rs(GONE, GONE, at(4, 0, 30), hz)[0] is None
|
||||
and rs(GONE, GONE, at(4, 2, 30), hz)[0] is None)
|
||||
hy = solarfc.Hold()
|
||||
rs(st(0.4, at(3, 17, 5)), st(12.0, at(3, 17, 5)), at(3, 17, 10), hy)
|
||||
check("F1: a 17:05 reading is kept at 22:00 (5 h) ...",
|
||||
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(3, 22, 0), hy)[0]))
|
||||
check("... and at 02:59 next day (9 h 54 min) ...",
|
||||
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 2, 59),
|
||||
hy)[0]))
|
||||
check("... and at 03:00 and 05:59 next day (OPT-34: the bell starts at 06:00)",
|
||||
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 3, 0),
|
||||
hy)[0])
|
||||
and (lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 5, 59),
|
||||
hy)[0]))
|
||||
check("... and at 06:00 it is not (12 h 55 min old, the bell has started)",
|
||||
rs(GONE, GONE, at(4, 6, 0), hy)[0] is None)
|
||||
hm = solarfc.Hold()
|
||||
rs(st(0.9, at(3, 9, 5)), st(12.0, at(3, 9, 5)), at(3, 9, 10), hm)
|
||||
check("F1: a 09:05 reading is kept at 20:00 (10 h 55 min) but not at 22:00",
|
||||
rs(GONE, GONE, at(3, 20, 0), hm)[0] is not None
|
||||
and rs(GONE, GONE, at(3, 22, 0), hm)[0] is None)
|
||||
|
||||
# F2: the 25 h autumn day (2026-10-25) and the 23 h spring day (2027-03-28).
|
||||
BRU = solarfc.BILLING_TZ # Europe/Brussels, or the encoded EU rule
|
||||
|
||||
|
||||
def bru(y, mo, d, hh, mm=0):
|
||||
return datetime(y, mo, d, hh, mm, tzinfo=BRU).astimezone(timezone.utc)
|
||||
|
||||
|
||||
for (y, mo, d), label in (((2026, 10, 25), "25 h autumn day 2026-10-25"),
|
||||
((2027, 3, 28), "23 h spring day 2027-03-28")):
|
||||
hd = solarfc.Hold()
|
||||
t17 = bru(y, mo, d, 17, 5)
|
||||
rs(st(0.4, t17), st(12.0, t17), t17 + timedelta(minutes=5), hd)
|
||||
# real hours from 20:00 local to the next local midnight, each hour
|
||||
start = bru(y, mo, d, 20, 0)
|
||||
end = bru(y, mo, d + 1, 0)
|
||||
t, miss = start, []
|
||||
while t < end:
|
||||
if rs(GONE, GONE, t, hd)[0] is None:
|
||||
miss.append(t.astimezone(BRU).strftime("%H:%M"))
|
||||
t += timedelta(minutes=30)
|
||||
print(f" {label}: not kept at {miss}")
|
||||
check(f"F2: {label}: the 17:05 figure is kept every half hour "
|
||||
"from 20:00 to local midnight", not miss)
|
||||
check(f"... and just before the next local midnight",
|
||||
rs(GONE, GONE, bru(y, mo, d + 1, 0) - timedelta(minutes=1),
|
||||
hd)[0] is not None)
|
||||
|
||||
# F5: old days are dropped from the stored hold, so it does not grow.
|
||||
hp5 = solarfc.Hold()
|
||||
sizes = []
|
||||
for dd in range(3, 13):
|
||||
t0_ = at(dd, 17, 5)
|
||||
rs(st(0.4, t0_), st(12.0, t0_), t0_ + timedelta(minutes=5), hp5)
|
||||
sizes.append(len(hp5.dump()["r"]))
|
||||
day0s = {row[2] for row in hp5.dump()["r"]}
|
||||
print(f" F5: stored entries per day over 10 days: {sizes}")
|
||||
check("F5: the stored hold does not grow day after day (at most today's and "
|
||||
"tomorrow's entry, with yesterday's tomorrow)", max(sizes) <= 3
|
||||
and len(day0s) <= 2)
|
||||
check("... and a day before today is gone",
|
||||
all(row[2] >= at(12, 0, 0).isoformat() for row in hp5.dump()["r"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. The real Controller: day/night 0.34 / 0.26, cycle cost 0.07")
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.26},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.34}]}')
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
DAY3 = 3
|
||||
|
||||
|
||||
def opts(cost=0.07):
|
||||
o = dict(rp.options(True, False, 2))
|
||||
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE,
|
||||
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT)
|
||||
return o
|
||||
|
||||
|
||||
def stops_at(last, *, down=()):
|
||||
"""Sensors that update until `last` and then show that reading for
|
||||
good. `down`: (from, to) windows in which they are unavailable."""
|
||||
def f(now, ent):
|
||||
for a, z in down:
|
||||
if a <= now < z:
|
||||
return GONE
|
||||
when = min(now, last)
|
||||
return st(0.4 if ent == T_ENT else 12.0, when)
|
||||
return f
|
||||
|
||||
|
||||
def never(now, ent):
|
||||
return st(0.0 if ent == T_ENT else 12.0, now - timedelta(hours=9))
|
||||
|
||||
|
||||
def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
|
||||
keep_store=True, snap=False):
|
||||
"""-> (controller, rows, pre_rows). `pre`: the times the forecast is read
|
||||
before the first cycle (the evening). `restart`: a time in `pre` at which
|
||||
the add-on restarts: a new Controller on the saved store."""
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in CHOICE.items():
|
||||
for k, val in (("pack_use", use), ("pack_mode", mode),
|
||||
("pack_sell", sell)):
|
||||
store.data[unit_key(f"inverter-{u}", k)] = val
|
||||
clock = [pre[0] if pre else start]
|
||||
rows, pre_rows = [], []
|
||||
cur = [store]
|
||||
|
||||
class H(rp.Hass):
|
||||
async def state(self, e):
|
||||
return sensors(clock[0], e)
|
||||
|
||||
def build(store):
|
||||
hass = H(("a", "b"), lambda t: 300.0, {"a": 40.0, "b": 40.0})
|
||||
tmp = tempfile.mkdtemp(prefix="o27-")
|
||||
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
# A house profile a few days old (300 W round the clock), so the day
|
||||
# plan is made as it is on a site that has run for a week.
|
||||
c.house_profile.load({d: {str(hh): 300.0 for hh in range(24)}
|
||||
for d in ("2026-10-31", "2026-11-01",
|
||||
"2026-11-02")})
|
||||
return c, hass
|
||||
|
||||
async def go():
|
||||
c, hass = build(store)
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
task = None
|
||||
try:
|
||||
for t_pre in pre:
|
||||
clock[0] = t_pre
|
||||
if restart is not None and t_pre == restart:
|
||||
# The saved file, through JSON; `keep_store=False` loses
|
||||
# the hold only (the control for the test).
|
||||
data = json.loads(json.dumps(cur[0].data))
|
||||
if not keep_store:
|
||||
data.pop(m.FC_HOLD_KEY, None)
|
||||
new = rp._Store()
|
||||
new.data.update(data)
|
||||
cur[0] = new
|
||||
c, hass = build(new)
|
||||
await c._read_forecast()
|
||||
pre_rows.append((t_pre, c.solar_fc is not None,
|
||||
bool(c.solar_fc and c.solar_fc.kept)))
|
||||
clock[0] = start
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
t = 0.0
|
||||
while t < minutes * 60:
|
||||
now = start + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if t == 0.0:
|
||||
for _ in range(500):
|
||||
if c.tariff_plan is not None and (
|
||||
c.tariff_at is not None and c.tariff_at >= now):
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(5.0, 10.0)
|
||||
rp_ = c.mode_runs
|
||||
rows.append(dict(
|
||||
t=t, fc_none=c.solar_fc is None,
|
||||
kept=bool(c.solar_fc and c.solar_fc.kept),
|
||||
runs_on=None if rp_ is None else rp_.on,
|
||||
reason=None if rp_ is None else rp_.reason,
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
charging=hass.plants["b"].p < -200.0,
|
||||
soc_b=hass.plants["b"].soc))
|
||||
t += 5.0
|
||||
keep = c.o
|
||||
c.o = dict(keep, arbitrage_enabled=True)
|
||||
try:
|
||||
c.mirror, c.mirror_why = homeview.trade_verdict(
|
||||
c, c.fresh_tariff_plan(clock[0]), clock[0])
|
||||
finally:
|
||||
c.o = keep
|
||||
c.cards = homeview.battery_rows(c, False, True)
|
||||
c.block = homeview.solar_block(c, clock[0])
|
||||
c.hold_saved = json.loads(json.dumps(cur[0].data.get(
|
||||
m.FC_HOLD_KEY)))
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
return c
|
||||
|
||||
return asyncio.run(go()), rows, pre_rows
|
||||
|
||||
|
||||
def why_of(c, name):
|
||||
card = first((b for b in c.cards if b["name"] == name), f"card {name}")
|
||||
return card["why_key"]
|
||||
|
||||
|
||||
EVENING = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
|
||||
LAST = at(DAY3, 17, 5)
|
||||
NIGHT_START = at(DAY3, 22, 0)
|
||||
|
||||
# a. a forecast that stops updating at sunset
|
||||
ca, ra, pa = drive27(opts(), stops_at(LAST), pre=EVENING + [at(DAY3, 21, 55)],
|
||||
start=NIGHT_START)
|
||||
la = nth(ra, -1, "last cycle")
|
||||
print(f" a. 22:00 with the 17:05 forecast: kept={la['kept']} on="
|
||||
f"{la['runs_on']} reason={la['reason']}; B charging "
|
||||
f"{sum(r['charging'] for r in ra)} of {len(ra)}; Why A {why_of(ca, A)} "
|
||||
f"B {why_of(ca, B)}; Home mirror {ca.mirror}")
|
||||
check("the forecast was read fresh before 17:05 and kept after it",
|
||||
any(not k for _t, ok, k in pa if _t <= LAST + timedelta(minutes=30))
|
||||
and all(ok for _t, ok, _k in pa)
|
||||
and all(k for _t, _ok, k in pa if _t >= at(DAY3, 20, 15)))
|
||||
check("22:00 night run: planned with the cycle-cost gap (needs 0.07 < 0.08)",
|
||||
present(la) and not la["fc_none"] and la["kept"]
|
||||
and la["runs_on"] is True and la["reason"] != "not_worth")
|
||||
check("... and B (Cheap hours) charges from the grid in the night period",
|
||||
sum(r["charging"] for r in ra) > len(ra) // 2
|
||||
and la["roles"].get(B) == modetrade.CHARGE
|
||||
and why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread")
|
||||
check("the day plan is made from the kept forecast (levels for Leftover "
|
||||
"solar), as it is from a fresh one",
|
||||
ca.day_plan is not None and ca.plan_targets() is not None
|
||||
and ca.block["state"] == "plan")
|
||||
check("Home's mirror of the gate allows it, as control does",
|
||||
ca.mirror is not None and ca.mirror_why == "allowed")
|
||||
|
||||
# b. no forecast all day
|
||||
cb, rb, pb = drive27(opts(), never, pre=EVENING + [at(DAY3, 21, 55)],
|
||||
start=NIGHT_START)
|
||||
lb = nth(rb, -1, "last cycle")
|
||||
print(f" b. no forecast all day: on={lb['runs_on']} reason={lb['reason']}; "
|
||||
f"B charging {sum(r['charging'] for r in rb)}; Why A {why_of(cb, A)} "
|
||||
f"B {why_of(cb, B)}")
|
||||
check("with no forecast all day the night run still needs 1.5x (0.105 > 0.08)"
|
||||
": not worth, no charge, 'Prices differ too little'",
|
||||
present(lb) and lb["fc_none"] and lb["runs_on"] is False
|
||||
and lb["reason"] == "not_worth"
|
||||
and not any(r["charging"] for r in rb)
|
||||
and why_of(cb, A) == "no_spread" and why_of(cb, B) == "no_spread"
|
||||
and not any(ok for _t, ok, _k in pb))
|
||||
check("... and there is no day plan (Leftover solar takes solar only)",
|
||||
cb.day_plan is None and cb.plan_targets() is None
|
||||
and cb.block["state"] == "no_forecast")
|
||||
check("... Home's mirror refuses it too, for the gap ('not_worth')",
|
||||
cb.mirror is None and cb.mirror_why == "not_worth")
|
||||
check("... and nothing is saved for the night (no fresh reading was made)",
|
||||
cb.hold_saved is None or cb.hold_saved.get("r") == [])
|
||||
|
||||
# c. an evening restart
|
||||
cc, rc, pc = drive27(opts(), stops_at(LAST),
|
||||
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
|
||||
restart=at(DAY3, 21, 55))
|
||||
lc = nth(rc, -1, "last cycle")
|
||||
check("an evening restart (new Controller on the saved store) keeps the "
|
||||
"forecast: the night run is planned and charges",
|
||||
present(lc) and lc["kept"] and lc["runs_on"] is True
|
||||
and sum(r["charging"] for r in rc) > len(rc) // 2
|
||||
and present(nth(pc, -1, "last read")) and pc[-1][1] and pc[-1][2])
|
||||
cc0, rc0, pc0 = drive27(opts(), stops_at(LAST),
|
||||
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
|
||||
restart=at(DAY3, 21, 55), keep_store=False)
|
||||
lc0 = nth(rc0, -1, "last cycle")
|
||||
check("(control: the same restart with the store lost is blind, 1.5x)",
|
||||
present(lc0) and lc0["fc_none"] and lc0["runs_on"] is False
|
||||
and present(nth(pc0, -1, "last read")) and not pc0[-1][1])
|
||||
|
||||
# d. a one-hour outage at 18:47
|
||||
OUT = (at(DAY3, 18, 47), at(DAY3, 19, 47))
|
||||
quarter = [at(DAY3, 16, 0) + timedelta(minutes=15 * i) for i in range(24)]
|
||||
cd, rd, pd = drive27(opts(), stops_at(at(DAY3, 18, 5), down=[OUT]),
|
||||
pre=quarter + [at(DAY3, 21, 55)], start=NIGHT_START)
|
||||
ld = nth(rd, -1, "last cycle")
|
||||
inside = [(t_, ok) for t_, ok, _k in pd if OUT[0] <= t_ < OUT[1]]
|
||||
print(f" d. reads inside the outage: {len(inside)}, with a forecast: "
|
||||
f"{sum(ok for _t, ok in inside)}")
|
||||
check("a one-hour outage at 18:47 does not drop the forecast "
|
||||
"(every read from 16:00 to 22:00 has one)",
|
||||
present(pd) and len(inside) >= 4 and all(ok for _t, ok, _k in pd))
|
||||
check("... and the 22:00 night run is planned and charges",
|
||||
present(ld) and ld["runs_on"] is True
|
||||
and sum(r["charging"] for r in rd) > len(rd) // 2)
|
||||
|
||||
# e. the bound: until the bell starts (06:00, OPT-34; it was midnight + 3 h)
|
||||
mid = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
|
||||
ce, re_, pe = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 5, 30)],
|
||||
start=at(4, 5, 30))
|
||||
le = nth(re_, -1, "last cycle")
|
||||
check("05:30 (before the bell starts): the figure from 17:05 is still used, "
|
||||
"as today's; the night run is planned",
|
||||
present(le) and le["kept"] and le["runs_on"] is True)
|
||||
cf, rf, pf = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 6, 30)],
|
||||
start=at(4, 6, 30))
|
||||
lf = nth(rf, -1, "last cycle")
|
||||
check("06:30 (the bell has started): a figure from before the night is not "
|
||||
"used: no forecast, 1.5x, not worth, no charge",
|
||||
present(lf) and lf["fc_none"] and lf["runs_on"] is False
|
||||
and lf["reason"] == "not_worth" and not any(r["charging"] for r in rf))
|
||||
|
||||
# g. OPT-28's shortfall sizing on a kept figure: Leftover solar (A) is sized
|
||||
# from the kept forecast exactly as from a fresh one, not "no forecast"
|
||||
def fresh_all(now, ent):
|
||||
return st(0.4 if ent == T_ENT else 12.0, now)
|
||||
|
||||
|
||||
cg, rg, pg = drive27(opts(), fresh_all, pre=EVENING + [at(DAY3, 21, 55)],
|
||||
start=NIGHT_START)
|
||||
kp, fp_ = ca.day_plan, cg.day_plan
|
||||
print(f" g. kept : short={kp and kp.short_kwh} uncovered="
|
||||
f"{kp and kp.uncovered_kwh} targets={kp and kp.targets} "
|
||||
f"need={kp and kp.need_kwh} sun={kp and kp.sun_kwh}")
|
||||
print(f" fresh : short={fp_ and fp_.short_kwh} uncovered="
|
||||
f"{fp_ and fp_.uncovered_kwh} targets={fp_ and fp_.targets} "
|
||||
f"need={fp_ and fp_.need_kwh} sun={fp_ and fp_.sun_kwh}")
|
||||
check("a kept figure sizes the night by OPT-28's shortfall, with a level for "
|
||||
"Leftover solar (A): not 'no forecast', which has no plan at all",
|
||||
kp is not None and A in kp.targets and kp.short_kwh > 0.0
|
||||
and cb.day_plan is None and not cg.solar_fc.kept)
|
||||
check("... and the level, the grid need, the shortfall and the sun counted "
|
||||
"are those a fresh figure gives (counted once, from the right time)",
|
||||
fp_ is not None and kp.targets == fp_.targets
|
||||
and kp.need_kwh == fp_.need_kwh and kp.share_kwh == fp_.share_kwh
|
||||
and abs(kp.short_kwh - fp_.short_kwh) < 1e-9
|
||||
and abs(kp.uncovered_kwh - fp_.uncovered_kwh) < 1e-9
|
||||
and abs(kp.sun_kwh - fp_.sun_kwh) < 1e-9)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. The screens")
|
||||
blk = ca.block
|
||||
print(f" forecast line: {blk['line']!r} (state {blk['state']})")
|
||||
check("the forecast line says when it was read and that it is kept for "
|
||||
"tonight", blk["line"] == "Solar forecast read 17:05, kept for tonight.")
|
||||
check("the same line comes from the Forecast tab (dayview) on today",
|
||||
homeview.line_solar(0.4, 0.3, "17:05", kept=True) == blk["line"]
|
||||
and homeview.line_solar(0.4, 0.3, "17:05").startswith(
|
||||
"Solar still to come today"))
|
||||
en = json.load(open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
||||
encoding="utf-8"))
|
||||
nl = json.load(open(os.path.join(HERE, "app", "ui_text", "nl.json"),
|
||||
encoding="utf-8"))
|
||||
k_en = en["home_words"]["solar"]["kept"]
|
||||
k_nl = nl["home_words"]["solar"]["kept"]
|
||||
print(f" EN {k_en!r}\n NL {k_nl!r}")
|
||||
check("EN and NL both say it, with the time, and the NL is not the English",
|
||||
"{at}" in k_en and "{at}" in k_nl and k_en != k_nl
|
||||
and "tonight" in k_en and "vannacht" in k_nl)
|
||||
check("a daytime reading keeps the old line (daytime unchanged)",
|
||||
"Updated 09:05" in homeview.line_solar(1.0, 0.7, "09:05")
|
||||
and "Bijgewerkt" in json.dumps(nl["home_words"]["solar"]["line"]))
|
||||
check("no forecast: the old 'No solar forecast now' line, no 'kept'",
|
||||
cb.block["state"] == "no_forecast"
|
||||
and "tonight" not in cb.block["line"])
|
||||
check("control and Batteries screen agree on the 22:00 run: not 'no_spread'",
|
||||
why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread"
|
||||
and la["runs_on"] is True)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,747 @@
|
||||
"""Runnable check for OPT-34. `python3 test_opt34.py`
|
||||
|
||||
OPT-27 keeps the last fresh Forecast.Solar figure over the night. With the
|
||||
sensors silent from 17:05 it ended at local midnight + 3 h, in the middle of the
|
||||
night grid charge: Leftover solar's planned level went with it and the charge
|
||||
stopped (rig, 2026-10-31: battery A 49.8 % at 07:00 against 79.9 % with a fresh
|
||||
figure). Forecast.Solar may not refresh after midnight on a live site, so the
|
||||
add-on must not depend on it. OPT-34:
|
||||
|
||||
1. a night charge that has started keeps its planned level when the forecast
|
||||
goes (`Controller._latch_charge()`), and the next slice of the run is planned
|
||||
with it (the wide no-forecast gap does not refuse it);
|
||||
2. the evening's "tomorrow" figure is today's until the bell starts (06:00
|
||||
local, `BELL_FROM_H`) instead of until midnight + 3 h;
|
||||
3. for that case only, a reading may be up to 15 h old (a 17:05 reading is
|
||||
12 h 55 min old at 06:00), but only if it was at most 12 h old at midnight;
|
||||
4. a fresh figure replaces the kept one at once.
|
||||
|
||||
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file): see the
|
||||
DECISIONS note and the dev report.
|
||||
|
||||
1. `solarfc` alone: the window, the age limits, the day, DST, restart.
|
||||
2. The real Controller, the owner's shape (modes on, arbitrage off, A Leftover
|
||||
solar + B Cheap hours, both 10 % at the start of the night, the night rate
|
||||
22:00-07:00, tomorrow 6 kWh, a 2.5 kW peak limit so the charge takes hours),
|
||||
sensors silent from 17:05, whole nights at 5 s per cycle (run in parallel
|
||||
processes, about 2.5 minutes each):
|
||||
a. silent from 17:05: both batteries end the night where a fresh figure
|
||||
ends them (the base left A at 57 % against 87 %), and the screens agree;
|
||||
b. restarts at 01:00 and 04:00 change nothing;
|
||||
c. a charge that cannot start before 03:00 (the price dips at 04:00) still
|
||||
starts at 04:00 on the kept figure;
|
||||
d. a fresh figure at 02:00 replaces the level at once, up, during the charge;
|
||||
and before a charge has started, up and down;
|
||||
e. the last hour, after the kept figure has ended at 06:00: the charge under
|
||||
way goes on to its level, also over a restart; the screens say so.
|
||||
3. The text, EN and NL.
|
||||
4. `_latch_charge()` alone: a battery that leaves the plan leaves the latch.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from multiprocessing import get_context
|
||||
|
||||
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
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
|
||||
|
||||
|
||||
def at(day, hh, mm=0):
|
||||
"""Local time on 2026-11-`day` (CET) as UTC."""
|
||||
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def st(kwh, when, *, unit="kWh"):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
|
||||
"last_updated": when.isoformat(),
|
||||
"last_reported": when.isoformat()}
|
||||
|
||||
|
||||
GONE = {"state": "unavailable", "attributes": {}}
|
||||
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
|
||||
BRU = solarfc.BILLING_TZ
|
||||
|
||||
|
||||
def bru(y, mo, d, hh, mm=0):
|
||||
return datetime(y, mo, d, hh, mm, tzinfo=BRU).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def rs(today, tomorrow, now, hold):
|
||||
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
|
||||
|
||||
|
||||
def near(a, b, tol):
|
||||
"""`|a - b| < tol`, and False (not a crash) when `a` is missing."""
|
||||
return a is not None and abs(a - b) < tol
|
||||
|
||||
|
||||
def used(hold, now):
|
||||
f = rs(GONE, GONE, now, hold)[0]
|
||||
return f is not None
|
||||
|
||||
|
||||
def hold_of(read_at, today=0.4, tomorrow=12.0):
|
||||
h = solarfc.Hold()
|
||||
rs(st(today, read_at), st(tomorrow, read_at),
|
||||
read_at + timedelta(minutes=5), h)
|
||||
return h
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. solarfc: the evening figure serves until the bell starts")
|
||||
h17 = hold_of(at(3, 17, 5))
|
||||
check("a 17:05 reading is kept at 20:00, 22:00, 00:00, 03:00 and 05:59",
|
||||
all(used(h17, t) for t in (at(3, 20, 0), at(3, 22, 0), at(4, 0, 0),
|
||||
at(4, 3, 0), at(4, 5, 59))))
|
||||
f_late = rs(GONE, GONE, at(4, 5, 59), h17)[0]
|
||||
check("... at 05:59 it is tomorrow's 12.0 kWh as TODAY's, the whole day",
|
||||
f_late is not None and f_late.kept and f_late.today.kwh == 12.0
|
||||
and f_late.tomorrow is None and f_late.at == at(3, 17, 5)
|
||||
and f_late.between(at(4, 6, 0), at(4, 20, 0)) is not None
|
||||
and abs(f_late.between(at(4, 6, 0), at(4, 20, 0)) - 12.0) < 1e-6)
|
||||
check("... and at 06:00 (12 h 55 min old, the bell starts) it is not",
|
||||
not used(h17, at(4, 6, 0)) and not used(h17, at(4, 6, 30))
|
||||
and not used(h17, at(4, 12, 0)))
|
||||
check("... not the next evening either, and not on the 5th",
|
||||
not used(h17, at(4, 22, 0)) and not used(h17, at(5, 3, 0)))
|
||||
|
||||
# The age limit of this case: 15 h, for a figure that was usable at midnight.
|
||||
h1230 = hold_of(at(3, 12, 30))
|
||||
check("a 12:30 reading (11 h 30 min old at midnight): used until 03:29 "
|
||||
"(14 h 59 min old) ...",
|
||||
used(h1230, at(4, 0, 0)) and used(h1230, at(4, 3, 29)))
|
||||
check("... and not from 03:31 (15 h 01 min), though it is before 06:00",
|
||||
not used(h1230, at(4, 3, 31)) and not used(h1230, at(4, 5, 30)))
|
||||
h1600 = hold_of(at(3, 16, 0))
|
||||
check("a 16:00 reading: the 06:00 window ends it, not the 15 h limit "
|
||||
"(14 h old at 06:00)", used(h1600, at(4, 5, 59))
|
||||
and not used(h1600, at(4, 6, 0)))
|
||||
|
||||
# Not used: a morning figure at night (12 h), and no come-back after midnight.
|
||||
h0905 = hold_of(at(3, 9, 5))
|
||||
check("a 09:05 reading: kept at 20:00 (10 h 55 min), not at 22:00 (12 h 55 min)",
|
||||
used(h0905, at(3, 20, 0)) and not used(h0905, at(3, 22, 0)))
|
||||
check("... and it does not come back after midnight (14 h 55 min at 00:00, "
|
||||
"under the 15 h of the evening-to-morning case): it was not usable "
|
||||
"at midnight",
|
||||
not used(h0905, at(4, 0, 0)) and not used(h0905, at(4, 0, 2))
|
||||
and not used(h0905, at(4, 2, 0)))
|
||||
h1105 = hold_of(at(3, 11, 59))
|
||||
check("the same for a reading that is 12 h 01 min old at midnight",
|
||||
not used(h1105, at(4, 0, 0)) and not used(h1105, at(4, 0, 30)))
|
||||
h1201 = hold_of(at(3, 12, 1))
|
||||
check("... and one that is 11 h 59 min old at midnight is used",
|
||||
used(h1201, at(4, 0, 0)) and used(h1201, at(4, 3, 0)))
|
||||
check("a same-day figure past 12 h is not used: a 07:05 reading at 20:00 "
|
||||
"(12 h 55 min)", not used(hold_of(at(3, 7, 5)), at(3, 20, 0)))
|
||||
check("a figure over 15 h old is not used: 17:05 read, 08:06 next day "
|
||||
"(15 h 01 min, bell started)", not used(h17, at(4, 8, 6)))
|
||||
check("(a figure from the day before is not used either)",
|
||||
not used(hold_of(at(2, 17, 5)), at(3, 22, 0))
|
||||
and not used(hold_of(at(2, 17, 5)), at(4, 3, 0)))
|
||||
|
||||
# Today's figure is not tomorrow's, and the figure is for its own day only.
|
||||
hd = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), hd)
|
||||
fx = rs(GONE, GONE, at(4, 4, 0), hd)[0]
|
||||
check("04:00: today's sensor is gone: the 4th gets the 3rd's TOMORROW "
|
||||
"figure (12.0), never the 3rd's today figure (3.0)",
|
||||
fx is not None and fx.today.kwh == 12.0)
|
||||
fy = rs(st(5.0, at(4, 3, 50)), st(9.0, at(4, 3, 50)), at(4, 4, 0), hd)[0]
|
||||
check("a fresh figure replaces the kept one at once (04:00: 5.0 today, "
|
||||
"9.0 tomorrow; not kept)", fy is not None and not fy.kept
|
||||
and fy.today.kwh == 5.0 and fy.tomorrow.kwh == 9.0)
|
||||
fy2 = rs(st(5.0, at(4, 7, 55)), GONE, at(4, 8, 0), hd)[0]
|
||||
check("... and at 08:00, today fresh and tomorrow's sensor down for over 3 h: "
|
||||
"no tomorrow (the 3rd's figure is not the 5th's)",
|
||||
fy2 is not None and fy2.tomorrow is None and fy2.today.kwh == 5.0)
|
||||
|
||||
# Restart: the saved hold keeps the figure.
|
||||
for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
|
||||
back = solarfc.Hold(json.loads(json.dumps(hold_of(at(3, 17, 5)).dump())))
|
||||
fr = rs(GONE, GONE, now, back)[0]
|
||||
check(f"restart at {label}: the saved hold (FC_HOLD_KEY) gives the "
|
||||
"17:05 figure again", fr is not None and fr.kept
|
||||
and fr.today.kwh == 12.0 and fr.at == at(3, 17, 5))
|
||||
|
||||
# DST: the 25 h autumn day and the 23 h spring day. The window ends at 06:00
|
||||
# LOCAL, not 6 h after midnight.
|
||||
for (y, mo, d), label in (((2026, 10, 25), "25 h day 2026-10-25"),
|
||||
((2027, 3, 28), "23 h day 2027-03-28")):
|
||||
t17 = bru(y, mo, d - 1, 17, 5)
|
||||
hs = hold_of(t17)
|
||||
ok_in = used(hs, bru(y, mo, d - 1, 23, 59)) and all(
|
||||
used(hs, bru(y, mo, d, hh, mm)) for hh, mm in
|
||||
((0, 0), (1, 30), (4, 0), (5, 59)))
|
||||
check(f"{label}: the 17:05 figure serves 23:59 to 05:59 local", ok_in)
|
||||
check(f"... and not at 06:00 local", not used(hs, bru(y, mo, d, 6, 0))
|
||||
and not used(hs, bru(y, mo, d, 6, 30)))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. The real Controller, whole nights (parallel processes)")
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.26},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.34}]}')
|
||||
# The price dips at 04:00 (a dynamic site): the cheap run starts after 03:00.
|
||||
PROFILE_LATE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00",'
|
||||
' "to": "04:00", "eur_per_kwh": 0.28},'
|
||||
'{"name": "dawn", "days": "all", "from": "04:00",'
|
||||
' "to": "07:00", "eur_per_kwh": 0.20},'
|
||||
'{"name": "day", "days": "all", "from": "07:00",'
|
||||
' "to": "22:00", "eur_per_kwh": 0.34}]}')
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
LAST = at(3, 17, 5)
|
||||
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
|
||||
+ [at(3, 21, 55)]
|
||||
|
||||
|
||||
def opts(late=False, cap=2500):
|
||||
o = dict(rp.options(True, False, 2))
|
||||
o.update(cycle_cost_threshold_eur=0.07,
|
||||
static_profile=PROFILE_LATE if late else PROFILE,
|
||||
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT,
|
||||
peak_cap_w=cap)
|
||||
return o
|
||||
|
||||
|
||||
def sensors(*, silent=True, fresh_from=None, fresh=(1.0, 5.0),
|
||||
fresh_until=None):
|
||||
"""Sensors that update until 17:05 (or all night) and then show that
|
||||
reading for good; from `fresh_from` a new reading every call."""
|
||||
def f(now, ent):
|
||||
if fresh_from is not None and now >= fresh_from:
|
||||
when = now if fresh_until is None else min(now, fresh_until)
|
||||
return st(fresh[0] if ent == T_ENT else fresh[1], when)
|
||||
when = min(now, LAST) if silent else now
|
||||
return st(0.4 if ent == T_ENT else 6.0, when)
|
||||
return f
|
||||
|
||||
|
||||
def run_night(spec):
|
||||
"""One scenario in this process -> a plain dict. `spec`: start (UTC),
|
||||
hours, silent, fresh_from, fresh, restarts, soc, load_until_s, marks."""
|
||||
start = spec["start"]
|
||||
soc0 = spec.get("soc", {"a": 10.0, "b": 10.0})
|
||||
house_w = spec.get("house_w", 700.0)
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in CHOICE.items():
|
||||
for k, val in (("pack_use", use), ("pack_mode", mode),
|
||||
("pack_sell", sell)):
|
||||
store.data[unit_key(f"inverter-{u}", k)] = val
|
||||
if spec.get("latch"):
|
||||
store.data[m.CHARGE_LATCH_KEY] = spec["latch"]
|
||||
sens = sensors(silent=spec.get("silent", True),
|
||||
fresh_from=spec.get("fresh_from"),
|
||||
fresh=spec.get("fresh", (1.0, 5.0)),
|
||||
fresh_until=spec.get("fresh_until"))
|
||||
clock = [EVENING[0]]
|
||||
|
||||
class H(rp.Hass):
|
||||
async def state(self, e):
|
||||
return GONE if spec.get("gone") else sens(clock[0], e)
|
||||
|
||||
hass = H(("a", "b"), lambda t: 300.0, soc0)
|
||||
out = dict(marks={}, soc_at={})
|
||||
|
||||
def build(sto):
|
||||
tmp = tempfile.mkdtemp(prefix="o34-")
|
||||
c = Controller(dict(opts(spec.get("late", False),
|
||||
spec.get("cap", 2500))), hass, sto,
|
||||
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
|
||||
c.house_profile.load({d: {str(hh): house_w for hh in range(24)}
|
||||
for d in ("2026-10-31", "2026-11-01",
|
||||
"2026-11-02")})
|
||||
return c
|
||||
|
||||
async def go():
|
||||
cur = store
|
||||
c = build(cur)
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
try:
|
||||
for t_pre in EVENING:
|
||||
clock[0] = t_pre
|
||||
await c._read_forecast()
|
||||
pending = sorted(spec.get("restarts", ()))
|
||||
marks = sorted(spec.get("marks", ()))
|
||||
first, t = True, 0.0
|
||||
hz = spec["hours"] * 3600.0
|
||||
while t < hz:
|
||||
now = start + timedelta(seconds=t)
|
||||
clock[0] = now
|
||||
if pending and now >= pending[0]:
|
||||
pending.pop(0)
|
||||
data = json.loads(json.dumps(cur.data))
|
||||
cur = rp._Store()
|
||||
cur.data.update(data)
|
||||
c = build(cur)
|
||||
first = True
|
||||
if first or int(t) % 600 == 0:
|
||||
# The tariff, resolved here at its own pace: the real
|
||||
# refresh task sleeps on the wall clock.
|
||||
hor = float(m.price_option(c.o, "price_horizon_h"))
|
||||
c.tariff_plan = await m.resolve_tariff(
|
||||
c.o, now, now + timedelta(hours=hor), now=now,
|
||||
history=c.price_history, watch=c.price_watch)
|
||||
c.tariff_at = now
|
||||
first = False
|
||||
hass.t = t
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(5.0, 10.0)
|
||||
if int(t) % 300 == 0:
|
||||
out["soc_at"][now.astimezone(L).strftime("%H:%M")] = (
|
||||
hass.plants["a"].soc, hass.plants["b"].soc,
|
||||
hass.plants["a"].p, c.solar_fc is None,
|
||||
bool(c.solar_fc and c.solar_fc.kept),
|
||||
None if c.plan_targets() is None
|
||||
else c.plan_targets().get(A))
|
||||
while marks and now >= marks[0]:
|
||||
marks.pop(0)
|
||||
cards = homeview.battery_rows(c, False, True)
|
||||
blk = homeview.solar_block(c, now)
|
||||
a_card = next(b for b in cards if b["name"] == A)
|
||||
out["marks"][now.astimezone(L).strftime("%H:%M")] = dict(
|
||||
line=blk["line"], state=blk["state"],
|
||||
why_a=a_card["why_key"],
|
||||
fc_none=c.solar_fc is None,
|
||||
kept=bool(c.solar_fc and c.solar_fc.kept),
|
||||
target_a=(c.plan_targets() or {}).get(A),
|
||||
soc_a=hass.plants["a"].soc,
|
||||
p_a=hass.plants["a"].p,
|
||||
charging=hass.plants["b"].p < -200.0)
|
||||
t += 5.0
|
||||
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
|
||||
out["latch"] = getattr(c, "_charge_latch", None) is not None
|
||||
finally:
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
return out
|
||||
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
NIGHT = at(3, 22, 0)
|
||||
SCEN = {
|
||||
"fresh": dict(start=NIGHT, hours=9, silent=False,
|
||||
marks=[at(4, 4, 30)]),
|
||||
"silent": dict(start=NIGHT, hours=9,
|
||||
marks=[at(4, 1, 0), at(4, 4, 30), at(4, 6, 30)]),
|
||||
"restart": dict(start=NIGHT, hours=9,
|
||||
restarts=[at(4, 1, 0), at(4, 4, 0)],
|
||||
marks=[at(4, 1, 30), at(4, 4, 30)]),
|
||||
# c. the charge cannot start before 03:00 (the price dips at 04:00)
|
||||
"late": dict(start=at(4, 2, 30), hours=4.5, late=True, house_w=500.0,
|
||||
soc={"a": 40.0, "b": 70.0}, marks=[at(4, 3, 30)]),
|
||||
"late_fresh": dict(start=at(4, 2, 30), hours=4.5, late=True,
|
||||
house_w=500.0, soc={"a": 40.0, "b": 70.0},
|
||||
silent=False),
|
||||
# d. a fresh, darker figure at 02:00 during the charge (a lighter house
|
||||
# use, so the level has room to rise)
|
||||
"dark": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
|
||||
house_w=500.0, fresh_from=at(4, 2, 0), fresh=(1.0, 5.0),
|
||||
marks=[at(4, 1, 59), at(4, 2, 5)]),
|
||||
"kept": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
|
||||
house_w=500.0, marks=[at(4, 1, 59), at(4, 2, 5)]),
|
||||
# before the charge starts: a fresh figure replaces the kept one, up and
|
||||
# down, at once
|
||||
"pre_kept": dict(start=at(4, 2, 30), hours=0.25, late=True,
|
||||
house_w=500.0, marks=[at(4, 2, 44)]),
|
||||
"pre_dark": dict(start=at(4, 2, 30), hours=0.25, late=True,
|
||||
house_w=500.0, fresh_from=at(4, 2, 40),
|
||||
fresh=(1.0, 5.0), marks=[at(4, 2, 44)]),
|
||||
"pre_bright": dict(start=at(4, 2, 30), hours=0.25, late=True,
|
||||
house_w=500.0, fresh_from=at(4, 2, 40),
|
||||
fresh=(30.0, 30.0), marks=[at(4, 2, 44)]),
|
||||
# d. the last hour, after the kept figure ended at 06:00
|
||||
"tail": dict(start=at(4, 5, 40), hours=80 / 60.0,
|
||||
soc={"a": 70.0, "b": 70.0}, restarts=[at(4, 6, 30)],
|
||||
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 40)]),
|
||||
# a fresh, brighter figure at 03:10 lowers A's level below its SoC (A
|
||||
# stops, B goes on), then the sensors go quiet: the forecast ends at 06:10
|
||||
"lowered": dict(start=at(4, 2, 30), hours=4.5, house_w=500.0,
|
||||
soc={"a": 30.0, "b": 30.0}, fresh_from=at(4, 3, 10),
|
||||
fresh_until=at(4, 3, 10), fresh=(30.0, 30.0),
|
||||
marks=[at(4, 3, 5), at(4, 3, 20), at(4, 6, 30)]),
|
||||
# F1: the add-on was down when the latch of the night before ended; it is
|
||||
# back inside a cheap run, with the sensors unavailable (no forecast)
|
||||
"stale": dict(start=at(3, 22, 0), hours=0.5, gone=True,
|
||||
latch={"until": at(3, 7, 15).isoformat(),
|
||||
"born": at(2, 23, 0).isoformat(),
|
||||
"levels": {A: 80.0, B: 80.0}},
|
||||
marks=[at(3, 22, 20)]),
|
||||
"stale_live": dict(start=at(3, 22, 0), hours=0.5, gone=True,
|
||||
latch={"until": at(4, 7, 15).isoformat(),
|
||||
"born": at(3, 22, 0).isoformat(),
|
||||
"levels": {A: 80.0, B: 80.0}},
|
||||
marks=[at(3, 22, 20)]),
|
||||
# the same, B already full and a 1.5 kW limit: the run is sized at the
|
||||
# battery's 2.5 kW rating and ends at 06:33, before A is there, so the
|
||||
# next slice is planned after 06:00 and is only A's
|
||||
"tail_bfull": dict(start=at(4, 5, 40), hours=80 / 60.0, cap=1500,
|
||||
soc={"a": 65.0, "b": 95.0},
|
||||
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 45)]),
|
||||
}
|
||||
t0 = time.time()
|
||||
with ProcessPoolExecutor(max_workers=min(6, os.cpu_count() or 2),
|
||||
mp_context=get_context("fork")) as pool:
|
||||
futs = {k: pool.submit(run_night, v) for k, v in SCEN.items()}
|
||||
R = {k: f.result() for k, f in futs.items()}
|
||||
print(f" {len(R)} scenarios in {time.time() - t0:.0f} s")
|
||||
|
||||
|
||||
def show(k):
|
||||
r = R[k]
|
||||
print(f" {k:10} A {r['soc'][0]:.2f} % B {r['soc'][1]:.2f} % "
|
||||
f"marks {', '.join(sorted(r['marks']))}")
|
||||
|
||||
|
||||
for k in R:
|
||||
show(k)
|
||||
fa, si, rr = R["fresh"], R["silent"], R["restart"]
|
||||
|
||||
# a. silent from 17:05
|
||||
check("a fresh figure all night: A ends at its planned level (87 %), B "
|
||||
"full", abs(fa["soc"][0] - 86.9) < 0.5 and fa["soc"][1] > 94.5)
|
||||
check("sensors silent from 17:05: A ends where the fresh run ends it "
|
||||
f"({si['soc'][0]:.1f} against {fa['soc'][0]:.1f} %)",
|
||||
abs(si["soc"][0] - fa["soc"][0]) < 0.3)
|
||||
check("... and so does B (Cheap hours; the wide no-forecast gap does not "
|
||||
f"stop its slices): {si['soc'][1]:.1f} against {fa['soc'][1]:.1f} %",
|
||||
abs(si["soc"][1] - fa["soc"][1]) < 0.3)
|
||||
check("A charges through 03:00 without a break (no step at 03:00)",
|
||||
si["soc_at"]["03:30"][0] > si["soc_at"]["03:00"][0] + 4.0
|
||||
and si["soc_at"]["03:00"][0] > si["soc_at"]["02:55"][0] + 0.3)
|
||||
m1, m4 = si["marks"]["01:00"], si["marks"]["04:30"]
|
||||
check("01:00 and 04:30: the kept figure is in use, with a day plan, and the "
|
||||
"line says so (EN)",
|
||||
not m1["fc_none"] and m1["kept"] and not m4["fc_none"] and m4["kept"]
|
||||
and m4["state"] == "plan"
|
||||
and m4["line"] == "Solar forecast read 17:05, kept for tonight."
|
||||
and m4["target_a"] is not None
|
||||
and near(m4["target_a"], fa["marks"]["04:30"]["target_a"], 1e-9))
|
||||
check("... and the level is the fresh run's (the kept figure counts as a "
|
||||
"fresh one)", near(m4["target_a"], 86.87, 0.2))
|
||||
m6 = si["marks"]["06:30"]
|
||||
check("06:30: the kept figure has ended (the bell started) and the screen "
|
||||
"does not say 'kept' (the charge was done by then in this shape)",
|
||||
m6["fc_none"] and "kept" not in m6["line"])
|
||||
|
||||
# b. restarts
|
||||
check("restarts at 01:00 and 04:00 (a new Controller on the saved store): "
|
||||
f"A {rr['soc'][0]:.1f}, B {rr['soc'][1]:.1f} % as without them",
|
||||
abs(rr["soc"][0] - si["soc"][0]) < 0.3
|
||||
and abs(rr["soc"][1] - si["soc"][1]) < 0.3)
|
||||
check("... with the figure kept right after each restart",
|
||||
all(rr["marks"][k]["kept"] and not rr["marks"][k]["fc_none"]
|
||||
and rr["marks"][k]["target_a"] is not None
|
||||
for k in ("01:30", "04:30")))
|
||||
|
||||
# c. a charge that cannot start before 03:00
|
||||
la, lf = R["late"], R["late_fresh"]
|
||||
ml = la["marks"]["03:30"]
|
||||
check("the cheap hours start at 04:00: nothing charges at 03:30",
|
||||
ml["soc_a"] < 40.1 and not ml["charging"])
|
||||
check("... the kept figure still stands at 03:30 (no charge had started, so "
|
||||
"no level to latch): the plan is kept, not 'no forecast'",
|
||||
not ml["fc_none"] and ml["kept"] and ml["target_a"] is not None)
|
||||
check("... and the charge starts at 04:00 on it: A ends where a fresh "
|
||||
f"figure ends it ({la['soc'][0]:.1f} against {lf['soc'][0]:.1f} %)",
|
||||
lf["soc"][0] > 50.0 and abs(la["soc"][0] - lf["soc"][0]) < 0.3
|
||||
and abs(la["soc"][1] - lf["soc"][1]) < 0.3)
|
||||
|
||||
# d. a fresh figure replaces the kept one at once
|
||||
dk, kp = R["dark"], R["kept"]
|
||||
check("a fresh, darker figure at 02:00 during the charge: the level goes "
|
||||
"UP at once (02:05)",
|
||||
kp["marks"]["02:05"]["target_a"] is not None
|
||||
and dk["marks"]["02:05"]["target_a"] is not None
|
||||
and dk["marks"]["02:05"]["target_a"] > kp["marks"]["02:05"]["target_a"]
|
||||
+ 3.0 and not dk["marks"]["02:05"]["kept"]
|
||||
and dk["marks"]["01:59"]["kept"])
|
||||
check("... and A charges on to it (more than on the kept figure: "
|
||||
f"{dk['soc'][0]:.1f} against {kp['soc'][0]:.1f} %)",
|
||||
dk["soc"][0] > kp["soc"][0] + 3.0)
|
||||
pk, pd, pb = R["pre_kept"], R["pre_dark"], R["pre_bright"]
|
||||
t_k, t_d, t_b = (x["marks"]["02:44"]["target_a"] for x in (pk, pd, pb))
|
||||
print(f" level before any charge, 02:44: kept {t_k}, dark {t_d}, "
|
||||
f"bright {t_b}")
|
||||
check("before a charge has started (nothing charges at 02:44): a darker "
|
||||
"fresh figure raises the level and a brighter one lowers it, at once",
|
||||
all(not x["marks"]["02:44"]["charging"] for x in (pk, pd, pb))
|
||||
and t_k is not None and t_d is not None and t_b is not None
|
||||
and t_d > t_k + 3.0 and t_b < t_k - 3.0
|
||||
and not pd["marks"]["02:44"]["kept"]
|
||||
and not pb["marks"]["02:44"]["kept"])
|
||||
|
||||
# e. the last hour, after the kept figure ended
|
||||
tl = R["tail"]
|
||||
t5, t6, t7 = (tl["marks"][k] for k in ("05:50", "06:10", "06:40"))
|
||||
check("05:50: charging on the kept figure; 06:10: the figure has ended",
|
||||
t5["kept"] and not t5["fc_none"] and t6["fc_none"])
|
||||
check("06:10 and 06:40 (the second after a restart at 06:30): A still "
|
||||
"charges to its level, 'charges from the grid' is the Why",
|
||||
t6["p_a"] < -500.0 and t7["p_a"] < -500.0
|
||||
and t6["why_a"] == "grid_charge" and t7["why_a"] == "grid_charge")
|
||||
check("... and ends the hour higher than it was (+8 % or more from 06:00)",
|
||||
tl["soc_at"]["06:55"][0] > tl["soc_at"]["06:00"][0] + 8.0)
|
||||
lo = R["lowered"]
|
||||
l0, l1, l2 = (lo["marks"][k] for k in ("03:05", "03:20", "06:30"))
|
||||
check("a fresh, brighter figure lowers A's level below its SoC during the "
|
||||
f"charge ({l0['target_a']} -> {l1['target_a']}, SoC "
|
||||
f"{l1['soc_a']:.1f} %), and A stops",
|
||||
l0["target_a"] is not None and l1["target_a"] is not None
|
||||
and l1["target_a"] < l1["soc_a"] - 2.0
|
||||
and l1["target_a"] < l0["target_a"] - 10.0
|
||||
and lo["soc_at"]["03:30"][0] < lo["soc_at"]["03:20"][0] + 0.5)
|
||||
check("the sensors go quiet, the forecast ends (06:30): the latch holds the "
|
||||
"LOWERED level, so A does not charge back up to the old one "
|
||||
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %), "
|
||||
"while B goes on",
|
||||
l2["fc_none"] and l2["target_a"] is not None
|
||||
and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0
|
||||
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0
|
||||
and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.0)
|
||||
st_, sl_ = R["stale"], R["stale_live"]
|
||||
check("F1: a latch from the night before (ended 07:15) loaded at a restart "
|
||||
"inside a cheap run, with no forecast: nothing grid-charges "
|
||||
f"(A {st_['soc'][0]:.1f}, B {st_['soc'][1]:.1f} %) and the line does "
|
||||
"not say the charge goes on",
|
||||
st_["soc"][0] < 10.2 and st_["soc"][1] < 10.2
|
||||
and st_["marks"]["22:20"]["state"] == "no_forecast"
|
||||
and not st_["latch"])
|
||||
check("(control: the same latch, still valid until tomorrow 07:15, does "
|
||||
f"charge both: A {sl_['soc'][0]:.1f}, B {sl_['soc'][1]:.1f} %)",
|
||||
sl_["soc"][0] > 11.0 and sl_["soc"][1] > 11.0
|
||||
and sl_["marks"]["22:20"]["state"] == "latched")
|
||||
tb = R["tail_bfull"]
|
||||
check("B full: the run ends at 06:33 before A is at its level, so the next "
|
||||
"slice is only A's, planned after the figure has ended with A's "
|
||||
"latched level: A goes on charging (06:10, 06:45; "
|
||||
f"{tb['soc_at']['06:00'][0]:.1f} to {tb['soc_at']['06:55'][0]:.1f} %)",
|
||||
tb["marks"]["06:10"]["fc_none"] and tb["marks"]["06:10"]["p_a"] < -500.0
|
||||
and tb["marks"]["06:45"]["p_a"] < -500.0
|
||||
and tb["soc_at"]["06:55"][0] > tb["soc_at"]["06:00"][0] + 8.0)
|
||||
check("the screens say what control does (EN): 'No solar forecast now. The "
|
||||
"charge under way goes on to its planned level.'",
|
||||
t6["state"] == "latched" and t7["state"] == "latched"
|
||||
and t6["line"] == "No solar forecast now. The charge under way goes "
|
||||
"on to its planned level.")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. The text (EN and NL)")
|
||||
en = json.load(open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
||||
encoding="utf-8"))
|
||||
nl = json.load(open(os.path.join(HERE, "app", "ui_text", "nl.json"),
|
||||
encoding="utf-8"))
|
||||
l_en, l_nl = (x["home_words"]["solar"]["latched"] for x in (en, nl))
|
||||
print(f" EN {l_en!r}\n NL {l_nl!r}")
|
||||
check("the new line is short and plain in both languages and the NL is not "
|
||||
"the English", l_en != l_nl and len(l_en.split()) <= 24
|
||||
and len(l_nl.split()) <= 24 and "{" not in l_en + l_nl)
|
||||
check("'kept for tonight' is unchanged (EN and NL)",
|
||||
en["home_words"]["solar"]["kept"]
|
||||
== "Solar forecast read {at}, kept for tonight."
|
||||
and nl["home_words"]["solar"]["kept"]
|
||||
== "Zonneprognose van {at}, bewaard voor vannacht.")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. The latch alone: a battery that leaves the plan leaves the latch")
|
||||
from types import SimpleNamespace # noqa: E402
|
||||
from app import modetrade, priceruns # noqa: E402
|
||||
|
||||
CLOCK = [at(4, 1, 0)]
|
||||
_dt0 = m.datetime
|
||||
|
||||
|
||||
class _FixedDT(_dt0):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return CLOCK[0] if tz is None else CLOCK[0].astimezone(tz)
|
||||
|
||||
|
||||
m.datetime = homeview.datetime = _FixedDT
|
||||
_tmp = tempfile.mkdtemp(prefix="o34-")
|
||||
lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0,
|
||||
{"a": 40.0, "b": 40.0}),
|
||||
rp._Store(), rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(_tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(_tmp, "ph.json"))
|
||||
n0 = at(4, 1, 0)
|
||||
lruns = SimpleNamespace(cheap=priceruns.Run(at(3, 22, 0), at(4, 7, 0), 0.26))
|
||||
charging = SimpleNamespace(action=modetrade.CHARGE,
|
||||
roles={A: modetrade.CHARGE, B: modetrade.CHARGE})
|
||||
resting = SimpleNamespace(action=None, roles={A: modetrade.HOLD,
|
||||
B: modetrade.HOLD})
|
||||
|
||||
|
||||
def latch_step(fc, tick, targets, now=n0, runs=None, packs=None):
|
||||
CLOCK[0] = now
|
||||
lc.solar_fc, lc.mode_tick = fc, tick
|
||||
lc._latch_charge(now, runs or lruns, targets, packs)
|
||||
return lc._charge_latch, lc.store.data.get(m.CHARGE_LATCH_KEY)
|
||||
|
||||
|
||||
FC = object() # a forecast is read (fresh or kept)
|
||||
lt, saved = latch_step(FC, charging, {A: 80.0, B: 70.0})
|
||||
check("both batteries charge with a forecast: both are latched at their "
|
||||
"levels, to the run's end + one slot, and saved",
|
||||
lt is not None and lt[1] == {A: 80.0, B: 70.0}
|
||||
and lt[0] == at(4, 7, 15) and saved is not None
|
||||
and saved["levels"] == {A: 80.0, B: 70.0})
|
||||
lt, saved = latch_step(FC, charging, {B: 70.0})
|
||||
check("A leaves the plan (its mode or use changed): A is dropped from the "
|
||||
"latch at once, B stays", lt is not None and lt[1] == {B: 70.0}
|
||||
and saved["levels"] == {B: 70.0})
|
||||
lc.solar_fc = None
|
||||
check("... and with the forecast then gone, A has no latched level (it is "
|
||||
"not charged back to 80 %)", lc.plan_targets() == {B: 70.0})
|
||||
lt, saved = latch_step(FC, resting, {A: 80.0, B: 70.0})
|
||||
check("A back in the plan while nothing charges: it does not rejoin",
|
||||
lt is not None and lt[1] == {B: 70.0})
|
||||
lt, saved = latch_step(FC, resting, {A: 80.0})
|
||||
check("B leaves the plan too: the latch is empty, so it is gone and "
|
||||
"cleared from the store", lt is None and saved is None
|
||||
and lc.plan_targets() is None)
|
||||
latch_step(FC, charging, {A: 80.0})
|
||||
lc.solar_fc = None
|
||||
ia = next((i for i in lc.inverters if i.name == A), None)
|
||||
st_below = st_at = st_after = None
|
||||
lc.mode_runs = lruns
|
||||
if ia is not None:
|
||||
ia.soc = 50.0
|
||||
st_below = homeview.plan_state(lc)
|
||||
CLOCK[0] = at(4, 7, 5) # the run has ended, the latch has not
|
||||
st_after = homeview.plan_state(lc)
|
||||
CLOCK[0] = n0
|
||||
ia.soc = 80.0
|
||||
st_at = homeview.plan_state(lc)
|
||||
check("the screen says the charge goes on only while the latched battery is "
|
||||
f"below its level ({st_below!r} at 50 %, {st_at!r} at 80 %)",
|
||||
st_below == "latched" and st_at == "no_forecast")
|
||||
check("F2: and only while a cheap run is on: at 07:05, the run over and the "
|
||||
f"latch in its grace slot, it does not ({st_after!r})",
|
||||
st_after == "no_forecast")
|
||||
|
||||
print("\n5. The latch ends: expiry, a changed mode, the night's bell")
|
||||
long_run = SimpleNamespace(cheap=priceruns.Run(at(4, 7, 0), at(4, 23, 0), .2))
|
||||
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
|
||||
lt, saved = latch_step(None, resting, None, now=at(4, 7, 20), runs=long_run)
|
||||
check("F1: past its end (07:15) the latch is dropped even with a cheap run "
|
||||
"on and no forecast (it was only dropped when no run held)",
|
||||
lt is None and saved is None)
|
||||
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
|
||||
CLOCK[0] = at(4, 7, 20)
|
||||
lc.solar_fc = None
|
||||
check("... plan_targets() ignores a latch past its end", lc.plan_targets()
|
||||
is None)
|
||||
CLOCK[0] = at(4, 1, 0)
|
||||
check("... and _load_latch() does not bring one back from the store",
|
||||
m._load_latch({"until": at(4, 7, 15).isoformat(), "born":
|
||||
at(4, 1, 0).isoformat(), "levels": {A: 80.0}},
|
||||
at(4, 7, 20)) is None
|
||||
and m._load_latch({"until": at(4, 7, 15).isoformat(), "born":
|
||||
at(4, 1, 0).isoformat(), "levels": {A: 80.0}},
|
||||
at(4, 6, 0)) is not None)
|
||||
|
||||
# F3: the mode or use of a latched battery changes
|
||||
def packs_of(a_mode, a_use=True):
|
||||
return [modetrade.Pack(A, a_use, a_mode, False, 2500.0, 50.0, 10.0),
|
||||
modetrade.Pack(B, True, "cheap_hours", False, 2500.0, 50.0, 10.0)]
|
||||
|
||||
|
||||
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
|
||||
lt, _s = latch_step(None, resting, None, packs=packs_of("leftover_solar"))
|
||||
check("F3: A is still Leftover solar and on: it stays latched",
|
||||
lt is not None and lt[1] == {A: 80.0})
|
||||
lt, saved = latch_step(None, resting, None, packs=packs_of("solar_first"))
|
||||
check("F3: the user changes A to Solar first (no forecast): it leaves the "
|
||||
"latch, which is then gone and cleared, so the narrow gap goes too",
|
||||
lt is None and saved is None)
|
||||
lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0))
|
||||
lt, _s = latch_step(None, resting, None,
|
||||
packs=packs_of("leftover_solar", a_use=False))
|
||||
check("F3: or switches A off", lt is None)
|
||||
|
||||
# F4: the night's bell
|
||||
lc._charge_latch = None
|
||||
lt, _s = latch_step(FC, charging, {A: 80.0}, now=at(4, 1, 0))
|
||||
check("F4: the latch began at 01:00, so its night's bell is 06:00 the same "
|
||||
"day", m._night_limit(lt[2]) == at(4, 6, 0)
|
||||
and m._night_limit(at(3, 23, 0)) == at(4, 6, 0)
|
||||
and m._night_limit(at(4, 6, 0)) == at(5, 6, 0))
|
||||
r1 = SimpleNamespace(cheap=priceruns.Run(at(4, 0, 0), at(4, 7, 0), .2))
|
||||
r2 = SimpleNamespace(cheap=priceruns.Run(at(4, 7, 0), at(4, 23, 0), .2))
|
||||
r3 = SimpleNamespace(cheap=priceruns.Run(at(4, 6, 0), at(4, 9, 0), .2))
|
||||
lc._charge_latch = None
|
||||
lt, _s = latch_step(FC, charging, {A: 80.0}, now=at(4, 1, 0), runs=r1)
|
||||
check("F4: first run 00:00-07:00 sets the end at 07:15", lt[0] == at(4, 7, 15))
|
||||
lt, _s = latch_step(None, resting, None, now=at(4, 6, 50), runs=r1)
|
||||
lt2, _s = latch_step(None, resting, None, now=at(4, 7, 5), runs=r2)
|
||||
check("F4: a back-to-back run that starts after the bell (07:00-23:00) does "
|
||||
"NOT carry it on: still 07:15", lt2 is not None
|
||||
and lt2[0] == at(4, 7, 15))
|
||||
lt3, _s = latch_step(None, resting, None, now=at(4, 7, 20), runs=r2)
|
||||
check("F4: and at 07:20 it has ended, though the run goes on", lt3 is None)
|
||||
lc._charge_latch = (at(4, 6, 10), {A: 80.0}, at(4, 1, 0))
|
||||
lt, _s = latch_step(None, resting, None, now=at(4, 6, 5), runs=r3)
|
||||
check("F4: a run that starts AT the bell (06:00-09:00) does carry it on, "
|
||||
"to its end + one slot", lt is not None and lt[0] == at(4, 9, 15))
|
||||
m.datetime = homeview.datetime = _dt0
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,473 @@
|
||||
"""Runnable check for SAFETY-126. `python3 test_safety126.py`
|
||||
|
||||
During the night grid charge, with both batteries under the peak reserve,
|
||||
the add-on logged ERROR PEAK_GUARD_FLOOR_LIMITED and raised "Battery stopped
|
||||
limiting the peak" at quarter ends, and the Batteries "Why" blinked "The peak
|
||||
limit needs power from the batteries.", while every quarter landed on the aim.
|
||||
The cause: the charge lands the quarter on the aim, a 1 W landing error in
|
||||
the quarter's last second becomes tens of kW through `excess_w()`'s
|
||||
900 / remaining, and the alarm fired on any `wanted > 0` when no battery can
|
||||
give. DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md.
|
||||
|
||||
Drives the REAL Controller (read_inputs + cycle, the tariff task on a
|
||||
day/night profile, a P1 meter on a simulated clock, OPT-15's lag), as
|
||||
test_safety125.py does, with the cycle a fraction of a second off the
|
||||
quarter's edge as on the rig.
|
||||
|
||||
1. The night grid charge under the reserve: no ERROR, no notification, no
|
||||
red peak row, no "Why" peak, every quarter on the aim; the guard did want
|
||||
power at the quarter's end (not vacuous); with the fix reverted the same
|
||||
run alarms. The commands are the same with and without the fix.
|
||||
2. A real overrun under the reserve still alarms, a step load at the same
|
||||
cycle as before, and names both batteries. After it, the latch is reset:
|
||||
the night's later landings are quiet again.
|
||||
3. `misses_aim()` on its own.
|
||||
4. The page and the "Why" on a decision within the aim.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
import app.p1 as p1mod # noqa: E402
|
||||
from app import homeview, messages, peakguard, tariff # noqa: E402
|
||||
from app import price as _price, static_tariff as _st # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
from app.tariff import QUARTER_S, Projection # noqa: E402
|
||||
|
||||
logging.disable(logging.NOTSET)
|
||||
logging.getLogger().setLevel(logging.CRITICAL)
|
||||
_ERRORS = io.StringIO()
|
||||
_h = logging.StreamHandler(_ERRORS)
|
||||
_h.setLevel(logging.ERROR)
|
||||
logging.getLogger("goodwe").addHandler(_h)
|
||||
logging.getLogger("goodwe").setLevel(logging.ERROR)
|
||||
logging.getLogger("goodwe").propagate = False
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
PEAK = 5413.17 # October's banked peak on the live site
|
||||
AIM_CAP = 3500.0 - 50.0
|
||||
AIM_PEAK = PEAK - 50.0
|
||||
TOL = 10.0 # W over the aim a quarter may land (SAFETY-81)
|
||||
L = timezone(timedelta(hours=1))
|
||||
NIGHT = datetime(2026, 10, 30, 22, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.2333},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.2801}]}')
|
||||
OWNER = {"a": "leftover_solar", "b": "cheap_hours"}
|
||||
|
||||
|
||||
def opts(*, modes=True, arb=False):
|
||||
o = dict(rp.options(modes, arb, 2))
|
||||
o.update(cycle_cost_threshold_eur=0.03, static_profile=PROFILE,
|
||||
peak_cap_w=3500, peak_margin_w=50)
|
||||
o["inverters"][0].update(max_w=5000, capacity_kwh=15.36)
|
||||
o["inverters"][1].update(max_w=4600, capacity_kwh=20.48)
|
||||
return o
|
||||
|
||||
|
||||
class Harder(rp.Plant):
|
||||
"""Charges harder than asked, as inverter-a did live (test_safety125.py:
|
||||
asked -4271 W, measured about -4750 W; asked -5000 W, about -5210 W), up
|
||||
to `cap` W. So a charge sized to the aim lands a few W over it, as on the
|
||||
rig (worst +7 W)."""
|
||||
cap = -5216.0
|
||||
|
||||
def step(self, dt, floor):
|
||||
c = self.cmd
|
||||
if c < 0:
|
||||
self.cmd = max(c * 1.11, self.cap)
|
||||
try:
|
||||
super().step(dt, floor)
|
||||
finally:
|
||||
self.cmd = c
|
||||
|
||||
|
||||
async def _no_fetch(*a, **k):
|
||||
raise AssertionError("the tariff task reached for a price feed")
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
"""release/1.0 cc9bbeb: no decision is ever within the aim."""
|
||||
real = getattr(Controller, "_peak_landing", None)
|
||||
if real is None:
|
||||
return fn()
|
||||
Controller._peak_landing = lambda self, decision: decision
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._peak_landing = real
|
||||
|
||||
|
||||
def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46,
|
||||
start=NIGHT, off=0.7, peak=None):
|
||||
"""The real Controller from `start` + `off` s for `minutes` at a 1 s
|
||||
cycle with OPT-15's lag, so a cycle lands `1 - off` s before each
|
||||
quarter's end. `house` is W or a function of t. Returns a dict: the
|
||||
quarters' true grid averages, the commands, the reserve notifications,
|
||||
the ERROR lines, and per cycle where the guard engaged: (t, floor
|
||||
limited, within the aim, battery "Why" peak, red reserve row, red
|
||||
saturated row, alarm latched)."""
|
||||
store = rp._Store()
|
||||
for u, mode in (modes or {}).items():
|
||||
store.data[unit_key(f"inverter-{u}", "pack_use")] = True
|
||||
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
|
||||
store.data[unit_key(f"inverter-{u}", "pack_sell")] = False
|
||||
load = house if callable(house) else (lambda t: float(house))
|
||||
t0 = start + timedelta(seconds=off)
|
||||
out = {"cmds": [], "notes": [], "seen": [], "sums": {}}
|
||||
_ERRORS.seek(0)
|
||||
_ERRORS.truncate()
|
||||
|
||||
async def go():
|
||||
lag = rp.Lag()
|
||||
hass = rp.Hass(("a", "b"), load, {"a": socs[0], "b": socs[1]},
|
||||
limits={"a": (-5000.0, 5000.0),
|
||||
"b": (-4600.0, 4600.0)}, lag=lag)
|
||||
for u, cap in (("a", -5216.0), ("b", -4800.0)):
|
||||
hass.plants[u].__class__ = Harder
|
||||
hass.plants[u].cap = cap
|
||||
real_set = hass.set_number
|
||||
|
||||
async def set_number(e, v):
|
||||
out["cmds"].append((round(hass.t, 3), e, float(v)))
|
||||
return await real_set(e, v)
|
||||
|
||||
async def call(*a):
|
||||
if a[:2] == ("persistent_notification", "create") \
|
||||
and a[2]["notification_id"] == messages.ID_PEAK_RESERVE:
|
||||
out["notes"].append((hass.t, a[2]["message"]))
|
||||
return True
|
||||
hass.set_number, hass.call = set_number, call
|
||||
tmp = tempfile.mkdtemp(prefix="s126-")
|
||||
db = os.path.join(tmp, "tariff.db")
|
||||
if peak is not None:
|
||||
tariff.PeakStore(db)._db.execute(
|
||||
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
|
||||
(tariff.month_key(t0), peak,
|
||||
"2026-10-21T20:15:00+00:00")).connection.commit()
|
||||
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=db,
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
|
||||
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
|
||||
c.auto = True
|
||||
real = m.datetime, homeview.datetime, p1mod.time
|
||||
clock, simt = [t0], [0.0]
|
||||
|
||||
class _DT(real[0]):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
m.datetime = homeview.datetime = _DT
|
||||
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
|
||||
real_load = _price.load_prices, _st.load_prices
|
||||
_price.load_prices = _st.load_prices = _no_fetch
|
||||
task = (asyncio.create_task(c.run_tariff_refresh())
|
||||
if o.get("battery_modes_enabled") or o.get("arbitrage_enabled")
|
||||
else None)
|
||||
try:
|
||||
hass.advance(0.0, 10.0)
|
||||
t = 0.0
|
||||
while t < minutes * 60:
|
||||
now = t0 + timedelta(seconds=t)
|
||||
hass.t, simt[0], clock[0] = t, t, now
|
||||
if t == 0.0 and task is not None:
|
||||
for _ in range(500):
|
||||
if c.tariff_plan is not None:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
q = int(now.timestamp() // QUARTER_S)
|
||||
acc = out["sums"].setdefault(q, [0.0, 0])
|
||||
if t >= lag.p1_s:
|
||||
imp, exp = split_signed(hass._past(lag.meter_s
|
||||
+ lag.p1_s)[0])
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now - timedelta(seconds=lag.p1_s),
|
||||
ingest_mono=1e6 + t))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(lag.tick, 10.0)
|
||||
acc[0] += max(0.0, hass.grid())
|
||||
acc[1] += 1
|
||||
g = c.peak_guard
|
||||
if g is not None and g.engaged and g.blind is None:
|
||||
rows = {r["id"]: r for r in c.checks(now)}
|
||||
row = rows.get("peak_guard", {})
|
||||
red = row.get("ok") is False and not row.get("warn")
|
||||
out["seen"].append((
|
||||
t, g.floor_limited, getattr(g, "within_aim", False),
|
||||
homeview.peak_engaged(c),
|
||||
red and "banner" in row,
|
||||
red and "banner" not in row,
|
||||
c._peak_floor_limited))
|
||||
t += lag.tick
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime, p1mod.time = real
|
||||
_price.load_prices, _st.load_prices = real_load
|
||||
return c
|
||||
out["c"] = asyncio.run(go())
|
||||
out["quarters"] = {
|
||||
datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"): s / k
|
||||
for q, (s, k) in sorted(out["sums"].items()) if k >= QUARTER_S - 1}
|
||||
out["errors"] = [x for x in _ERRORS.getvalue().splitlines()
|
||||
if "PEAK_GUARD_FLOOR_LIMITED" in x]
|
||||
return out
|
||||
|
||||
|
||||
def show(tag, r):
|
||||
print(f" {tag}: quarters "
|
||||
f"{ {k: round(v, 1) for k, v in r['quarters'].items()} }, "
|
||||
f"guard cycles {len(r['seen'])}, floor-limited "
|
||||
f"{sum(1 for s in r['seen'] if s[1])}, within-aim "
|
||||
f"{sum(1 for s in r['seen'] if s[2])}, Why peak "
|
||||
f"{sum(1 for s in r['seen'] if s[3])}, notes {len(r['notes'])}, "
|
||||
f"errors {len(r['errors'])}")
|
||||
|
||||
|
||||
def quiet(r):
|
||||
"""No reserve alarm in any form: ERROR, notification, red row, latch,
|
||||
"Why" peak, and no red saturated row."""
|
||||
return (not r["errors"] and not r["notes"]
|
||||
and not any(s[3] or s[4] or s[5] or s[6] for s in r["seen"]))
|
||||
|
||||
|
||||
def on_aim(r, aim):
|
||||
q = r["quarters"]
|
||||
return len(q) >= 3 and all(v <= aim + TOL for v in q.values())
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. the night grid charge under the peak reserve")
|
||||
NIGHTS = {}
|
||||
# The owner's shape at the cap's aim. At the banked peak's aim it is
|
||||
# test_safety125.py's live shape (A Cheap hours, B Solar first), because
|
||||
# B's 4 600 W alone stays under that aim.
|
||||
LIVE = {"a": "cheap_hours", "b": "solar_first"}
|
||||
for tag, kw, aim in (
|
||||
("aim 3450, cycle 0.3 s before the edge", dict(off=0.7), AIM_CAP),
|
||||
("aim 3450, cycle 0.01 s before the edge", dict(off=0.99), AIM_CAP),
|
||||
("month peak 5413, cycle 0.01 s before the edge",
|
||||
dict(off=0.99, peak=PEAK, modes=LIVE, socs=(11.0, 10.0)),
|
||||
AIM_PEAK)):
|
||||
head = drive(opts(), **kw)
|
||||
base = without_fix(lambda: drive(opts(), **kw))
|
||||
NIGHTS[tag] = (head, base)
|
||||
show("head " + tag, head)
|
||||
show("fix reverted", base)
|
||||
check(f"⭐ {tag}: no ERROR, no notification, no red peak row and no "
|
||||
f"'Why' peak", quiet(head))
|
||||
check(f"{tag}: every quarter at or under the aim ({aim:.0f} W + "
|
||||
f"{TOL:.0f} W)", on_aim(head, aim))
|
||||
check(f"{tag}: not vacuous, the guard wanted power under the reserve "
|
||||
f"at a quarter's end (floor-limited, within the aim)",
|
||||
any(s[1] and s[2] for s in head["seen"]))
|
||||
check(f"{tag}: with the fix reverted the same run alarms",
|
||||
not quiet(base))
|
||||
check(f"{tag}: the commands are the same with and without the fix",
|
||||
head["cmds"] == base["cmds"] and len(head["cmds"]) > 100)
|
||||
_h, _b = NIGHTS["aim 3450, cycle 0.01 s before the edge"]
|
||||
check("fix reverted, at 0.01 s: the ERROR, the notification and the "
|
||||
"'Why' peak all come back (the rig's three symptoms)",
|
||||
_b["errors"] and _b["notes"] and any(s[3] for s in _b["seen"]))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a real overrun under the reserve still alarms")
|
||||
|
||||
|
||||
def evening(t):
|
||||
"""600 W, then an oven and a car charger from 20 minutes on."""
|
||||
return 600.0 if t < 1200 else 6500.0
|
||||
|
||||
|
||||
for tag, onset, kw in (
|
||||
("night grid charge, then 6500 W", 1200.0, dict(house=evening)),
|
||||
("modes off, 5500 W all along", 0.0,
|
||||
dict(house=5500.0, modes=None, socs=(15.0, 12.0)))):
|
||||
o = opts(modes=kw.get("modes", OWNER) is not None)
|
||||
head = drive(o, minutes=35, **kw)
|
||||
base = without_fix(lambda: drive(o, minutes=35, **kw))
|
||||
show("head " + tag, head)
|
||||
show("fix reverted", base)
|
||||
# From the load's onset: before it, the fix reverted shows the night's
|
||||
# false alarm (part 1).
|
||||
first_h = next((s[0] for s in head["seen"] if s[6] and s[0] >= onset),
|
||||
None)
|
||||
first_b = next((s[0] for s in base["seen"] if s[6] and s[0] >= onset),
|
||||
None)
|
||||
check(f"⭐ {tag}: the ERROR and the notification, naming both "
|
||||
f"batteries",
|
||||
head["errors"] and head["notes"]
|
||||
and all("inverter-a and inverter-b" in n for _t, n in head["notes"]))
|
||||
_th = [t for t, _n in head["notes"]]
|
||||
_tb = [t for t, _n in base["notes"] if t >= onset]
|
||||
print(f" notifications at {_th} s; with the fix reverted {_tb} s")
|
||||
check(f"{tag}: the first at the same cycle as before the fix "
|
||||
f"({first_h} s, {first_b} s), and none that was not raised before",
|
||||
first_h is not None and first_h == first_b
|
||||
and present(nth(_th, 0)) and nth(_th, 0) == nth(_tb, 0)
|
||||
and set(_th) <= set(_tb))
|
||||
check(f"{tag}: the red peak row and its banner while it stands",
|
||||
any(s[4] for s in head["seen"]))
|
||||
|
||||
|
||||
def burst(t):
|
||||
"""600 W, a 6 500 W load from 20 to 25 minutes, then 600 W again while
|
||||
the night charge goes on."""
|
||||
return 6500.0 if 1200 <= t < 1500 else 600.0
|
||||
|
||||
|
||||
# F3 (review): `_peak_landing()` resets its latch when the guard lets go, so
|
||||
# a real overrun does not keep the later landing errors alarming.
|
||||
_bh = drive(opts(), house=burst, off=0.99, minutes=61)
|
||||
_bb = without_fix(lambda: drive(opts(), house=burst, off=0.99,
|
||||
minutes=61))
|
||||
show("head real overrun, then the night again", _bh)
|
||||
show("fix reverted", _bb)
|
||||
# The burst banked a new month peak, which the later quarters stay under.
|
||||
_new_peak = _bh["c"].peak_guard.ceiling_w
|
||||
|
||||
|
||||
def _late(r):
|
||||
"""From the next quarter on: the overrun's own quarter ends over."""
|
||||
return [x for x in r["seen"] if x[0] >= 1800]
|
||||
|
||||
|
||||
print(f" notifications at {[t for t, _n in _bh['notes']]} s; with the fix "
|
||||
f"reverted {[t for t, _n in _bb['notes']]} s; limit after the burst "
|
||||
f"{_new_peak:.0f} W")
|
||||
check("⭐ after a real overrun ends, the latch is reset: the alarm came "
|
||||
"during the overrun, and the later quarters' landings are quiet "
|
||||
"(no notification, no 'Why' peak, no red row) and under the new peak",
|
||||
any(1200 <= t < 1500 for t, _n in _bh["notes"])
|
||||
and not [t for t, _n in _bh["notes"] if t >= 1800]
|
||||
and not any(x[3] or x[4] or x[5] for x in _late(_bh))
|
||||
and present(nth([v for k, v in _bh["quarters"].items()
|
||||
if k >= "22:30"], 0))
|
||||
and all(v <= _new_peak for k, v in _bh["quarters"].items()
|
||||
if k >= "22:30"))
|
||||
check("...not vacuous: the guard wanted power at those landings, and with "
|
||||
"the fix reverted they show the 'Why' peak or an alarm",
|
||||
any(x[2] for x in _late(_bh))
|
||||
and (any(x[3] or x[4] or x[5] for x in _late(_bb))
|
||||
or bool([t for t, _n in _bb["notes"] if t >= 1800])))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. misses_aim() on its own")
|
||||
START = datetime(2026, 10, 30, 21, 15, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def proj(projected, elapsed, held):
|
||||
return Projection(projected, projected, elapsed, 1.0, True, START, held)
|
||||
|
||||
|
||||
check("the rig's case: 3452 W against 3450 W, 0.3 s left, charging "
|
||||
"1250 W from the grid: not a miss",
|
||||
not peakguard.misses_aim(proj(3452.0, 899.7, 3452.0), batt_w=-1250.0,
|
||||
target_w=AIM_CAP))
|
||||
check("...although decide() wants power there (its gain, 900 / 0.3 s)",
|
||||
(peakguard.decide(proj(3452.0, 899.7, 3452.0), batt_w=-1250.0,
|
||||
cap_w=3500, margin_w=50, reading=None,
|
||||
achievable_w=0.0, soc=10.0, soc_floor=20.0)
|
||||
.wanted_w or 0.0) > 1000.0)
|
||||
check("a quarter heading 11 W over the aim with the batteries at 0 W is a "
|
||||
"miss", peakguard.misses_aim(proj(3461.0, 899.7, 3461.0), batt_w=0.0,
|
||||
target_w=AIM_CAP))
|
||||
check("...and 10 W is not (ALARM_MISS_W, as for 'Load too high')",
|
||||
not peakguard.misses_aim(proj(3460.0, 899.7, 3460.0), batt_w=0.0,
|
||||
target_w=AIM_CAP))
|
||||
check("the add-on's own grid charge counts as stoppable: 4000 W on the "
|
||||
"meter mid-quarter, 3000 W of it charging, is not a miss",
|
||||
not peakguard.misses_aim(proj(2500.0, 450.0, 4000.0), batt_w=-3000.0,
|
||||
target_w=AIM_CAP))
|
||||
check("...and a house of 6000 W the batteries are covering is one",
|
||||
peakguard.misses_aim(proj(1000.0, 450.0, 0.0), batt_w=6000.0,
|
||||
target_w=AIM_CAP))
|
||||
check("no battery reading, or no seconds left: not a miss (decide() files "
|
||||
"nothing there)",
|
||||
not peakguard.misses_aim(proj(9000.0, 450.0, 9000.0), batt_w=None,
|
||||
target_w=AIM_CAP)
|
||||
and not peakguard.misses_aim(proj(9000.0, 900.0, 9000.0), batt_w=0.0,
|
||||
target_w=AIM_CAP))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the page and the 'Why' on a decision within the aim")
|
||||
_c = NIGHTS["aim 3450, cycle 0.01 s before the edge"][0]["c"]
|
||||
_dec = peakguard.PeakDecision(
|
||||
engaged=True, floor_w=5000.0, ceiling_w=3500.0,
|
||||
ceiling_from=peakguard.CEILING_CAP, projected_w=3451.0, excess_w=9e4,
|
||||
wanted_w=9e4, target_w=AIM_CAP, saturated=True, soc=30.0, soc_floor=20.0)
|
||||
|
||||
|
||||
def page(d):
|
||||
"""(the peak row is red, the "Why" says peak) for decision `d`."""
|
||||
_c.peak_guard, _c._peak_floor_limited = d, False
|
||||
row = first((r for r in _c.checks(datetime.now(timezone.utc))
|
||||
if r["id"] == "peak_guard"), "peak row")
|
||||
if not present(row):
|
||||
return {"red": None}, None
|
||||
return dict(row, red=row["ok"] is False and not row.get("warn")), \
|
||||
homeview.peak_engaged(_c)
|
||||
|
||||
|
||||
_r1, _w1 = page(_dec)
|
||||
_r2, _w2 = page(dataclasses.replace(_dec, within_aim=True))
|
||||
check("a saturated decision: red 'Load too high' row and the 'Why' peak, "
|
||||
"as before", _r1["red"] and _w1)
|
||||
check("...within the aim: neither", _r2["red"] is False and _w2 is False)
|
||||
_lim = dataclasses.replace(_dec, floor_w=0.0, saturated=False,
|
||||
floor_limited=True, soc=10.0,
|
||||
soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR)
|
||||
_r3, _w3 = page(_lim)
|
||||
_r4, _w4 = page(dataclasses.replace(_lim, within_aim=True))
|
||||
check("floor-limited at 0 W: the red reserve row with its banner, as "
|
||||
"before", _r3["red"] and "banner" in _r3 and not _w3)
|
||||
check("...within the aim: no red row",
|
||||
_r4["red"] is False and _w4 is False)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} SAFETY-126 check(s) FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all SAFETY-126 checks passed")
|
||||
@@ -48,7 +48,7 @@ logging.disable(logging.WARNING)
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import arbitrage, homeview, modetrade # noqa: E402
|
||||
from app import arbitrage, homeview, modetrade, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
||||
@@ -59,6 +59,13 @@ from app.store import unit_key # noqa: E402
|
||||
fails = []
|
||||
|
||||
|
||||
class _NoHold(solarfc.Hold):
|
||||
"""OPT-27: a hold that never stands in for a lost sensor."""
|
||||
|
||||
def stand_in(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
@@ -163,6 +170,11 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
# OPT-27: a sensor that is missing is covered for up to 3 h by its last
|
||||
# fresh reading. This file is about what the plan does when the
|
||||
# forecast IS lost, so it runs with a hold that never covers
|
||||
# (test_opt27.py tests the cover).
|
||||
c.fc_hold = _NoHold()
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
clock = [t0]
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MAINT-20: prove a comment/docstring cut changed no code, and show the ratio.
|
||||
|
||||
python3 goodwe_controller/tools/maint20_same_code.py [BASE_REF] [FILE ...]
|
||||
|
||||
For each FILE (default: every goodwe_controller/app/*.py that differs from
|
||||
BASE_REF, default release/1.0) the ast.dump() of the tree with docstrings
|
||||
removed must be identical at BASE_REF and in the working tree. Also prints
|
||||
code lines vs comment-only + docstring lines (whole lines, via tokenize) before
|
||||
and after. Run from the repository root. Exit 1 on any code difference.
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
import tokenize
|
||||
|
||||
SCOPES = (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
||||
|
||||
|
||||
def docstring(n):
|
||||
if isinstance(n, SCOPES) and n.body:
|
||||
e = n.body[0]
|
||||
if (isinstance(e, ast.Expr) and isinstance(e.value, ast.Constant)
|
||||
and isinstance(e.value.value, str)):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
def dump(src):
|
||||
tree = ast.parse(src)
|
||||
for n in ast.walk(tree):
|
||||
if docstring(n) is not None:
|
||||
n.body = n.body[1:] or [ast.Pass()]
|
||||
return ast.dump(tree)
|
||||
|
||||
|
||||
def counts(src):
|
||||
doc = set()
|
||||
for n in ast.walk(ast.parse(src)):
|
||||
e = docstring(n)
|
||||
if e is not None:
|
||||
doc.update(range(e.lineno, e.end_lineno + 1))
|
||||
skip = (tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT,
|
||||
tokenize.ENDMARKER, tokenize.COMMENT)
|
||||
code, com = set(), set()
|
||||
for tk in tokenize.generate_tokens(io.StringIO(src).readline):
|
||||
if tk.type == tokenize.COMMENT:
|
||||
com.add(tk.start[0])
|
||||
elif tk.type not in skip:
|
||||
code.update(range(tk.start[0], tk.end[0] + 1))
|
||||
code -= doc
|
||||
return len(code), len(doc) + len(com - code - doc)
|
||||
|
||||
|
||||
def git(*a):
|
||||
return subprocess.run(["git", *a], check=True, capture_output=True,
|
||||
text=True).stdout
|
||||
|
||||
|
||||
def main(argv):
|
||||
base = argv[0] if argv else "release/1.0"
|
||||
files = argv[1:] or [
|
||||
f for f in git("diff", "--name-only", base, "--",
|
||||
"goodwe_controller/app").split()
|
||||
if f.endswith(".py")]
|
||||
bad = 0
|
||||
print(f"base {base} = {git('rev-parse', base).strip()}")
|
||||
for f in files:
|
||||
old = git("show", f"{base}:{f}")
|
||||
new = open(f, encoding="utf-8").read()
|
||||
same = dump(old) == dump(new)
|
||||
(c0, m0), (c1, m1) = counts(old), counts(new)
|
||||
print(f"{'SAME CODE' if same else 'CODE DIFFERS'} {f} "
|
||||
f"code {c0}->{c1} comment+doc {m0}->{m1} "
|
||||
f"ratio {m0 / c0:.2f}->{m1 / c1:.2f}")
|
||||
bad += not same
|
||||
print("OK: no code change" if not bad else f"FAIL: {bad} file(s) differ")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user