Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a5281e97f | ||
|
|
6eba18968c | ||
|
|
958f5cd218 | ||
|
|
c45893ffad | ||
|
|
5059e28860 | ||
|
|
375a1e29d2 | ||
|
|
c2787da539 |
@@ -13,6 +13,232 @@ if it has, it needs 0.10.0. Whoever deploys decides.
|
||||
| | |
|
||||
|---|---|
|
||||
| _this branch_ | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart |
|
||||
| _this branch_ | FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard |
|
||||
| _this branch_ | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
|
||||
| _this branch_ | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
|
||||
|
||||
### FCST-06 - the second way to be a store that cannot persist
|
||||
|
||||
**A price cache that could not write went on answering a yesterday the file did
|
||||
not contain.** `price_history.py`'s docstring states its rule unconditionally -
|
||||
*"A store that cannot persist now records nothing, and both of its answers say
|
||||
the same thing"* - and FCST-04 enforced it against `not self.ok`, which is one
|
||||
**symptom**: the durable volume is missing. `ok` is about the DIRECTORY, and a
|
||||
read-only remount and a full disk both leave it `True`. Reproduced on `5059e28`
|
||||
against two real volumes - a `/data` bind-mounted read-only and a full tmpfs:
|
||||
|
||||
| | with a `/data` that cannot be written |
|
||||
|---|---|
|
||||
| `last_error` | `could not write ([Errno 30] Read-only file system)` |
|
||||
| `words` | `2026-01-14 (24 points over 24 h); 2026-01-15 (24 points over 24 h) [could not write (...)]` |
|
||||
| `yesterday()` | **series, 24 points** |
|
||||
| on disk | `{}` |
|
||||
|
||||
`words` and `yesterday()` were describing two different stores, and the 24
|
||||
points were gone on the next restart with nothing saying so - the
|
||||
silently-forgetting cache that rule exists to refuse, reached by the condition
|
||||
it did not name. **No shipped path reaches it**: `/data` is the Supervisor's
|
||||
durable volume and this module is its only writer. The defect is that a
|
||||
guarantee the module states about itself did not hold in one condition.
|
||||
|
||||
**"Cannot persist" is now ONE derived predicate, asked once, about the
|
||||
outcome.** It cannot be answered *before* the attempt - a directory that existed
|
||||
and was writable a moment ago is not a write that completed - so `_save()`
|
||||
returns whether the file now holds what memory holds: `False` with no durable
|
||||
volume, `False` on an `OSError` from the write, `True` otherwise. `_remember()`
|
||||
keeps only what that one answer says was kept, and **the `not self.ok` test in
|
||||
`_remember()` is gone** rather than joined by a second one. FCST-04's M4 and
|
||||
FCST-05's HIGH were the same shape - a rule enforced on the conditions somebody
|
||||
thought of - so a third cause now needs no third branch.
|
||||
|
||||
**What is undone is the MERGE, not the store.** The state restored is the last
|
||||
one that *is on disk*, so 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. It simply stops adding to it. The
|
||||
snapshot is a `copy.deepcopy`: a one-level copy shares the day record the merge
|
||||
rebinds `points` on, and measured with one, a store holding 12 persisted points
|
||||
answered **24** after a failed merge.
|
||||
|
||||
⚠️ **A failed write is still not fatal, and that was a constraint rather than an
|
||||
oversight.** §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 being fixed, so `_save()` reports and returns - the failure
|
||||
stays in the log, in `last_error`, in `words` and in `yesterday()`'s reason -
|
||||
and the shipped `resolve_tariff()` path still returns a provider plan with no
|
||||
§11.4 degradation. What changed is that it is now *acted on*.
|
||||
|
||||
`test_price_history.py` section 5d drives it on two real failures with different
|
||||
syscalls and different errnos - a directory where the file goes (`os.replace`,
|
||||
EISDIR) and a volume that moves out from under an open store (`mkstemp`,
|
||||
ENOENT) - because the rule is enforced on the outcome and one cause would grade
|
||||
a coincidence. See `DECISIONS/FCST-06-cannot-persist-is-one-question.md`.
|
||||
|
||||
### TARIFF-08 - no network price provider had ever parsed a live response
|
||||
|
||||
**`generic_dayahead` could not read one byte of `api.energy-charts.info`, and
|
||||
had never been able to.** `_points_from_json()` asks for `doc["data"]`, a list
|
||||
of `{start, price_eur_mwh}` objects. That document does not exist. Fetched live
|
||||
on 2026-09-10, the feed returns **columnar** parallel arrays and no `data` key
|
||||
at all:
|
||||
|
||||
```
|
||||
top-level keys: ['deprecated', 'license_info', 'price', 'unit', 'unix_seconds']
|
||||
unix_seconds: 96 UTC epoch seconds, first 1788991200, spacing 900 s
|
||||
price: 96 floats, first 216.08
|
||||
unit: 'EUR / MWh'
|
||||
```
|
||||
|
||||
Driving the shipped parser against that body: `SourceUnavailable: the feed
|
||||
carried no 'data' list`. Since `generic_dayahead` is the only shipped provider
|
||||
whose endpoint anybody has reached, **dynamic pricing did not work at all**.
|
||||
|
||||
⚠️ **AND §11.4 MADE THAT SILENT, WHICH IS WHY IT LASTED.** A parse error becomes
|
||||
`no_data`, becomes the static profile, becomes arbitrage off - the *correct*
|
||||
degradation, and it says nothing whatsoever about the cause. A site would have
|
||||
sat on the fallback indefinitely while the status page reported only that the
|
||||
feed was down.
|
||||
|
||||
⚠️ **THE SUITE COULD NOT SEE IT BECAUSE EVERY FIXTURE WAS BUILT FROM THE
|
||||
PARSER'S OWN ASSUMPTION.** `test_price.py`, `test_price_history.py` and
|
||||
`test_static_tariff.py` each built `json.dumps({"data": [{"start": ...,
|
||||
"price_eur_mwh": ...}]})` - a shape invented here, published by nobody. The
|
||||
checks graded the parser against the parser's guess: a closed loop that cannot
|
||||
notice the guess is wrong, and no amount of coverage inside it would have. The
|
||||
loop is broken by **bytes nobody in this repository wrote**: two unmodified
|
||||
response bodies are committed under `scenarios/`, one quarter-hourly
|
||||
(2026-09-10, 96 points at 900 s) and one hourly (2025-09-10, 24 points at
|
||||
3600 s - the same endpoint before Belgium's 15-minute MTU), with URL, retrieval
|
||||
time, licence-as-returned and a sha256 recorded beside them in
|
||||
`energy-charts-be-price-captures.json` and **re-derived** by the replay, so a
|
||||
fixture edited to make a check pass stops being a capture. The remaining
|
||||
invented fixtures are renamed `synthetic_row_feed` at every call site.
|
||||
|
||||
**The columnar document is read by its own function, `_points_from_columnar_json()`.**
|
||||
Not by teaching the row parser to sniff for both: a parser that decides which
|
||||
document it received is a parser that can decide wrong, and a wrong-but-
|
||||
parseable response then becomes a price. Two shapes, two functions, and each
|
||||
plugin names the one its feed publishes. Feeding the row shape to
|
||||
`generic_dayahead` is now a graded `no_data`, so the sniff cannot come back
|
||||
quietly.
|
||||
|
||||
⚠️ **UNEQUAL ARRAYS ARE REFUSED RATHER THAN ZIPPED.** `zip()` stops at the
|
||||
shorter list and says nothing, so a truncated `price` array would have produced
|
||||
a **real price series with its tail missing** - and the tail of a Belgian day is
|
||||
the evening peak. That is worse than no series: `no_data` switches arbitrage
|
||||
off, whereas a short series is traded on as though complete, and nothing
|
||||
downstream can tell them apart. Refused in both directions, with the two lengths
|
||||
in the reason.
|
||||
|
||||
⚠️ **A UNIT CHANGE IS REFUSED FOR A REASON `_validate_raw()` CANNOT COVER.** The
|
||||
plugin converts with a fixed `0.001`; if the feed ever published EUR/kWh under
|
||||
the same keys, every price would come out a **thousand times too small** and
|
||||
`MAX_PLAUSIBLE_EUR_KWH` would not fire, because it only bounds prices that are
|
||||
too large. DATA-02's fetcher already refused on this field; the shipped path
|
||||
does now too.
|
||||
|
||||
**`unix_seconds` are UTC instants, said so explicitly**
|
||||
(`datetime.fromtimestamp(t, timezone.utc)`); §9.3's rendering at the Brussels
|
||||
offset stays where it already lived, in `assemble()`, rather than being copied
|
||||
into the plugin where a mutation could not tell it from a no-op. A published
|
||||
`null` is dropped and logged with a count - a missing quarter is not a free
|
||||
quarter - while an all-null body is `no_data` named by this parser rather than
|
||||
by the boundary validator.
|
||||
|
||||
**End to end against the live feed, 2026-09-10:** `resolve_tariff()` returns a
|
||||
`TariffPlan` with `origin='provider'`, **96 quarter-hourly points**, spacing
|
||||
`[900]` s and no decimation, first point `2026-09-10T00:00:00+02:00` at 0.30608
|
||||
EUR/kWh all-in. FCST-04's H1 fix was never theoretical - 900 s is the shape of
|
||||
the real feed.
|
||||
|
||||
⚠️ **WHAT THIS DOES NOT FIX.** `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy` are untouched: their URLs are unverified, no real body can be
|
||||
captured for them, and withdrawing them is TARIFF-07's open decision. Their
|
||||
fixtures are now named `synthetic_row_feed` so nothing reads them as evidence
|
||||
that those feeds parse. And `generic_dayahead` still ignores `since`/`until`
|
||||
and asks for no date range, so the default URL returns one published day - a
|
||||
full 48 h horizon is reached only through §8.3's carry-forward fill.
|
||||
|
||||
### FCST-05 - what FCST-04's own contract still did not hold
|
||||
|
||||
**A single non-UTF-8 byte in `/data/price_history.json` stopped the add-on from
|
||||
starting.** `_load()` caught `(json.JSONDecodeError, OSError)`.
|
||||
`UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError` -
|
||||
neither of those, and `issubclass(UnicodeDecodeError, OSError)` is **False** -
|
||||
so it escaped `_load()` -> `_open()` -> `PriceHistory.__init__`, which
|
||||
`Controller.__init__` does not guard. Reproduced: `UnicodeDecodeError: 'utf-8'
|
||||
codec can't decode byte 0xff`, and **the controller did not come up**. That is the same class as the day-record defect the store was reworked
|
||||
for, in the one byte class the rework left uncovered: valid-UTF-8-invalid-JSON,
|
||||
a UTF-8 BOM and a non-dict day record all degraded correctly. The suite could
|
||||
not see it because its "unreadable file" fixture writes `"{ this is not json"`,
|
||||
which is **valid UTF-8**. The handler now names the `ValueError` family - the
|
||||
file's CONTENT - beside `OSError`, the file's BYTES, and a fixture holding a
|
||||
genuinely undecodable byte is in `test_price_history.py` section 5.
|
||||
|
||||
**A malformed `start` under the day the feed is writing to killed the tariff
|
||||
plan permanently.** `_remember()` merged a day's points with
|
||||
`{q["start"]: q ...}` and sorted on `q["start"]`, trusting a value read off
|
||||
`/data` to be a hashable, sortable string. An `int`, `null`, a `dict` or a
|
||||
`list` there raised `TypeError`; `run_tariff_refresh` catches `Exception`, so
|
||||
the add-on stayed **up** while **every refresh raised, forever** - the plan
|
||||
retiring after `TARIFF_MAX_AGE_S` and arbitrage and the cheap window off for
|
||||
good, with no self-heal because `_remember()` raised before reaching `_save()`.
|
||||
The read path (`_parse_points()`) was properly defensive throughout, which is
|
||||
why `yesterday()` survived and the existing fixture passed: it files its
|
||||
malformed rows under a day the feed is **not** writing to, so `_remember()`
|
||||
never opened that record. **The two paths now share one validator**,
|
||||
`_point_of()`, and neither has a standard of its own. A row that is not a point
|
||||
is dropped and reported, and `_save()` rewrites the record clean.
|
||||
|
||||
⚠️ **AND THE STATUS ROW SAID THE STORE WAS HEALTHY WHILE THE PLAN WAS DEAD.**
|
||||
`words` read `'2026-01-15 (1 h)'` about the record that was killing every
|
||||
refresh. `_load()` now counts the rows inside a kept record as well as the
|
||||
record itself, so the store stops reporting plain health from the moment it is
|
||||
opened; the note names what was **found** at open and what was **dropped** by
|
||||
the refresh, and clears itself once the file has been rewritten.
|
||||
|
||||
⚠️ **AND THE ROW COUNTED POINTS AND CALLED THEM HOURS.** `f"{d} ({_n_points(d)}
|
||||
h)"` on the quarter-hourly feed this add-on already supports reads
|
||||
`'2026-01-13 (96 h)'` for a 24-hour day - the same points/hours conflation the
|
||||
sub-hourly defect above was about, on the row that defect's own card shipped.
|
||||
The cell is now `2026-01-13 (96 points over 24 h)`: two facts, each under its
|
||||
own unit, and the hour figure is the one that says whether tomorrow morning's
|
||||
fill has a whole day to carry.
|
||||
|
||||
⚠️ **AND THE MERGE KEY IS THE INSTANT RATHER THAN ITS SPELLING**, which is what
|
||||
rule 4 already said. Aware datetimes hash and compare by instant. The string
|
||||
key merged by spelling, and the lexicographic sort beside it put the second
|
||||
02:00 of the 25-hour October day (`+01:00`) **before** the first (`+02:00`);
|
||||
only the stored order was affected, because `_parse_points()` re-sorts by
|
||||
instant on the way out.
|
||||
|
||||
⚠️ **TWO STATED REASONS WERE FALSE, AND THIS REPOSITORY TREATS THAT AS A
|
||||
DEFECT.** `price_forecast._audit()`'s "WHY IT IS STRICTER" section claimed the
|
||||
rework refused two things FCST-03's audit did not. A differential over the
|
||||
whole domain the audit can express - every published mask over 3 hours crossed
|
||||
with every output series of length 0-3 - found **zero divergence in both
|
||||
directions**: the reworked audit is exactly **EQUAL** to FCST-03's, not
|
||||
stronger. The old `seen` count already refused the same published point emitted
|
||||
twice (one point twice is one hour twice), and the old **identity** rule
|
||||
already refused a carried point sharing a published hour. The only behavioural
|
||||
change is the intended **relaxation** - several distinct published points in
|
||||
one hour - and that stays. The docstring and the two check texts now say what
|
||||
is true, and the differential is committed as a check in
|
||||
`test_price_forecast.py` rather than left as prose.
|
||||
|
||||
⚠️ **AND `_audit` IS NOT WHAT STOPS THE DECIMATION DEFECT RECURRING.** It does
|
||||
not check **completeness**: fed the decimation directly - four published points
|
||||
in an hour, output emitting only the last - it **accepts**, and it accepts an
|
||||
empty output. That is a limit, not a regression, and the module docstring said
|
||||
otherwise by presenting the audit as the post-condition guarding provenance.
|
||||
What actually guards it is the point **count** and the pass-through **identity**
|
||||
checks in `test_price_forecast.py` and `test_price_history.py`, and the text now
|
||||
points at them. It is **not** made complete: `published` is the whole read while
|
||||
the fill emits only `[since, until)`, so "every published point appears" is not
|
||||
a true post-condition of those two arguments. Measured - the rule as stated
|
||||
takes the 1 h, 12 h and 48 h horizon checks red on legitimate series.
|
||||
|
||||
**No option was added, `config.yaml` is untouched, and nothing was deployed.**
|
||||
|
||||
|
||||
**FCST-03 shipped the fill and no caller.** `price_forecast.fill_from_yesterday()`
|
||||
was complete and checked, and so were the consumers written for it -
|
||||
@@ -58,8 +284,9 @@ spread it would have created was never compared against anything.
|
||||
no durable volume passes the explicit `NoHistory()`. A default of `None`
|
||||
meaning "skip the fill" would hand a caller who forgot the argument exactly
|
||||
the truncated horizon this card removes, silently.
|
||||
* A new status row, **Price history**, names the days held and their hour
|
||||
counts.
|
||||
* A new status row, **Price history**, names the days held, how many points
|
||||
each holds and how many whole hours they cover (see FCST-05: it shipped
|
||||
counting points and printing them under the hour's name).
|
||||
|
||||
⚠️ **ONE CHANGE HERE MOVES ENERGY, AND IT IS NOT THE ONE THE CARD IS NAMED FOR.**
|
||||
Carried hours are provisional, so §8.3 keeps them out of the trade set: the fill
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# FCST-06 — "cannot persist" is one question, and it is asked about the outcome
|
||||
|
||||
Two decisions. The first is the one the ticket put up for judgement; the second
|
||||
is the one that decides what a store answers *after* it has failed to write.
|
||||
|
||||
---
|
||||
|
||||
## 1. One derived predicate, not two places testing two symptoms — yes
|
||||
|
||||
`price_history.py`'s module docstring states, unconditionally:
|
||||
|
||||
> ⚠️ AND THAT RULE IS CARRIED OUT IN `_remember()`, NOT ONLY IN `_save()`. …
|
||||
> A store that cannot persist now records nothing, and both of its answers say
|
||||
> the same thing.
|
||||
|
||||
It was enforced against `not self.ok`, and `ok` is **about the directory**: it
|
||||
says `/data` was there when the store opened. A read-only remount and a full
|
||||
volume both leave it `True`. So the second way to be a store that cannot
|
||||
persist — `ok=True`, the directory present, `_save()` failing — was outside the
|
||||
one condition the rule named, and in that state `words` printed two full days
|
||||
beside the write error while `yesterday()` answered 24 real points, from memory,
|
||||
until a restart dropped them without a word.
|
||||
|
||||
**The ticket's question was whether "cannot persist" should be one derived
|
||||
predicate rather than two places each testing a different symptom. It should,
|
||||
and the reason is not tidiness — it is that the enumeration cannot be
|
||||
completed.** FCST-04's M4 enforced the rule on the symptom that was in front of
|
||||
it. FCST-05's HIGH was the same shape — an `except` clause naming the one
|
||||
content-failure member somebody had met (`json.JSONDecodeError`) instead of the
|
||||
`ValueError` family — and its fix was to name the family. This is the third instance in
|
||||
one module, and it is the last place in this file where the rule can be spelt
|
||||
twice. A fourth cause — an `OSError` subclass, a filesystem that accepts the
|
||||
`mkstemp` and refuses the `os.replace`, a container whose volume is unmounted
|
||||
mid-refresh — needs no fourth branch now, because none of them is asked about.
|
||||
|
||||
### Where the one question can be asked, which is the whole design
|
||||
|
||||
**"Can this store persist?" cannot be answered before the attempt.** A
|
||||
directory that exists and was writable a moment ago is not a write that
|
||||
completed; there is no pre-check that is not itself a guess, and adding one
|
||||
(`os.access`, a probe write) would be a *third* symptom test — precisely what
|
||||
the ticket rules out. So the predicate is an **outcome**:
|
||||
|
||||
_save() -> bool # True only if the file now holds what memory holds
|
||||
|
||||
Both ways of not persisting are the same answer from the same call: no durable
|
||||
volume returns `False` without touching the disk, an `OSError` out of the write
|
||||
returns `False` after recording why. `_remember()` reads that one answer and
|
||||
keeps only what it says was kept. **The `not self.ok` test in `_remember()` is
|
||||
gone** — not moved, gone — because there is nothing left for a second test to
|
||||
add, and a check in `test_price_history.py` 5d asserts its absence from the
|
||||
source.
|
||||
|
||||
⚠️ **That last check is deliberately a source check, and habit 4 is why it is
|
||||
allowed to be.** The claim "there are not two places testing two symptoms" is a
|
||||
claim about the *shape of the code*, not about a behaviour — the behaviour is
|
||||
identical whether the rule is enforced once or twice. Every other check in 5d
|
||||
is driven.
|
||||
|
||||
### What was NOT done, and why
|
||||
|
||||
- **Not a `can_persist()` helper.** It would have to answer before the attempt,
|
||||
which is the thing that cannot be done honestly, and it would be a second
|
||||
place to keep in step with the first.
|
||||
- **Not a probe write at `_open()`.** It would make `ok` mean something new, it
|
||||
would write to `/data` on every start, and a volume that is writable at 08:00
|
||||
and full at 14:00 would still defeat it. A predicate that can go stale is a
|
||||
symptom test wearing a predicate's name.
|
||||
- **Not making a failed write fatal.** §6, and this module's own 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 being fixed. The
|
||||
store degrades; it does not raise. `_save()` reports and returns.
|
||||
|
||||
---
|
||||
|
||||
## 2. The MERGE is undone, not the store
|
||||
|
||||
When `_save()` answers no, `_remember()` restores the state the read started
|
||||
from — **not** an empty one.
|
||||
|
||||
The alternative considered was to clear `self._days` outright, on the argument
|
||||
that a store which cannot write is a store that should stop answering. It is
|
||||
wrong, and measurably so: the state being restored is the last one that **is on
|
||||
disk**, so answering it is not a promise the store cannot keep. A site whose
|
||||
volume fills at 14:00 goes on answering the yesterday it wrote at 13:30 —
|
||||
through the restart, because that record is in the file. What it stops doing is
|
||||
*adding* to it.
|
||||
|
||||
Clearing would throw away a good yesterday that survives a restart in order to
|
||||
punish a write that failed, and it would make the morning fill worse on exactly
|
||||
the day the operator is already dealing with a full disk.
|
||||
|
||||
That gives the file the invariant its rule was always describing, stated once
|
||||
in the class docstring:
|
||||
|
||||
> `self._days` **never holds a point the file does not.**
|
||||
|
||||
⚠️ Not *"mirrors the file"* — `_usable_days()` drops records the file still
|
||||
holds, and that asymmetry is deliberate. The only direction that can **invent a
|
||||
yesterday** is the one named.
|
||||
|
||||
⚠️ **And the snapshot is `copy.deepcopy`, not `dict(self._days)`.** A one-level
|
||||
copy shares the day *records*, and the merge rebinds `points` on the ones it
|
||||
touches — so the "restored" store would answer the merge that never reached the
|
||||
file. Measured: with a shallow copy, a store holding 12 persisted points
|
||||
answered **24** after a failed merge. It would also be correct only for as long
|
||||
as nobody makes the merge mutate a `points` list or a row in place, which is the
|
||||
same "correct for the conditions someone thought of" this ticket is about. The
|
||||
store holds at most `KEEP_DAYS + 2` records and writes 48 times a day; a copy
|
||||
that is right by construction is worth more than the allocations.
|
||||
|
||||
---
|
||||
|
||||
## What this does not change
|
||||
|
||||
- **No shipped path reaches the defect.** `/data` is the Supervisor's durable
|
||||
volume and this module is its only writer, so on a real install `_save()`
|
||||
succeeds and nothing above is on the hot path. The fix is that a guarantee the
|
||||
module states about itself now holds in the condition it did not.
|
||||
- **The failure was already visible and still is** — `_LOG.error("could not
|
||||
persist price history: %s")` and `last_error`, reported by both `words` and
|
||||
`yesterday()`'s reason. What changed is that it is now *acted on*.
|
||||
- **Nothing about the prices.** The series served is the feed's own, every
|
||||
carried point is still flagged provisional, and a broken cache is still a
|
||||
short horizon rather than a §11.4 degradation.
|
||||
@@ -1395,6 +1395,27 @@ flagged as high-volatility, which widens arbitrage thresholds once the
|
||||
optimiser lands. That comes from the plugin's own declaration, not from its
|
||||
name.
|
||||
|
||||
**`generic_dayahead` and the shape of the public feed.** It reads Fraunhofer
|
||||
ISE's Energy-Charts API (`https://api.energy-charts.info/price?bzn=BE`, no key
|
||||
and no registration), which publishes a *columnar* document — parallel
|
||||
`unix_seconds` and `price` arrays, in EUR/MWh, with the unit and the licence
|
||||
stated in the response. Two things follow that are worth knowing before you
|
||||
point `price_feed_url` somewhere else:
|
||||
|
||||
* The timestamps are **UTC instants**. They are rendered at the Brussels offset
|
||||
that applies to each one, so the hour that happens twice every October is two
|
||||
points rather than one overwritten one.
|
||||
* Belgium moved to a **15-minute market time unit** on 2025-10-01, and the feed
|
||||
followed. A published day is **96 points, not 24**. Nothing in the add-on
|
||||
assumes either spacing, and nothing aggregates the quarters away.
|
||||
|
||||
If the arrays ever come back different lengths, or the unit stops saying
|
||||
EUR/MWh, the fetch is refused outright rather than trusted in part: a price
|
||||
series missing its last hours is a series missing the evening peak, and it
|
||||
would be traded on as though it were complete. A refused fetch is the §11.4
|
||||
row — static profile, provisional plan, no arbitrage — and the reason is in the
|
||||
log.
|
||||
|
||||
**`manual_csv` — the offline fallback.** It needs no internet, no supplier
|
||||
account and no HTTP stack at all, and it is the supported way to run this
|
||||
add-on price-aware on a site with no outbound connectivity. Write a file and
|
||||
@@ -1474,8 +1495,13 @@ a cheap night that has already gone used to sit in the series as the cheapest
|
||||
hour of the day, and a cycle can never buy in an hour that is past — so the
|
||||
trade was quietly never taken. The spread is now taken over hours still ahead.
|
||||
|
||||
A new status row, **Price history**, says which days are held and how many hours
|
||||
each has. If it reads `empty` or `unavailable`, nothing is carried, the horizon
|
||||
A new status row, **Price history**, says which days are held, how many price
|
||||
points each holds and how many whole hours those cover — `2026-01-13 (96 points
|
||||
over 24 h)`. ⚠️ **Those are two different numbers and the row used to print one
|
||||
of them under the other's name**: it counted points and labelled them `h`, so a
|
||||
quarter-hourly feed read `96 h` for a 24-hour day. The figure that says whether
|
||||
tomorrow morning's fill has a whole day to carry is the **hour** one. If it
|
||||
reads `empty` or `unavailable`, nothing is carried, the horizon
|
||||
is as short as the feed makes it, and every other guarantee is unchanged — a
|
||||
cache that cannot answer is not allowed to be a controller that cannot regulate.
|
||||
The store is rebuilt by the next successful fetch; on a brand-new install the
|
||||
@@ -1483,10 +1509,20 @@ first fill is possible the day after the first refresh.
|
||||
|
||||
The same applies to a file that is *partly* broken: a day record this build
|
||||
cannot read is dropped, the row says how many were dropped and why, and the rest
|
||||
of the file still loads. And where there is no `/data` at all the store records
|
||||
**nothing** — not even in memory. An in-memory yesterday would answer for one
|
||||
run and vanish on the next restart, which is a cache that forgets without saying
|
||||
so; `unavailable` and *no prices carried* are the honest pair.
|
||||
of the file still loads. And a store that **cannot persist records nothing** —
|
||||
not even in memory. That covers a missing `/data`, and it also covers a `/data`
|
||||
that is there and cannot be **written**: a full volume, a read-only remount. An
|
||||
in-memory yesterday would answer for one run and vanish on the next restart,
|
||||
which is a cache that forgets without saying so; the row naming the reason and
|
||||
*no prices carried* are the honest pair.
|
||||
|
||||
⚠️ **What a failed write does not do is throw away what is already in the
|
||||
file.** A volume that fills at 14:00 leaves the yesterday written at 13:30 still
|
||||
answering, because that record survives a restart — the store stops *adding* to
|
||||
it, nothing more. And it never stops the add-on: a write that cannot complete is
|
||||
logged and reported on the status row, never raised. A cache that cannot answer
|
||||
is not allowed to be a controller that cannot regulate, and that applies to the
|
||||
volume being full exactly as it applies to the volume being absent.
|
||||
|
||||
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
|
||||
often near zero, which by itself strongly favours self-consumption over export.
|
||||
|
||||
@@ -127,6 +127,43 @@ Two rules come out of it, and both are cheap:
|
||||
function, that pair is the first thing to try — TEST-16's two survivors were
|
||||
adjacent lines in one function and neither was individually live.
|
||||
|
||||
⚠️ **AND FCST-05 WALKED INTO IT AGAIN, IN ITS OWN NEW CHECK, WHICH IS WHY THE
|
||||
RULE IS “mutate every check you add”.** `price_history.py` reports a malformed
|
||||
point row from **two** places into **one** field: `_usable_days()` records what
|
||||
it FOUND when the file was opened, and `_remember()` records what it DROPPED on
|
||||
the next refresh. Both land in `last_error`, and `words` prints `last_error`.
|
||||
The first draft of the new check asked `"malformed point row" in words` — which
|
||||
whichever rung is still standing satisfies — so deleting `_remember()`'s note
|
||||
entirely was a **−1 B SURVIVOR of the whole suite**, on the half of the fix that
|
||||
tells an operator the cache **self-healed**. It is graded on the TENSE now
|
||||
(`… in <day>` at open, `… dropped from <day>` after the refresh), the two are
|
||||
asserted to DIFFER, and the paired mutation — both notes deleted, −91 B — is
|
||||
red on 15 named checks. Same shape as TEST-16 and the same fix: **grade the
|
||||
reason, not the verdict**, and when two rungs answer one stimulus, mutate them
|
||||
together.
|
||||
|
||||
⚠️ **AND FCST-06 FOUND THE COUNT ITSELF LYING — LOW, NOT SILENT.** A mutation
|
||||
whose red is a **traceback** does not report one red; it reports however many
|
||||
checks ran before the traceback, and grades **none** of the ones after it. The
|
||||
new section drove M4 (`self._days = before` → `self._days = {}`) and the run
|
||||
printed **1 FAIL**. The true figure is **3**: an f-string in the next check read
|
||||
`len(read.points)`, and `.points` on a `no_data` **raises `Absence` by design**,
|
||||
so the harness died mid-section with the remaining checks ungraded. That is
|
||||
`why()`'s and `derived()`'s rule — *"it answered nothing" and "it broke on the
|
||||
way to answering" must not grade the same* — arriving in the **printed operand**
|
||||
of a check rather than in its condition, which is the half that is easy to miss
|
||||
because it looks like formatting. Two things follow:
|
||||
|
||||
* **a mutation report's red COUNT is only a lower bound until the run is seen to
|
||||
have reached the end of its section.** Read the output, not the count;
|
||||
* **the total accessor has to cover what a check PRINTS, not only what it
|
||||
asserts.** `test_price_history.py` 5d added `npts()` beside `why()` for
|
||||
exactly this, and re-driving M4 against it moved 1 FAIL to 3.
|
||||
|
||||
Worse, had the traceback landed one check earlier the mutation would have
|
||||
produced **no named red at all** — a result that reads like a broken harness and
|
||||
gets retried rather than read.
|
||||
|
||||
## The third way a check lies: its DATA cannot reach the failure it names
|
||||
|
||||
Stale bytecode manufactures survivors from a mutation that never ran. Masking
|
||||
@@ -658,6 +695,45 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
that two of it cannot silently overwrite each other's work. A fixed path
|
||||
in a tool that edits source is a shared mutable global with a filename.
|
||||
|
||||
19. ⚠️ **A FIXTURE IN A SHAPE NOBODY PUBLISHES GRADES THE PARSER AGAINST THE
|
||||
PARSER — FOURTEENTH INSTANCE OF HABIT 4, AND THE FIRST WHERE THE FIXTURE
|
||||
AND THE CODE UNDER TEST SHARED A *SHAPE* RATHER THAN A NUMBER.** TARIFF-08.
|
||||
`price.py`'s `_points_from_json()` read `doc["data"]` as a list of
|
||||
`{start, price_eur_mwh}` objects. `api.energy-charts.info` publishes
|
||||
parallel `unix_seconds` / `price` arrays and has no `data` key at all, so
|
||||
`generic_dayahead` raised `SourceUnavailable` on **every live fetch it ever
|
||||
made** — and the suite was green, because all three price suites built
|
||||
their bodies with `json.dumps({"data": [...]})`. The fixture was derived
|
||||
from the parser's guess, so no mutation of the parser and no amount of
|
||||
coverage inside that loop could ever have reached the failure. §11.4 then
|
||||
turned it into a *correct* silent degradation: parse error -> `no_data` ->
|
||||
static profile -> arbitrage off, with nothing naming the cause.
|
||||
|
||||
Habit 2 with a network on the end of it: **the reference has to be data the
|
||||
code under test did not shape.** For a parser that means BYTES, captured
|
||||
from the source and committed unmodified, with the URL, the retrieval time
|
||||
and a checksum beside them - `scenarios/energy-charts-be-price-*.json` and
|
||||
its `-captures.json` provenance, re-derived by the replay so an edited
|
||||
fixture stops being a capture. Two granularities, because the same endpoint
|
||||
answers hourly before 2025-10-01 and quarter-hourly after it and a fixture
|
||||
at one spacing cannot grade an assumption about the other.
|
||||
|
||||
⚠️ **AND THE SURVIVING SYNTHETIC FIXTURES ARE NAMED FOR WHAT THEY ARE.**
|
||||
Three shipped plugins point at endpoints nobody has verified (TARIFF-07),
|
||||
so no capture exists for them; their fixture builder is called
|
||||
`synthetic_row_feed` at every call site, because the next reader's default
|
||||
assumption will otherwise be that a green check means the feed parses.
|
||||
|
||||
⚠️ **ONE OF THIS CARD'S OWN NEW LINES WAS DECORATION AND THE BATTERY SAID
|
||||
SO.** The columnar parser first rendered its instants with `brussels()`
|
||||
before returning them. `assemble()` already renders every plugin's points,
|
||||
so removing the parser's call changed nothing observable - **M4 came back a
|
||||
survivor on a `:ro`, purged tree**, which is not the direction stale
|
||||
bytecode fabricates. The line was removed rather than fenced with a check:
|
||||
a second copy of a rule that already has an owner is indistinguishable from
|
||||
a no-op, and the mutation that proves the rendering is real belongs on
|
||||
`assemble()`, where it is now aimed.
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
|
||||
@@ -979,9 +979,166 @@ def _points_from_json(body, price_key, scale, provisional_default):
|
||||
return out
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TARIFF-08: the COLUMNAR document, which is what the public EPEX feed really
|
||||
# publishes.
|
||||
#
|
||||
# ⚠️ THIS IS A SECOND SHAPE, READ BY A SECOND FUNCTION ON PURPOSE.
|
||||
# `_points_from_json()` above reads a row-per-point document: `{"data": [{...},
|
||||
# {...}]}`. `https://api.energy-charts.info/price?bzn=BE` does not publish that
|
||||
# and never did - it publishes PARALLEL ARRAYS:
|
||||
#
|
||||
# {"unix_seconds": [1788991200, 1788992100, ...], <- UTC epoch seconds
|
||||
# "price": [216.08, 209.90, ...], <- one per instant
|
||||
# "unit": "EUR / MWh", "license_info": "...", "deprecated": false}
|
||||
#
|
||||
# The shipped parser was pointed at that URL and asked for `doc["data"]`, so
|
||||
# every fetch ended `SourceUnavailable: the feed carried no 'data' list` ->
|
||||
# §11.4 -> static profile, arbitrage off, and nothing in the status page said
|
||||
# why. Verified live 2026-09-10 against both fixtures under `scenarios/`.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT ONE FUNCTION THAT SNIFFS FOR BOTH. A parser that decides
|
||||
# which document it got is a parser that can decide wrong, and a wrong-but-
|
||||
# parseable response then becomes a price. Two shapes, two functions, and each
|
||||
# plugin names the one its own feed publishes.
|
||||
#
|
||||
# The columnar shape was already known in this repository: DATA-02's
|
||||
# `scenarios/fetch_epex_be.py` has read `zip(d["unix_seconds"], d["price"])`
|
||||
# since 2026-08-31. Only the SHIPPED path never learned it, because every
|
||||
# fixture in the suite was built from the invented row shape - the parser was
|
||||
# graded against its own assumption. See test_price.py section 2b.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
COLUMNAR_TIME_KEY = "unix_seconds"
|
||||
COLUMNAR_PRICE_KEY = "price"
|
||||
COLUMNAR_UNIT_KEY = "unit"
|
||||
|
||||
|
||||
def _columnar_unit(text) -> str:
|
||||
"""`"EUR / MWh"` -> `"eur/mwh"`. Spacing and case are not information."""
|
||||
return "".join(str(text).split()).lower()
|
||||
|
||||
|
||||
def _points_from_columnar_json(body, expect_unit, scale,
|
||||
provisional_default):
|
||||
"""Parse a COLUMNAR price document: parallel `unix_seconds` / `price`.
|
||||
|
||||
Defects this refuses, each of which has a way of becoming a number rather
|
||||
than an error:
|
||||
|
||||
* ⚠️ UNEQUAL ARRAYS ARE `SourceUnavailable`, NOT A SHORT SERIES. `zip()`
|
||||
stops at the shorter array and says nothing, so a `price` list that
|
||||
arrived truncated would produce a REAL price series with its tail
|
||||
missing - and the tail of a Belgian day is the evening peak. A series
|
||||
that is missing the expensive hours is worse than no series: §11.4's
|
||||
no-data row switches arbitrage OFF, whereas a truncated series is
|
||||
traded on. Nothing downstream can tell the two apart, so the refusal
|
||||
has to happen here.
|
||||
* ⚠️ A UNIT THAT IS NOT THE DECLARED ONE IS REFUSED. `scale` is the
|
||||
caller's standing assumption about the unit; if the feed ever
|
||||
publishes EUR/kWh under the same keys, every price silently comes out
|
||||
a THOUSAND times too small. `_validate_raw()` cannot catch that -
|
||||
`MAX_PLAUSIBLE_EUR_KWH` only bounds prices that are too LARGE.
|
||||
DATA-02's fetcher refuses on the same field for the same reason.
|
||||
* ⚠️ `unix_seconds` ARE UTC INSTANTS, SAID SO EXPLICITLY.
|
||||
`datetime.fromtimestamp(t, timezone.utc)`. Without that second
|
||||
argument CPython returns a NAIVE datetime in the HOST's zone, and a
|
||||
naive datetime is a wall clock reading plus an assumption about whose
|
||||
clock it was. `brussels()` and `_validate_raw()` both refuse one, so
|
||||
the mistake surfaces as a refused fetch rather than as a mispriced
|
||||
day - but they refuse ALL naive datetimes and can say nothing about
|
||||
whether this one was the right instant. That part is decided here.
|
||||
|
||||
§9.3's rendering AT THE BRUSSELS OFFSET is `assemble()`'s, once, for
|
||||
every plugin - `brussels(p.start)` there is the single line that does
|
||||
it, and calling it here as well would be a second copy of a rule that
|
||||
already has an owner, indistinguishable from a no-op under mutation.
|
||||
A plugin's job is the right INSTANT; the framework's is the rendering.
|
||||
|
||||
A `null` price is DROPPED and logged, not fatal, and that is a different
|
||||
judgement from the one above: an unequal pair of arrays means the pairing
|
||||
of instant to price is unreliable, whereas a null means the source stated
|
||||
it published no price for that one instant. A dropped instant leaves a
|
||||
hole, and `_Series.at()` already answers a hole with `Absence` - §11.4 for
|
||||
that hour. `scenarios/fetch_epex_be.py` dropped 0 nulls over the 12 months
|
||||
to 2026-08-31, so this is a shape the source allows rather than one it uses.
|
||||
"""
|
||||
try:
|
||||
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \
|
||||
else str(body)
|
||||
doc = json.loads(text)
|
||||
except (ValueError, UnicodeDecodeError) as e:
|
||||
raise SourceUnavailable(f"the feed did not parse as JSON: {e}")
|
||||
if not isinstance(doc, dict):
|
||||
raise SourceUnavailable(
|
||||
f"the feed is a {type(doc).__name__}, not a columnar object with "
|
||||
f"{COLUMNAR_TIME_KEY!r} and {COLUMNAR_PRICE_KEY!r} arrays")
|
||||
times = doc.get(COLUMNAR_TIME_KEY)
|
||||
prices = doc.get(COLUMNAR_PRICE_KEY)
|
||||
for key, col in ((COLUMNAR_TIME_KEY, times), (COLUMNAR_PRICE_KEY, prices)):
|
||||
if not isinstance(col, list):
|
||||
raise SourceUnavailable(
|
||||
f"the feed carried no {key!r} array; this provider reads the "
|
||||
f"columnar shape (parallel {COLUMNAR_TIME_KEY}/"
|
||||
f"{COLUMNAR_PRICE_KEY} arrays), and a row-per-point document "
|
||||
f"is a different feed, not this one with a field missing")
|
||||
if len(times) != len(prices):
|
||||
raise SourceUnavailable(
|
||||
f"{COLUMNAR_TIME_KEY} has {len(times)} entries and "
|
||||
f"{COLUMNAR_PRICE_KEY} has {len(prices)}; refusing to zip them. "
|
||||
f"The short array would truncate the series silently, and a price "
|
||||
f"series missing its last hours is one missing the evening peak - "
|
||||
f"traded on as if complete, where no data at all would have "
|
||||
f"switched arbitrage off (§11.4)")
|
||||
unit = _columnar_unit(doc.get(COLUMNAR_UNIT_KEY, ""))
|
||||
if unit != _columnar_unit(expect_unit):
|
||||
raise SourceUnavailable(
|
||||
f"the feed reports {doc.get(COLUMNAR_UNIT_KEY)!r}, not "
|
||||
f"{expect_unit!r}. This provider converts with a fixed factor of "
|
||||
f"{scale}, so a changed unit is a factor of 1000 on every price "
|
||||
f"with nothing downstream able to notice it")
|
||||
if doc.get("deprecated"):
|
||||
# Not a refusal: today's prices are still prices. But §11.4 makes a
|
||||
# dead feed look exactly like a quiet one, so the warning that the
|
||||
# source is going away has to be emitted while it still works.
|
||||
_LOG.warning("the price feed reports deprecated=%r; it still answers, "
|
||||
"but this endpoint is scheduled to stop",
|
||||
doc.get("deprecated"))
|
||||
out = []
|
||||
dropped = []
|
||||
for i, (t, v) in enumerate(zip(times, prices)):
|
||||
if v is None:
|
||||
dropped.append(i)
|
||||
continue
|
||||
if isinstance(t, bool) or not isinstance(t, (int, float)) or \
|
||||
not math.isfinite(t):
|
||||
raise SourceUnavailable(
|
||||
f"{COLUMNAR_TIME_KEY}[{i}] is {t!r}, not epoch seconds")
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float)) or \
|
||||
not math.isfinite(v):
|
||||
raise SourceUnavailable(
|
||||
f"{COLUMNAR_PRICE_KEY}[{i}] is {v!r}, which is not a price")
|
||||
start = datetime.fromtimestamp(float(t), timezone.utc)
|
||||
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
|
||||
provisional=bool(provisional_default)))
|
||||
if dropped:
|
||||
_LOG.warning("the feed published no price for %d of %d instants "
|
||||
"(index %s); those instants are left OUT of the series, "
|
||||
"never zero-filled - a missing hour is not a free hour",
|
||||
len(dropped), len(times),
|
||||
", ".join(str(i) for i in dropped[:5])
|
||||
+ (", ..." if len(dropped) > 5 else ""))
|
||||
if not out:
|
||||
raise SourceUnavailable(
|
||||
f"the feed carried {len(times)} instants and a price for none of "
|
||||
f"them")
|
||||
return out
|
||||
|
||||
|
||||
@register
|
||||
class GenericDayAhead(PriceProvider):
|
||||
"""Public EPEX BE day-ahead feed, in EUR/MWh.
|
||||
"""Public EPEX BE day-ahead feed, COLUMNAR, in EUR/MWh.
|
||||
|
||||
SOURCES: energy
|
||||
The framework ADDS: markup, taxes
|
||||
@@ -990,6 +1147,19 @@ class GenericDayAhead(PriceProvider):
|
||||
is the wholesale clearing price and nothing else, so both user-entered
|
||||
components have to be added to it - and because this plugin cannot read
|
||||
either number, it cannot be the one that adds them twice.
|
||||
|
||||
⚠️ IT READS THE COLUMNAR SHAPE, AND SAYS SO IN ITS OWN `fetch()`. Until
|
||||
TARIFF-08 it called `_points_from_json()` and asked
|
||||
`api.energy-charts.info` for a `"data"` list it has never published, so
|
||||
every fetch this provider ever made ended in `SourceUnavailable` and §11.4
|
||||
turned that into a silent fall back to the static profile. See
|
||||
`_points_from_columnar_json()` for the shape and for why there are two
|
||||
parsers rather than one that guesses.
|
||||
|
||||
⚠️ THE FEED IS QUARTER-HOURLY SINCE BELGIUM'S 15-MINUTE MTU (2025-10-01):
|
||||
96 points a day at 900 s, not 24 at 3600 s. Nothing here assumes either -
|
||||
the instants come from the document - and both granularities are replayed
|
||||
from real captured bodies in test_price.py section 2b.
|
||||
"""
|
||||
|
||||
id = "generic_dayahead"
|
||||
@@ -1006,7 +1176,9 @@ class GenericDayAhead(PriceProvider):
|
||||
"https://api.energy-charts.info/price?bzn=BE"
|
||||
check_egress(url, self.capabilities())
|
||||
body = await transport(url, float(opts.get("price_timeout_s")))
|
||||
return _points_from_json(body, "price_eur_mwh", 0.001, False)
|
||||
return _points_from_columnar_json(body, expect_unit="EUR / MWh",
|
||||
scale=0.001,
|
||||
provisional_default=False)
|
||||
|
||||
|
||||
@register
|
||||
|
||||
@@ -55,6 +55,17 @@ So the flag is not passed through anywhere in this file:
|
||||
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
|
||||
@@ -225,14 +236,67 @@ def _audit(points, published) -> None:
|
||||
* 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 WHY IT IS STRICTER. This
|
||||
audit began as a `seen` set: any repeated `_utc_hour` raised, because a
|
||||
duplicated hour is how a DST day gets mispriced. 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. A repeated CARRIED hour still raises; so does the same
|
||||
published point emitted twice; and a carried point may no longer share an
|
||||
hour with a published one, which the count never checked at all.
|
||||
⚠️ 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
|
||||
|
||||
@@ -61,6 +61,38 @@ 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
|
||||
=====================================
|
||||
@@ -148,6 +180,7 @@ and turning it on is a product decision, not a wiring one. See
|
||||
DECISIONS/FCST-04-the-day-that-is-carried.md.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -208,8 +241,65 @@ def _yesterday_of(instant: datetime) -> str:
|
||||
return (brussels(instant).date() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _n_points(rec) -> int:
|
||||
"""How many points a day record holds. 0 for anything that is not one.
|
||||
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.
|
||||
"""
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
try:
|
||||
start = datetime.fromisoformat(row["start"])
|
||||
value = float(row["eur_per_kwh"])
|
||||
flag = row["provisional"]
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if start.tzinfo is None or start.utcoffset() is None \
|
||||
or not isinstance(flag, bool):
|
||||
return None
|
||||
return PricePoint(start=start, eur_per_kwh=value, provisional=flag)
|
||||
|
||||
|
||||
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
|
||||
@@ -220,11 +310,52 @@ def _n_points(rec) -> int:
|
||||
"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.
|
||||
"""
|
||||
if not isinstance(rec, dict):
|
||||
return (0, 0)
|
||||
raw = rec.get("points")
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return (0, 0)
|
||||
n, hours = 0, set()
|
||||
for row in raw:
|
||||
pt = _point_of(row)
|
||||
if pt is None:
|
||||
continue
|
||||
n += 1
|
||||
hours.add(pt.start.astimezone(timezone.utc)
|
||||
.replace(minute=0, second=0, microsecond=0))
|
||||
return (n, len(hours))
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
return (f"{day} ({points} point{'' if points == 1 else 's'} "
|
||||
f"over {hours} h)")
|
||||
|
||||
|
||||
def _bad_rows(rec) -> int:
|
||||
"""How many rows of a day record are not points. Total; never raises."""
|
||||
if not isinstance(rec, dict):
|
||||
return 0
|
||||
pts = rec.get("points")
|
||||
return len(pts) if isinstance(pts, (list, tuple)) else 0
|
||||
raw = rec.get("points")
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return 0
|
||||
return sum(1 for row in raw if _point_of(row) is None)
|
||||
|
||||
|
||||
class PriceHistory:
|
||||
@@ -235,6 +366,14 @@ class PriceHistory:
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str = "/data/price_history.json"):
|
||||
@@ -269,11 +408,33 @@ class PriceHistory:
|
||||
"one, and the fill has nothing to carry until the day "
|
||||
"after that", self.path)
|
||||
return
|
||||
except (json.JSONDecodeError, OSError) as err:
|
||||
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.
|
||||
self.last_error = f"unreadable ({err})"
|
||||
_LOG.warning("price history at %s is %s - starting from empty. "
|
||||
"§8.3 carry-forward has nothing to carry until the "
|
||||
@@ -318,33 +479,71 @@ class PriceHistory:
|
||||
|
||||
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.
|
||||
"""
|
||||
kept, dropped = {}, []
|
||||
kept, dropped, malformed = {}, [], {}
|
||||
for key, rec in days.items():
|
||||
if not isinstance(key, str) or not isinstance(rec, dict) \
|
||||
or not isinstance(rec.get("points"), list):
|
||||
dropped.append(str(key))
|
||||
continue
|
||||
bad = _bad_rows(rec)
|
||||
if bad:
|
||||
malformed[key] = bad
|
||||
kept[key] = rec
|
||||
notes = []
|
||||
if dropped:
|
||||
notes.append(f"{len(dropped)} unusable day record(s) dropped "
|
||||
f"({', '.join(sorted(dropped))})")
|
||||
if malformed:
|
||||
notes.append(
|
||||
f"{sum(malformed.values())} malformed point row(s) in "
|
||||
+ ", ".join(f"{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.
|
||||
self.last_error = (f"{len(dropped)} unusable day record(s) dropped "
|
||||
f"({', '.join(sorted(dropped))})")
|
||||
self.last_error = "; ".join(notes)
|
||||
_LOG.warning("price history at %s: %s - the controller starts "
|
||||
"anyway and the horizon is as short as the feed makes "
|
||||
"it", self.path, self.last_error)
|
||||
return kept
|
||||
|
||||
def _save(self) -> None:
|
||||
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.
|
||||
"""
|
||||
if not self.ok:
|
||||
return
|
||||
# ⚠️ 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.
|
||||
return False
|
||||
tmp = None
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path))
|
||||
@@ -357,12 +556,14 @@ class PriceHistory:
|
||||
except OSError as err:
|
||||
self.last_error = f"could not write ({err})"
|
||||
_LOG.error("could not persist price history: %s", err)
|
||||
return False
|
||||
finally:
|
||||
if tmp is not None:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError: # nothing further to try, and a
|
||||
pass # failed cleanup is not a fault
|
||||
return True
|
||||
|
||||
# -- the one public entry point -----------------------------------------
|
||||
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
|
||||
@@ -399,15 +600,6 @@ class PriceHistory:
|
||||
nothing outside this file names it, and `test_price_history.py` checks
|
||||
the tree for that rather than trusting the underscore.
|
||||
"""
|
||||
if not self.ok:
|
||||
# ⚠️ A STORE THAT CANNOT PERSIST RECORDS NOTHING. See the module
|
||||
# docstring's third warning: filling `self._days` here would give a
|
||||
# site with no /data an in-memory yesterday that answers a series
|
||||
# all day and is gone on the next restart - a cache that forgets
|
||||
# without saying so, which is the one thing that rule refuses. The
|
||||
# honest degraded state is `words` = "unavailable" and `yesterday()`
|
||||
# = `no_data`, and those two must agree.
|
||||
return
|
||||
points = read.match(series=lambda r: r.points, no_data=lambda _r: ())
|
||||
if not points:
|
||||
return
|
||||
@@ -416,7 +608,27 @@ 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.
|
||||
before = copy.deepcopy(self._days)
|
||||
|
||||
touched = set()
|
||||
junked = {}
|
||||
for p in points:
|
||||
day = _bdate(p.start)
|
||||
rec = self._days.setdefault(
|
||||
@@ -424,16 +636,71 @@ class PriceHistory:
|
||||
# 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.
|
||||
raw = rec.get("points")
|
||||
held = {q["start"]: q for q in (raw if isinstance(raw, list) else ())
|
||||
if isinstance(q, dict) and "start" in q}
|
||||
held[p.start.isoformat()] = p.canonical()
|
||||
rec["points"] = sorted(held.values(), key=lambda q: q["start"])
|
||||
held = {}
|
||||
for q in (raw if isinstance(raw, list) else ()):
|
||||
kept = _point_of(q)
|
||||
if kept is None:
|
||||
junked[day] = junked.get(day, 0) + 1
|
||||
continue
|
||||
held[kept.start] = q
|
||||
held[p.start] = p.canonical()
|
||||
order = sorted(held, key=lambda t: t.astimezone(timezone.utc))
|
||||
rec["points"] = [held[t] for t in order]
|
||||
rec["product"], rec["provider"] = product, provider
|
||||
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.
|
||||
self.last_error = (
|
||||
f"{sum(junked.values())} malformed point row(s) dropped from "
|
||||
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items())))
|
||||
_LOG.warning("price history at %s: %s - the record is rewritten "
|
||||
"without them and the refresh continues",
|
||||
self.path, self.last_error)
|
||||
self._prune(stamp)
|
||||
self._save()
|
||||
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.
|
||||
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))
|
||||
|
||||
@@ -523,15 +790,21 @@ class PriceHistory:
|
||||
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 `_n_points()` is total, so this has two independent reasons
|
||||
not to fail on a broken file.
|
||||
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.
|
||||
"""
|
||||
if not self.ok:
|
||||
return f"unavailable - {self.last_error}"
|
||||
if not self._days:
|
||||
why = f" - {self.last_error}" if self.last_error else ""
|
||||
return f"empty{why} - nothing to carry forward yet"
|
||||
held = "; ".join(f"{d} ({_n_points(self._days[d])} h)"
|
||||
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.
|
||||
@@ -550,21 +823,14 @@ def _parse_points(raw) -> tuple:
|
||||
return (), "the `points` field is not a list"
|
||||
out, dropped = [], 0
|
||||
for row in raw:
|
||||
if not isinstance(row, dict):
|
||||
# ⚠️ `_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.
|
||||
pt = _point_of(row)
|
||||
if pt is None:
|
||||
dropped += 1
|
||||
continue
|
||||
try:
|
||||
start = datetime.fromisoformat(row["start"])
|
||||
value = float(row["eur_per_kwh"])
|
||||
flag = row["provisional"]
|
||||
except (KeyError, TypeError, ValueError):
|
||||
dropped += 1
|
||||
continue
|
||||
if start.tzinfo is None or start.utcoffset() is None \
|
||||
or not isinstance(flag, bool):
|
||||
dropped += 1
|
||||
continue
|
||||
out.append(PricePoint(start=start, eur_per_kwh=value, provisional=flag))
|
||||
out.append(pt)
|
||||
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
|
||||
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1757455200,1757458800,1757462400,1757466000,1757469600,1757473200,1757476800,1757480400,1757484000,1757487600,1757491200,1757494800,1757498400,1757502000,1757505600,1757509200,1757512800,1757516400,1757520000,1757523600,1757527200,1757530800,1757534400,1757538000],"price":[73.0,61.31,64.32,62.49,67.0,64.48,94.52,149.26,132.43,91.61,84.8,79.61,69.23,66.75,68.91,69.31,80.29,66.91,105.2,161.05,116.73,84.2,64.1,42.41],"unit":"EUR / MWh","deprecated":false}
|
||||
@@ -0,0 +1 @@
|
||||
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1788991200,1788992100,1788993000,1788993900,1788994800,1788995700,1788996600,1788997500,1788998400,1788999300,1789000200,1789001100,1789002000,1789002900,1789003800,1789004700,1789005600,1789006500,1789007400,1789008300,1789009200,1789010100,1789011000,1789011900,1789012800,1789013700,1789014600,1789015500,1789016400,1789017300,1789018200,1789019100,1789020000,1789020900,1789021800,1789022700,1789023600,1789024500,1789025400,1789026300,1789027200,1789028100,1789029000,1789029900,1789030800,1789031700,1789032600,1789033500,1789034400,1789035300,1789036200,1789037100,1789038000,1789038900,1789039800,1789040700,1789041600,1789042500,1789043400,1789044300,1789045200,1789046100,1789047000,1789047900,1789048800,1789049700,1789050600,1789051500,1789052400,1789053300,1789054200,1789055100,1789056000,1789056900,1789057800,1789058700,1789059600,1789060500,1789061400,1789062300,1789063200,1789064100,1789065000,1789065900,1789066800,1789067700,1789068600,1789069500,1789070400,1789071300,1789072200,1789073100,1789074000,1789074900,1789075800,1789076700],"price":[216.08,216.08,214.92,200.0,223.37,201.43,196.32,190.0,199.53,194.83,180.53,172.23,179.88,175.77,167.1,173.89,163.29,165.99,171.41,178.73,170.27,177.37,187.71,201.07,193.62,207.61,218.27,226.77,227.17,234.05,236.99,229.94,248.93,227.5,216.08,195.67,215.99,195.01,177.1,155.09,174.91,152.71,139.59,123.83,140.77,123.19,114.96,94.5,102.77,96.63,94.57,116.34,114.63,126.82,129.89,123.95,105.0,119.11,121.0,136.79,123.41,135.01,135.0,156.68,146.88,152.16,162.02,181.78,154.76,184.28,194.3,215.76,197.23,234.15,253.03,296.58,345.32,389.16,482.99,559.0,551.11,446.41,358.97,295.9,271.39,268.06,241.43,238.01,239.7,226.41,223.05,211.56,218.21,210.61,197.78,171.79],"unit":"EUR / MWh","deprecated":false}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"card": "TARIFF-08",
|
||||
"what": "UNMODIFIED response bodies from the public EPEX BE day-ahead feed, captured so the SHIPPED parser can be replayed against what the source really publishes",
|
||||
"why": "every price fixture in the suite was built from a `{\"data\": [{\"start\": ..., \"price_eur_mwh\": ...}]}` document that this feed has never published. The suite therefore graded `_points_from_json` against its own assumption - a closed loop that could not notice the assumption was wrong - and §11.4 turned the resulting failure into a silent fall back to the static profile. A captured real body is what breaks that loop, so these files are bytes, not a reconstruction: nothing here is pretty-printed, re-keyed or trimmed.",
|
||||
"source": "Energy-Charts API (Fraunhofer ISE), api.energy-charts.info",
|
||||
"upstream": "Bundesnetzagentur | SMARD.de",
|
||||
"licence": "CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)",
|
||||
"licence_note": "the BE bidding zone is in the API's CC BY 4.0 list, so redistribution is permitted with attribution. DATA-02 established this against the API's own OpenAPI document before committing `epex-be-dayahead-2025-09-01_2026-09-01.json`; both bodies below carry the same `license_info` string, recorded per file and asserted by the replay test.",
|
||||
"attribution": "Data licensed CC BY 4.0 by Bundesnetzagentur | SMARD.de, retrieved via the Energy-Charts API (Fraunhofer ISE), https://energy-charts.info. Published without changes.",
|
||||
"retrieved_utc": "2026-09-10T06:35:00+00:00",
|
||||
"retrieved_with": "curl, by hand; no code in `app/` fetches these",
|
||||
"shape": "COLUMNAR: parallel `unix_seconds` (UTC epoch seconds) and `price` arrays, plus `unit`, `license_info` and `deprecated`. There is no `data` key and no per-point object.",
|
||||
"replayed_by": "test_price.py section 2b",
|
||||
"files": {
|
||||
"energy-charts-be-price-2026-09-10-quarterhourly.json": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE",
|
||||
"note": "the SHIPPED default URL of `generic_dayahead`, verbatim and with no parameters, so the replay drives the bytes the add-on's own request produces",
|
||||
"covers_local_day": "2026-09-10 (Europe/Brussels)",
|
||||
"granularity_s": 900,
|
||||
"intervals": 96,
|
||||
"bytes": 1877,
|
||||
"sha256": "176309d0020ad208320797385ef68b6a98ac248218539416cddf6579dd060487",
|
||||
"unit_as_returned": "EUR / MWh",
|
||||
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated_as_returned": false,
|
||||
"top_level_keys": [
|
||||
"deprecated",
|
||||
"license_info",
|
||||
"price",
|
||||
"unit",
|
||||
"unix_seconds"
|
||||
]
|
||||
},
|
||||
"energy-charts-be-price-2025-09-10-hourly.json": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE&start=2025-09-10&end=2025-09-10",
|
||||
"note": "a day BEFORE Belgium's 15-minute MTU (2025-10-01), so the same endpoint answers HOURLY. Kept because the parser must not assume either spacing, and a fixture at only one granularity cannot grade that",
|
||||
"covers_local_day": "2025-09-10 (Europe/Brussels)",
|
||||
"granularity_s": 3600,
|
||||
"intervals": 24,
|
||||
"bytes": 572,
|
||||
"sha256": "03fe41690810df224611f4016899f856457f67a3c12c7654da17b1331ff2eff4",
|
||||
"unit_as_returned": "EUR / MWh",
|
||||
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated_as_returned": false,
|
||||
"top_level_keys": [
|
||||
"deprecated",
|
||||
"license_info",
|
||||
"price",
|
||||
"unit",
|
||||
"unix_seconds"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+350
-18
@@ -45,6 +45,7 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import hashlib
|
||||
import inspect
|
||||
import io
|
||||
import json
|
||||
@@ -148,6 +149,7 @@ def derived(fn):
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# ⚠️ NOT the shipped 0.03 / 0.06. See this module's docstring: 0.06 is exactly
|
||||
# 2 x 0.03, so under the shipped pair "the framework added the markup twice and
|
||||
@@ -283,10 +285,31 @@ RETAIL_EUR_KWH = [0.1341, 0.0872, 0.2610] # EUR/kWh, markup already inside
|
||||
EDDY_EUR_MWH = [141.0, 96.2, 268.4] # EUR/MWh, all-in
|
||||
|
||||
|
||||
def json_feed(key, values, provisional_at=1):
|
||||
"""A feed body. `provisional_at` makes ONE point provisional and the rest
|
||||
not, so `plan_is_provisional` is graded against a mixed series - an
|
||||
all-true or all-false fixture would return the same answer under any bug."""
|
||||
def synthetic_row_feed(key, values, provisional_at=1):
|
||||
"""An INVENTED row-per-point body. `provisional_at` makes ONE point
|
||||
provisional and the rest not, so `plan_is_provisional` is graded against a
|
||||
mixed series - an all-true or all-false fixture would return the same
|
||||
answer under any bug.
|
||||
|
||||
⚠️ THE NAME SAYS `synthetic` BECAUSE TARIFF-08 IS WHAT HAPPENS WHEN IT DOES
|
||||
NOT. This shape - `{"data": [{"start": ..., "price_eur_mwh": ...}]}` - was
|
||||
invented by whoever wrote `_points_from_json()`, and until 2026-09-10 it
|
||||
was the ONLY shape any price check in this repository had ever fed a
|
||||
parser, `generic_dayahead` included. The public EPEX feed publishes
|
||||
parallel arrays and no `data` key at all, so every live fetch that plugin
|
||||
ever made raised `SourceUnavailable` while the suite stayed green: the
|
||||
checks were grading the parser against the parser's own assumption. §11.4
|
||||
then turned the failure into a quiet fall back to the static profile, so a
|
||||
site would have sat on it forever.
|
||||
|
||||
The three plugins still fed from here - `engie_dynamic`,
|
||||
`totalenergies_dynamic`, `eneco_eddy` - have NO captured body, because
|
||||
their endpoints are unverified (TARIFF-07 is the open decision on whether
|
||||
they are withdrawn). This fixture therefore grades that their DECLARATIONS
|
||||
and the framework arithmetic are right; it grades NOTHING about what those
|
||||
feeds publish, and it must not be read as evidence that they parse.
|
||||
`generic_dayahead` is replayed from real bytes instead - section 2b.
|
||||
"""
|
||||
return json.dumps({"data": [
|
||||
{"start": (T0 + timedelta(hours=i)).isoformat(),
|
||||
key: v,
|
||||
@@ -294,6 +317,37 @@ def json_feed(key, values, provisional_at=1):
|
||||
for i, v in enumerate(values)]}).encode()
|
||||
|
||||
|
||||
def columnar_feed(values, provisional_at=None, unit="EUR / MWh",
|
||||
step_min=60, start=None, prices=None):
|
||||
"""A body in the shape `api.energy-charts.info` really publishes.
|
||||
|
||||
Parallel arrays and no per-point object; `unix_seconds` are UTC epoch
|
||||
seconds. `prices` overrides the price column outright, which is how the
|
||||
unequal-length case below is built without hand-writing JSON.
|
||||
|
||||
⚠️ THERE IS NO PER-POINT `provisional` IN THIS SHAPE, so `provisional_at`
|
||||
exists only to be REFUSED - the columnar document cannot carry the flag and
|
||||
the plugin supplies it. It is a keyword the callers do not pass; the
|
||||
parameter is here so that a future caller who reaches for it gets a
|
||||
TypeError rather than a silently ignored argument.
|
||||
"""
|
||||
if provisional_at is not None:
|
||||
raise TypeError(
|
||||
"the columnar document has no per-point provisional flag; "
|
||||
"`generic_dayahead` declares it in fetch(). See "
|
||||
"_points_from_columnar_json().")
|
||||
t0 = start if start is not None else T0
|
||||
secs = [int((t0 + timedelta(minutes=step_min * i)).timestamp())
|
||||
for i in range(len(values))]
|
||||
doc = {"unix_seconds": secs,
|
||||
"price": list(values) if prices is None else list(prices),
|
||||
"unit": unit,
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||||
"from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated": False}
|
||||
return json.dumps(doc).encode()
|
||||
|
||||
|
||||
CSV_BODY = ("start,eur_per_kwh,provisional\n"
|
||||
"2026-01-15T00:00:00+01:00,0.1834,false\n"
|
||||
"2026-01-15T01:00:00+01:00,-0.0210,true\n"
|
||||
@@ -302,14 +356,18 @@ CSV_BODY = ("start,eur_per_kwh,provisional\n"
|
||||
# The fixture, the transport that serves it, and - crucially - the ENERGY
|
||||
# component of each point, computed here so the all-in expectation below is
|
||||
# arithmetic this file did itself.
|
||||
# ⚠️ `generic_dayahead` IS FED THE COLUMNAR SHAPE AND THE OTHER THREE ARE NOT,
|
||||
# and the asymmetry is the honest one rather than an oversight: the columnar
|
||||
# document is what the one endpoint anybody has ever reached actually returns.
|
||||
FIXTURES = {
|
||||
"generic_dayahead": (json_feed("price_eur_mwh", EPEX_EUR_MWH),
|
||||
"generic_dayahead": (columnar_feed(EPEX_EUR_MWH),
|
||||
[v / 1000 for v in EPEX_EUR_MWH]),
|
||||
"engie_dynamic": (json_feed("price_eur_kwh", RETAIL_EUR_KWH),
|
||||
"engie_dynamic": (synthetic_row_feed("price_eur_kwh", RETAIL_EUR_KWH),
|
||||
list(RETAIL_EUR_KWH)),
|
||||
"totalenergies_dynamic": (json_feed("price_eur_kwh", RETAIL_EUR_KWH),
|
||||
"totalenergies_dynamic": (synthetic_row_feed("price_eur_kwh",
|
||||
RETAIL_EUR_KWH),
|
||||
list(RETAIL_EUR_KWH)),
|
||||
"eneco_eddy": (json_feed("price_eur_mwh", EDDY_EUR_MWH),
|
||||
"eneco_eddy": (synthetic_row_feed("price_eur_mwh", EDDY_EUR_MWH),
|
||||
[v / 1000 for v in EDDY_EUR_MWH]),
|
||||
"manual_csv": (None, [0.1834, -0.0210, 0.3115]),
|
||||
}
|
||||
@@ -396,6 +454,259 @@ check("the whole envelope round-trips through json.dumps",
|
||||
for r in READS.values())) is True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2b. TARIFF-08 - the SHIPPED parser, replayed against REAL captured "
|
||||
"bodies")
|
||||
#
|
||||
# ⚠️ WHY THIS SECTION EXISTS, WHICH IS THE MORE IMPORTANT HALF OF THE TICKET.
|
||||
# Until 2026-09-10 every price fixture in this repository - here, in
|
||||
# test_price_history.py and in test_static_tariff.py - was a
|
||||
# `{"data": [{"start": ..., "price_eur_mwh": ...}]}` document invented by
|
||||
# whoever wrote `_points_from_json()`. `api.energy-charts.info` has never
|
||||
# published that shape. So the suite validated the parser against the parser's
|
||||
# own assumption: a closed loop that cannot notice the assumption is wrong, and
|
||||
# it stayed green while `generic_dayahead` raised `SourceUnavailable` on every
|
||||
# live fetch it ever made. §11.4 then made that silent - a parse error becomes
|
||||
# `no_data` becomes the static profile with arbitrage off, which is the CORRECT
|
||||
# degradation and says nothing at all about the cause.
|
||||
#
|
||||
# The only thing that breaks the loop is bytes nobody here wrote. Both fixtures
|
||||
# below are unmodified response bodies, captured by hand with curl on
|
||||
# 2026-09-10; `scenarios/energy-charts-be-price-captures.json` records the URL,
|
||||
# the retrieval time, the licence as returned and a sha256 of each, and the
|
||||
# checksums are RE-DERIVED here so a fixture edited to make a check pass stops
|
||||
# being a capture and starts being a failure.
|
||||
#
|
||||
# ⚠️ THE OTHER THREE NETWORKED PLUGINS HAVE NO CAPTURE AND THIS FILE DOES NOT
|
||||
# PRETEND OTHERWISE. `engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy`
|
||||
# point at endpoints nobody has verified; TARIFF-07 is the open decision on
|
||||
# whether they are withdrawn. Their fixtures are named `synthetic_row_feed` so
|
||||
# no reader can mistake them for evidence that those feeds parse, and the check
|
||||
# below states, derived from the shipped `PROVIDERS` registry, exactly which
|
||||
# plugins are replayed from real bytes and which are not.
|
||||
|
||||
_CAPTURES = json.load(open(
|
||||
os.path.join(HERE, "scenarios", "energy-charts-be-price-captures.json"),
|
||||
encoding="utf-8"))
|
||||
|
||||
|
||||
def _capture(name):
|
||||
with open(os.path.join(HERE, "scenarios", name), "rb") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
_REAL = {n: _capture(n) for n in _CAPTURES["files"]}
|
||||
_bad_sha = sorted(n for n, b in _REAL.items()
|
||||
if hashlib.sha256(b).hexdigest()
|
||||
!= _CAPTURES["files"][n]["sha256"])
|
||||
check(f"both captured bodies are the bytes the provenance file recorded "
|
||||
f"{sorted(_REAL)}",
|
||||
len(_REAL) == 2 and _bad_sha == [])
|
||||
|
||||
# The document really is columnar, derived from the bytes rather than asserted:
|
||||
# if it ever grows a `data` key this check says so before any parser does.
|
||||
_shapes = {n: sorted(json.loads(b.decode("utf-8"))) for n, b in _REAL.items()}
|
||||
check(f"...and both are COLUMNAR - parallel arrays, no 'data' key "
|
||||
f"{sorted({tuple(v) for v in _shapes.values()})}",
|
||||
all(v == ["deprecated", "license_info", "price", "unit", "unix_seconds"]
|
||||
for v in _shapes.values()))
|
||||
check("...which is precisely the shape the shipped parser used to demand a "
|
||||
"'data' list from",
|
||||
all("data" not in v for v in _shapes.values()))
|
||||
|
||||
# ⚠️ CC BY 4.0 IS WHY THESE BYTES MAY BE IN THE REPOSITORY AT ALL. DATA-02
|
||||
# established that the BE zone is redistributable with attribution; the feed
|
||||
# restates the licence per response, so a capture whose own licence line has
|
||||
# changed is a capture that must not stay committed.
|
||||
_lic = {n: json.loads(b.decode("utf-8"))["license_info"] for n, b in _REAL.items()}
|
||||
check(f"...and each carries the CC BY 4.0 line the provenance block cites",
|
||||
all("CC BY 4.0" in v for v in _lic.values())
|
||||
and all(_lic[n] == _CAPTURES["files"][n]["licence_as_returned"]
|
||||
for n in _REAL))
|
||||
|
||||
_QH = "energy-charts-be-price-2026-09-10-quarterhourly.json"
|
||||
_H1 = "energy-charts-be-price-2025-09-10-hourly.json"
|
||||
|
||||
# ⚠️ THE TWO GRANULARITIES ARE NOT DECORATION. Belgium moved to a 15-minute MTU
|
||||
# on 2025-10-01 and the same endpoint answers 96 points at 900 s for a day after
|
||||
# it and 24 at 3600 s for a day before it. A fixture at one spacing cannot grade
|
||||
# whether anything assumes the other, so both are replayed and the spacings are
|
||||
# DERIVED from the bodies rather than typed.
|
||||
_spacing = {}
|
||||
for _n, _b in _REAL.items():
|
||||
_u = json.loads(_b.decode("utf-8"))["unix_seconds"]
|
||||
_spacing[_n] = sorted({_u[i + 1] - _u[i] for i in range(len(_u) - 1)})
|
||||
check(f"the two captures really differ in granularity - {_spacing[_QH]} s over "
|
||||
f"{len(json.loads(_REAL[_QH])['price'])} points and {_spacing[_H1]} s "
|
||||
f"over {len(json.loads(_REAL[_H1])['price'])} points - or replaying both "
|
||||
f"grades nothing more than replaying one",
|
||||
_spacing[_QH] == [900] and _spacing[_H1] == [3600])
|
||||
|
||||
_REPLAY = {n: load("generic_dayahead", transport=canned(b))
|
||||
for n, b in _REAL.items()}
|
||||
_not_series = sorted(n for n, r in _REPLAY.items() if r.kind != "series")
|
||||
check(f"the SHIPPED generic_dayahead parses both real bodies "
|
||||
f"{[r.words for r in _REPLAY.values()]}",
|
||||
_not_series == [])
|
||||
|
||||
# The point count is the source's own interval count, taken from the document.
|
||||
_counts = derived(lambda: {n: len(r.points) for n, r in _REPLAY.items()})
|
||||
_want_counts = {n: len(json.loads(b.decode("utf-8"))["price"])
|
||||
for n, b in _REAL.items()}
|
||||
check(f"...emitting one point per published interval, no decimation "
|
||||
f"{_counts}", _counts == _want_counts)
|
||||
check(f"...which for the quarter-hourly body is 96 points and not 24 - the "
|
||||
f"15-minute MTU is the shape of the real feed, not a hypothetical",
|
||||
_want_counts[_QH] == 96 and _want_counts[_H1] == 24)
|
||||
|
||||
# §9.3: unix_seconds are UTC instants, RENDERED at the Brussels offset. Both
|
||||
# captured days are inside CEST, so the correct first start is local midnight at
|
||||
# +02:00. ⚠️ A NAIVE READING - `datetime.fromtimestamp(t)` with no tzinfo - is
|
||||
# what this grades: it would produce the host's local wall clock, and on a UTC
|
||||
# host that is 22:00 of the previous day. The expectation is computed here from
|
||||
# the raw epoch second by different arithmetic than price.py uses.
|
||||
_first_raw = {n: json.loads(b.decode("utf-8"))["unix_seconds"][0]
|
||||
for n, b in _REAL.items()}
|
||||
_first_utc = {n: datetime(1970, 1, 1, tzinfo=UTC) + timedelta(seconds=t)
|
||||
for n, t in _first_raw.items()}
|
||||
_first_got = derived(lambda: {n: r.points[0].start
|
||||
for n, r in _REPLAY.items()})
|
||||
check(f"every start is the UTC instant the feed published "
|
||||
f"{ {n: v.isoformat() for n, v in _first_utc.items()} }",
|
||||
isinstance(_first_got, dict)
|
||||
and all(_first_got[n] == _first_utc[n] for n in _REAL))
|
||||
check(f"...rendered at the Brussels offset, so a September day starts at "
|
||||
f"00:00+02:00 and NOT at the 22:00 a naive local reading gives "
|
||||
f"{ {n: v.isoformat() for n, v in (_first_got if isinstance(_first_got, dict) else {}).items()} }",
|
||||
isinstance(_first_got, dict)
|
||||
and all(v.utcoffset() == timedelta(hours=2) for v in _first_got.values())
|
||||
and all((v.hour, v.minute) == (0, 0) for v in _first_got.values()))
|
||||
|
||||
# EUR/MWh -> all-in EUR/kWh, recomputed here from the raw column.
|
||||
_allin_want = {n: [round(v / 1000 + MARKUP + TAXES, 6)
|
||||
for v in json.loads(b.decode("utf-8"))["price"]]
|
||||
for n, b in _REAL.items()}
|
||||
_allin_got = derived(lambda: {n: [p.eur_per_kwh for p in r.points]
|
||||
for n, r in _REPLAY.items()})
|
||||
check(f"...and every price is the published EUR/MWh figure over 1000 plus "
|
||||
f"markup and taxes (first: {[v[0] for v in _allin_want.values()]})",
|
||||
_allin_got == _allin_want)
|
||||
# ⚠️ NON-DEGENERACY. If the captured prices happened to be all equal, the
|
||||
# arithmetic check above would pass under a parser that emitted the same number
|
||||
# for every interval.
|
||||
_raw_spread = {n: round(max(v) - min(v), 6)
|
||||
for n, v in ((k, json.loads(b.decode("utf-8"))["price"])
|
||||
for k, b in _REAL.items())}
|
||||
check(f"...on bodies whose prices actually vary, or that check grades a "
|
||||
f"constant {_raw_spread}",
|
||||
all(v > 1.0 for v in _raw_spread.values()))
|
||||
|
||||
|
||||
# --- the refusals, driven from the real body by breaking one thing ---------- #
|
||||
|
||||
def _edited(name, **fields):
|
||||
"""The captured body with one field replaced. Everything else is real."""
|
||||
doc = json.loads(_REAL[name].decode("utf-8"))
|
||||
doc.update(fields)
|
||||
return json.dumps(doc).encode()
|
||||
|
||||
|
||||
_full = json.loads(_REAL[_QH].decode("utf-8"))
|
||||
_short = load("generic_dayahead",
|
||||
transport=canned(_edited(_QH, price=_full["price"][:-4])))
|
||||
check(f"a `price` array 4 entries short of `unix_seconds` is no_data, NOT a "
|
||||
f"series truncated to {len(_full['price']) - 4} points - the missing "
|
||||
f"tail of a Belgian day is the evening peak, and a short series is "
|
||||
f"traded on where no data at all switches arbitrage off (§11.4)",
|
||||
_short.kind == "no_data")
|
||||
check("...and the reason names the two lengths, so an operator can see it was "
|
||||
"a length mismatch and not an outage",
|
||||
_short.kind == "no_data"
|
||||
and str(len(_full["unix_seconds"])) in _short.reason
|
||||
and str(len(_full["price"]) - 4) in _short.reason)
|
||||
# The other direction, so the check is not a one-sided `len(price) < len(time)`.
|
||||
_long = load("generic_dayahead",
|
||||
transport=canned(_edited(_QH, price=_full["price"] + [1.0])))
|
||||
check("...and a `price` array one entry LONGER is refused too",
|
||||
_long.kind == "no_data")
|
||||
# ⚠️ THE CONTROL. Without it, "refuses everything" passes both checks above.
|
||||
check("...while the untouched body is still a series, so the refusal is the "
|
||||
"length and not the editing",
|
||||
load("generic_dayahead",
|
||||
transport=canned(_edited(_QH))).kind == "series")
|
||||
|
||||
_unit = load("generic_dayahead", transport=canned(_edited(_QH,
|
||||
unit="EUR / kWh")))
|
||||
check("a feed that changes its unit to EUR/kWh is no_data, not every price "
|
||||
"silently divided by 1000 - MAX_PLAUSIBLE_EUR_KWH only bounds prices "
|
||||
"that are too LARGE, so nothing downstream could catch it",
|
||||
_unit.kind == "no_data")
|
||||
|
||||
_nulls = _full["price"][:]
|
||||
for _i in (3, 17):
|
||||
_nulls[_i] = None
|
||||
_nulled = load("generic_dayahead", transport=canned(_edited(_QH,
|
||||
price=_nulls)))
|
||||
check(f"a published null is DROPPED, leaving a hole rather than a zero - a "
|
||||
f"missing quarter is not a free quarter",
|
||||
_nulled.kind == "series"
|
||||
and derived(lambda: len(_nulled.points)) == len(_full["price"]) - 2)
|
||||
check("...and the dropped instants are the ones the feed left null, not the "
|
||||
"first two",
|
||||
derived(lambda: sorted(
|
||||
int(p.start.timestamp()) for p in _nulled.points))
|
||||
== sorted(t for i, t in enumerate(_full["unix_seconds"])
|
||||
if i not in (3, 17)))
|
||||
_allnull = load("generic_dayahead",
|
||||
transport=canned(_edited(_QH,
|
||||
price=[None] * len(_full["price"]))))
|
||||
check("...but a body with a price for nothing at all is no_data, not an empty "
|
||||
"series", _allnull.kind == "no_data")
|
||||
# ⚠️ WHICH RULE REFUSED IT, not merely that something did. `_validate_raw()`
|
||||
# would also refuse an empty list, one layer further out and with a sentence
|
||||
# that says nothing about the feed - so asserting only `no_data` here would
|
||||
# grade the same under a parser that had lost this refusal entirely.
|
||||
check(f"...refused by the COLUMNAR parser, naming what arrived, rather than "
|
||||
f"reaching the boundary validator as a generic empty list - "
|
||||
f"{_allnull.reason!r}",
|
||||
_allnull.kind == "no_data"
|
||||
and f"{len(_full['price'])} instants" in _allnull.reason
|
||||
and "a price for none of them" in _allnull.reason)
|
||||
|
||||
# ⚠️ THE TWO WARNINGS ARE GRADED, NOT ASSUMED. §11.4 is exactly what makes a
|
||||
# price defect quiet - a parse failure becomes no_data becomes the static
|
||||
# profile, correctly and silently - so an unverified diagnostic is the first
|
||||
# thing here to rot. Both are captured off the shipped logger.
|
||||
_wbuf = io.StringIO()
|
||||
_wh = logging.StreamHandler(_wbuf)
|
||||
logging.getLogger("goodwe.price").addHandler(_wh)
|
||||
load("generic_dayahead", transport=canned(_edited(_QH, price=_nulls)))
|
||||
_dep = load("generic_dayahead", transport=canned(_edited(_QH,
|
||||
deprecated=True)))
|
||||
logging.getLogger("goodwe.price").removeHandler(_wh)
|
||||
_warn = _wbuf.getvalue()
|
||||
_n_null = len([v for v in _nulls if v is None])
|
||||
check(f"the dropped nulls are reported with a count ({_n_null} of "
|
||||
f"{len(_nulls)}), so a series with holes in it is visible to an operator "
|
||||
f"rather than merely short",
|
||||
f"{_n_null} of {len(_nulls)}" in _warn)
|
||||
check("...and a feed announcing deprecated=true says so WHILE IT STILL WORKS - "
|
||||
"the day it stops, §11.4 makes its death look exactly like a quiet one",
|
||||
"deprecated" in _warn and _dep.kind == "series")
|
||||
|
||||
# Which plugins are replayed from real bytes, derived from the registry so a
|
||||
# sixth supplier cannot be added without this line noticing.
|
||||
_networked = sorted(pid for pid, cls in PROVIDERS.items()
|
||||
if cls.capabilities().egress_hosts)
|
||||
_replayed = ["generic_dayahead"]
|
||||
check(f"the networked plugins are {_networked}; {_replayed} is replayed from a "
|
||||
f"captured real body and {sorted(set(_networked) - set(_replayed))} have "
|
||||
f"no capture because their endpoints are unverified (TARIFF-07)",
|
||||
_networked == sorted(["generic_dayahead", "engie_dynamic",
|
||||
"totalenergies_dynamic", "eneco_eddy"]))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. all-in: energy + markup + taxes, added exactly once (AC1, trap 1)")
|
||||
@@ -444,12 +755,29 @@ check(f"a negative day-ahead price passes through, all-in {_neg}",
|
||||
_neg == [round(EPEX_EUR_MWH[1] / 1000 + MARKUP + TAXES, 6)])
|
||||
|
||||
# provisional is per point and comes from the feed, not from the series.
|
||||
for pid, read in sorted(READS.items()):
|
||||
#
|
||||
# ⚠️ `generic_dayahead` IS EXCLUDED FROM THE "MIXED" REQUIREMENT, AND THE
|
||||
# EXCLUSION IS THE POINT RATHER THAN A GAP. The columnar document the real feed
|
||||
# publishes has no per-point provisional field - it is a settled auction result
|
||||
# - so the plugin declares `False` for all of them. Demanding a mixed series
|
||||
# from it would mean feeding it an invented body carrying an invented flag,
|
||||
# which is exactly the closed loop TARIFF-08 exists to remove. It is graded on
|
||||
# what its real body can grade instead, immediately below.
|
||||
for pid in sorted(set(READS) - {"generic_dayahead"}):
|
||||
read = READS[pid]
|
||||
flags = derived(lambda r=read: [p.provisional for p in r.points])
|
||||
check(f"{pid}: provisional is per point and mixed {flags}",
|
||||
isinstance(flags, list) and any(flags) and not all(flags))
|
||||
check(f"...so plan_is_provisional is derived from those points",
|
||||
derived(lambda r=read: plan_is_provisional(r)) is True)
|
||||
_gen_flags = derived(lambda: [p.provisional
|
||||
for p in READS["generic_dayahead"].points])
|
||||
check(f"generic_dayahead's settled auction comes out settled at every point "
|
||||
f"{_gen_flags}",
|
||||
isinstance(_gen_flags, list) and len(_gen_flags) == len(EPEX_EUR_MWH)
|
||||
and not any(_gen_flags))
|
||||
check("...so plan_is_provisional is False for it, derived from the same points",
|
||||
derived(lambda: plan_is_provisional(READS["generic_dayahead"])) is False)
|
||||
# ...and the same function says False when nothing is provisional. Without this
|
||||
# arm, "always True" would pass every check above.
|
||||
_settled = PriceRead.series(
|
||||
@@ -759,7 +1087,7 @@ class _NewSupplier(PriceProvider):
|
||||
|
||||
|
||||
_sixth = load("_test_sixth_supplier",
|
||||
transport=canned(json_feed("price_eur_mwh", EPEX_EUR_MWH)))
|
||||
transport=canned(synthetic_row_feed("price_eur_mwh", EPEX_EUR_MWH)))
|
||||
check("a plugin invented outside price.py resolves through the registry",
|
||||
_sixth.kind == "series" and _sixth.provider == "_test_sixth_supplier")
|
||||
_sixth_regime = derived(lambda: _sixth.regime)
|
||||
@@ -894,7 +1222,14 @@ _failures = {
|
||||
"the source is unreachable": load("generic_dayahead", transport=_boom),
|
||||
"the body is not JSON": load("generic_dayahead", transport=canned(b"<html>")),
|
||||
"the feed is empty": load("generic_dayahead",
|
||||
transport=canned(b'{"data": []}')),
|
||||
transport=canned(columnar_feed([]))),
|
||||
# ⚠️ THE ROW SHAPE IS A FAILURE FOR THIS PLUGIN, NOT A SECOND DIALECT. If
|
||||
# `_points_from_columnar_json()` ever grows a sniff for `data`, this case
|
||||
# goes from no_data to a series and TARIFF-08's scope rule is broken by a
|
||||
# check rather than by a review.
|
||||
"the body is the OTHER shape": load(
|
||||
"generic_dayahead",
|
||||
transport=canned(synthetic_row_feed("price_eur_mwh", EPEX_EUR_MWH))),
|
||||
"the provider id is unknown": load("no_such_supplier"),
|
||||
"no provider is configured": load("off"),
|
||||
"the csv is missing": load("manual_csv",
|
||||
@@ -1035,11 +1370,8 @@ check("the offset boundary is 01:00 UTC, not local midnight",
|
||||
# A feed that publishes the duplicated hour must survive it. The 25-hour day is
|
||||
# 25 strictly increasing instants; a plugin keyed on local hours would emit 24
|
||||
# and be rejected by the boundary validator instead of silently mispricing.
|
||||
_dst_feed = json.dumps({"data": [
|
||||
{"start": (datetime(2026, 10, 24, 22, tzinfo=UTC)
|
||||
+ timedelta(hours=i)).isoformat(),
|
||||
"price_eur_mwh": 50.0 + i, "provisional": i == 24}
|
||||
for i in range(25)]}).encode()
|
||||
_dst_feed = columnar_feed([50.0 + i for i in range(25)],
|
||||
start=datetime(2026, 10, 24, 22, tzinfo=UTC))
|
||||
_dst_read = load("generic_dayahead", transport=canned(_dst_feed))
|
||||
_dst_n = derived(lambda: len(_dst_read.points))
|
||||
check(f"a 25-point autumn feed is accepted whole ({_dst_n} points)",
|
||||
@@ -1164,7 +1496,7 @@ check("a userinfo prefix cannot smuggle a declared host past the check",
|
||||
check("an operator-supplied URL to another supplier's host is refused",
|
||||
load("generic_dayahead",
|
||||
{"price_feed_url": "https://api.eneco.be/eddy/prices"},
|
||||
transport=canned(json_feed("price_eur_mwh", EPEX_EUR_MWH))
|
||||
transport=canned(synthetic_row_feed("price_eur_mwh", EPEX_EUR_MWH))
|
||||
).kind == "no_data")
|
||||
_declared = {pid: cls.capabilities().egress_hosts
|
||||
for pid, cls in PROVIDERS.items()}
|
||||
@@ -1176,7 +1508,7 @@ check("the timeout the plugin passes to the transport is the configured one, "
|
||||
"not a hardcoded default",
|
||||
(lambda t: (load("generic_dayahead", {"price_timeout_s": 7},
|
||||
transport=t), t.calls[0][1] == 7.0)[1])(
|
||||
canned(json_feed("price_eur_mwh", EPEX_EUR_MWH))))
|
||||
canned(synthetic_row_feed("price_eur_mwh", EPEX_EUR_MWH))))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -59,6 +59,7 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import itertools
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -354,14 +355,164 @@ check("...and still REFUSES a repeated CARRIED hour, which is the DST "
|
||||
"mispricing that count existed to catch",
|
||||
raises(lambda: _fc._audit([_fc._carried(D0, 0.11),
|
||||
_fc._carried(D0, 0.12)], {}), ValueError))
|
||||
check("...and refuses the SAME published point emitted twice - the provider's "
|
||||
"points are strictly increasing, so a repeat is never a second print",
|
||||
check("...and STILL refuses the SAME published point emitted twice - the "
|
||||
"provider's points are strictly increasing, so a repeat is never a "
|
||||
"second print. The old hour COUNT already refused this (one point twice "
|
||||
"is one hour twice); what is graded here is that the relaxation did not "
|
||||
"lose it, not that it added it",
|
||||
raises(lambda: _fc._audit([_QUARTERS.points[0], _QUARTERS.points[0]],
|
||||
_PUB_Q), ValueError))
|
||||
check("...and refuses a CARRIED point standing in an hour the feed DID "
|
||||
"publish, which the old count never looked at",
|
||||
check("...and STILL refuses a CARRIED point standing in an hour the feed DID "
|
||||
"publish. The old COUNT never looked at it; the old IDENTITY rule did, "
|
||||
"and raised - so this is preserved behaviour, not new strictness. "
|
||||
"FCST-04's text called it new here and in `_audit`'s docstring, and a "
|
||||
"measured differential says otherwise",
|
||||
raises(lambda: _fc._audit([_fc._carried(D0, 0.11)], _PUB_Q), ValueError))
|
||||
|
||||
# ⚠️ AND WHAT THE AUDIT DOES NOT DO, STATED WHERE A READER OF THESE THREE
|
||||
# CHECKS WOULD OTHERWISE ASSUME IT (FCST-05, MUTATION.md habit 16). `_audit` is
|
||||
# a PROVENANCE post-condition, not a COMPLETENESS one: it grades every point
|
||||
# that IS emitted and says nothing about points that are not. So it accepts the
|
||||
# very decimation FCST-04 was raised for. That is not a regression - FCST-03's
|
||||
# audit was equally blind - but it means the audit is NOT the thing that stops
|
||||
# H1 coming back. The count and identity checks above this line are.
|
||||
check("the audit ACCEPTS the H1 decimation itself - four published points in "
|
||||
"one hour, only the last emitted - and accepts an EMPTY output. It is a "
|
||||
"provenance check, not a completeness one, and the checks that guard "
|
||||
"provenance completeness are the COUNT and IDENTITY ones above",
|
||||
derived(lambda: _fc._audit([_QUARTERS.points[3]], _PUB_Q)) is None
|
||||
and derived(lambda: _fc._audit([], _PUB_Q)) is None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ AND THE CLAIM "STRICTER" IS ITSELF A CHECK NOW (FCST-05).
|
||||
#
|
||||
# FCST-04 rewrote `_audit` and wrote, in the module and in the three checks
|
||||
# above, that the result was STRICTER than FCST-03's. It is not. Two of the
|
||||
# stated reasons were false - the old `seen` count already refused the same
|
||||
# point emitted twice, and the old IDENTITY rule already refused a carried
|
||||
# point in a published hour - and a right decision with a fabricated reason is
|
||||
# a defect in this repository (MUTATION.md, and the reason DECISIONS files
|
||||
# exist). So the claim is measured here rather than asserted in prose:
|
||||
# FCST-03's audit is reproduced below from dd40c56 - its RULES verbatim, its
|
||||
# message TEXT shortened, and nothing here reads a message - and the two are
|
||||
# run over the whole domain `_audit` can express, in BOTH directions.
|
||||
#
|
||||
# THE DOMAIN, AND ITS SIZE. Every published mask over 3 hours (2**3) crossed
|
||||
# with every output series of length 0-3 drawn from 3 hours x 5 point kinds.
|
||||
# The nominal cross-product is 28 928; three of those kinds - published,
|
||||
# published-provisional, copy-of-published - DO NOT EXIST for an hour the feed
|
||||
# did not publish, and those combinations are SKIPPED rather than faked, which
|
||||
# leaves 11 990 realisable comparisons. A fabricated "published point" for an
|
||||
# unpublished hour is not in either audit's domain, and inventing one would
|
||||
# grade the fixture rather than the code.
|
||||
#
|
||||
# ⚠️ THE OLD COPY IS A LITERAL, NOT A CALL. MUTATION.md habit 2: a differential
|
||||
# whose two sides both come from the shipped module compares a thing against
|
||||
# itself and moves with every edit. This side is frozen text.
|
||||
def _audit_fcst03(points, published):
|
||||
"""FCST-03's `_audit`, verbatim from dd40c56. Its `published` maps a UTC
|
||||
hour to ONE point, which is exactly the expressiveness this ticket relaxed.
|
||||
"""
|
||||
seen = set()
|
||||
for p in points:
|
||||
u = _fc._utc_hour(p.start)
|
||||
if u in seen:
|
||||
raise ValueError("the filled series repeats " + u.isoformat())
|
||||
seen.add(u)
|
||||
src = published.get(u)
|
||||
if src is None:
|
||||
if p.provisional is not True:
|
||||
raise ValueError(u.isoformat() + " is carried and is flagged "
|
||||
"provisional=" + repr(p.provisional))
|
||||
else:
|
||||
if p is not src:
|
||||
raise ValueError(u.isoformat() + " is published but the series "
|
||||
"carries a COPY of that point")
|
||||
|
||||
|
||||
def _verdict(fn, pts, pub):
|
||||
try:
|
||||
fn(list(pts), pub)
|
||||
return "accept"
|
||||
except ValueError:
|
||||
return "raise"
|
||||
|
||||
|
||||
_DH = [D0 + timedelta(hours=6 + i) for i in range(3)]
|
||||
_weaker, _stronger, _compared = [], [], 0
|
||||
for _mask in itertools.product((0, 1), repeat=3):
|
||||
_pub = {u: PricePoint(start=u, eur_per_kwh=0.10 + i, provisional=False)
|
||||
for i, (u, _on) in enumerate(zip(_DH, _mask)) if _on}
|
||||
_old_pub = dict(_pub)
|
||||
_new_pub = {u: (q,) for u, q in _pub.items()}
|
||||
# The five shapes an output point can take at a given hour. Three of them
|
||||
# do not exist for an hour the feed did not publish, and those combinations
|
||||
# are skipped rather than faked - a fabricated "published point" for an
|
||||
# unpublished hour is not in either audit's domain.
|
||||
_kinds = []
|
||||
for _u in _DH:
|
||||
_src = _pub.get(_u)
|
||||
_kinds.append(("published", _src))
|
||||
_kinds.append(("published-provisional",
|
||||
None if _src is None else
|
||||
PricePoint(start=_src.start,
|
||||
eur_per_kwh=_src.eur_per_kwh,
|
||||
provisional=True)))
|
||||
_kinds.append(("carried", _fc._carried(_u, 0.5)))
|
||||
_kinds.append(("carried-unflagged",
|
||||
PricePoint(start=_u, eur_per_kwh=0.5,
|
||||
provisional=False)))
|
||||
_kinds.append(("copy-of-published",
|
||||
None if _src is None else
|
||||
PricePoint(start=_src.start,
|
||||
eur_per_kwh=_src.eur_per_kwh,
|
||||
provisional=_src.provisional)))
|
||||
for _n in range(4):
|
||||
for _combo in itertools.product(_kinds, repeat=_n):
|
||||
if any(k[1] is None for k in _combo):
|
||||
continue
|
||||
_pts = [k[1] for k in _combo]
|
||||
_compared += 1
|
||||
_o = _verdict(_audit_fcst03, _pts, _old_pub)
|
||||
_nw = _verdict(_fc._audit, _pts, _new_pub)
|
||||
if _o == "raise" and _nw == "accept":
|
||||
_weaker.append((_mask, [k[0] for k in _combo]))
|
||||
if _o == "accept" and _nw == "raise":
|
||||
_stronger.append((_mask, [k[0] for k in _combo]))
|
||||
|
||||
# ⚠️ THE PRECONDITION FIRST (MUTATION.md habit 1). A differential over a
|
||||
# domain that cannot reach the two disputed cases would score 0-0 for the wrong
|
||||
# reason and read exactly like this one, so the two cases are driven against
|
||||
# the OLD audit by name before the totals are graded.
|
||||
_probe = PricePoint(start=_DH[0], eur_per_kwh=0.1, provisional=False)
|
||||
check(f"the differential really covers the two claims it is here to settle, "
|
||||
f"or it is {_compared} runs of nothing: FCST-03's own audit raises on "
|
||||
f"BOTH 'the same published point twice' and 'a carried point in a "
|
||||
f"published hour', which is what FCST-04 said it did not",
|
||||
_compared > 3000
|
||||
and _verdict(_audit_fcst03, [_probe, _probe], {_DH[0]: _probe}) == "raise"
|
||||
and _verdict(_audit_fcst03, [_fc._carried(_DH[0], 0.5)],
|
||||
{_DH[0]: _probe}) == "raise")
|
||||
check(f"...and over all {_compared} of them the reworked audit is NOT WEAKER "
|
||||
f"than FCST-03's: {len(_weaker)} case(s) the old one raised and this one "
|
||||
f"accepts {_weaker[:2]}",
|
||||
_weaker == [])
|
||||
check(f"...and NOT STRONGER either: {len(_stronger)} case(s) the old one "
|
||||
f"accepted and this one raises {_stronger[:2]}. FCST-04's docstring and "
|
||||
f"two of these checks said it was stricter and named two reasons, and "
|
||||
f"BOTH were already refused by the old count and the old identity rule. "
|
||||
f"The only behavioural change is the RELAXATION below, which is what the "
|
||||
f"rework was for",
|
||||
_stronger == [])
|
||||
_QP = [PricePoint(start=_DH[0] + timedelta(minutes=15 * i),
|
||||
eur_per_kwh=0.1 + i, provisional=False) for i in range(4)]
|
||||
check("...and the relaxation is real and is NOT expressible to the old audit: "
|
||||
"four distinct published points inside one hour, which FCST-03's count "
|
||||
"refused as a repeated hour and this one accepts",
|
||||
_verdict(_fc._audit, _QP, {_DH[0]: tuple(_QP)}) == "accept"
|
||||
and _verdict(_audit_fcst03, _QP, {_DH[0]: _QP[0]}) == "raise")
|
||||
|
||||
# ⚠️ THE HORIZON IS WHOLE HOURS AT BOTH ENDS (FCST-04 L8). `since` is floored
|
||||
# and `until` was not, so a 48 h request made at any instant off the hour came
|
||||
# back 49 hours long. Graded at an OFF-THE-HOUR instant, because on the hour
|
||||
|
||||
@@ -34,6 +34,18 @@ cache cannot decay by being fed its own output.
|
||||
`PriceHistory(...)` and `.words` are unguarded in `Controller.__init__`
|
||||
- so the add-on did not start, and never self-healed.
|
||||
|
||||
AC5 (FCST-05) THE CACHE'S OWN CONTRACT HOLDS FOR EVERY BYTE AND EVERY ROW.
|
||||
A file holding one non-UTF-8 byte no longer stops the add-on from
|
||||
starting - `_load()` caught `(json.JSONDecodeError, OSError)` and
|
||||
`UnicodeDecodeError` is neither, so it escaped `PriceHistory.__init__`
|
||||
and `Controller.__init__` does not guard it. And a malformed `start`
|
||||
under the day the feed is WRITING to no longer kills the tariff plan
|
||||
for good: the write path shares `_point_of()` with the read path,
|
||||
drops the row, says so, and `_save()` rewrites the record clean.
|
||||
⚠️ THE STATUS ROW IS PART OF THIS. It said `'2026-01-15 (1 h)'` about a
|
||||
store whose every refresh was raising - health it did not have, in a
|
||||
unit that was not its own (it counts POINTS).
|
||||
|
||||
THE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||||
|
||||
* EVERY VERDICT IS DERIVED. The carried/published split is re-derived here
|
||||
@@ -262,6 +274,13 @@ YESTERDAY_MWH = {h: 400.0 + 3 * h for h in range(24)}
|
||||
|
||||
MARKUP, TAXES = 0.03, 0.06
|
||||
|
||||
# ⚠️ FCST-05's HIGH, AS BYTES. A cache that is structurally valid JSON up to
|
||||
# the point where one byte is not UTF-8 - which is what a truncated write, a
|
||||
# half-flushed page or a foreign writer produces. Written with `open(..., "wb")`
|
||||
# and never through a text handle, or the fixture cannot hold the byte it is
|
||||
# named for. `0xff` is not a valid UTF-8 start byte in any position.
|
||||
_UTF8_BOMB = b'{"version": 1, "days": {"2026-01-14": "\xff\xfe"}}'
|
||||
|
||||
|
||||
def allin(mwh):
|
||||
"""EUR/MWh as published -> the all-in EUR/kWh `assemble()` produces."""
|
||||
@@ -269,9 +288,25 @@ def allin(mwh):
|
||||
|
||||
|
||||
def feed_body(instants, table):
|
||||
return json.dumps({"data": [
|
||||
{"start": u.isoformat(), "price_eur_mwh": table[brussels(u).hour]}
|
||||
for u in instants]}).encode()
|
||||
"""A `generic_dayahead` body in the COLUMNAR shape the real feed publishes.
|
||||
|
||||
⚠️ TARIFF-08. This used to build `{"data": [{"start": ...,
|
||||
"price_eur_mwh": ...}]}` - a shape `api.energy-charts.info` has never
|
||||
published and that the shipped parser was nevertheless written to expect.
|
||||
Every price fixture in the repository fed that invented shape, so the
|
||||
suite graded the parser against the parser's own assumption while the live
|
||||
plugin raised `SourceUnavailable` on every fetch. Real captured bodies are
|
||||
replayed in test_price.py section 2b; the fixtures here are SYNTHETIC
|
||||
prices in the REAL shape, because this file is grading the fill and the
|
||||
history, not the parse.
|
||||
"""
|
||||
return json.dumps({
|
||||
"unix_seconds": [int(u.timestamp()) for u in instants],
|
||||
"price": [table[brussels(u).hour] for u in instants],
|
||||
"unit": "EUR / MWh",
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||||
"from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated": False}).encode()
|
||||
|
||||
|
||||
def transport_for(body):
|
||||
@@ -540,14 +575,24 @@ for _n in range(len(TODAY_H) * (60 // _QSTEP_MIN)):
|
||||
_qu = TODAY_H[0] + timedelta(minutes=_QSTEP_MIN * _n)
|
||||
# A spiky within-the-hour profile, so decimation is visible in the SPREAD
|
||||
# and not only in a count.
|
||||
_qrows.append({"start": _qu.isoformat(),
|
||||
"price_eur_mwh": 100.0 + 40.0 * (_n % 4) + brussels(_qu).hour})
|
||||
_qbody = json.dumps({"data": _qrows}).encode()
|
||||
_qrows.append((_qu,
|
||||
100.0 + 40.0 * (_n % 4) + brussels(_qu).hour))
|
||||
# ⚠️ COLUMNAR, like the real feed - see feed_body(). And 900 s spacing is not a
|
||||
# hypothetical here: Belgium moved to a 15-minute MTU on 2025-10-01 and
|
||||
# `api.energy-charts.info` has answered 96 points a day ever since, which is
|
||||
# exactly what test_price.py section 2b replays from a captured body.
|
||||
_qbody = json.dumps({
|
||||
"unix_seconds": [int(_u.timestamp()) for _u, _v in _qrows],
|
||||
"price": [_v for _u, _v in _qrows],
|
||||
"unit": "EUR / MWh",
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||||
"from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated": False}).encode()
|
||||
check(f"the fixture really is sub-hourly - {len(_qrows)} points across "
|
||||
f"{len(TODAY_H)} Brussels hours - or every check below passes for an "
|
||||
f"hourly feed too and grades nothing",
|
||||
len(_qrows) == 4 * len(TODAY_H)
|
||||
and len({_r["start"] for _r in _qrows}) == len(_qrows))
|
||||
and len({_r[0] for _r in _qrows}) == len(_qrows))
|
||||
|
||||
_q_before = run(resolve_tariff(CFG, NOW, _until, transport=transport_for(_qbody),
|
||||
now=NOW, history=NoHistory()))
|
||||
@@ -896,6 +941,55 @@ check("an unreadable file: the reason is kept and reported, and the record is "
|
||||
and _h6.yesterday(NOW).kind == "no_data"
|
||||
and "unreadable" in why(_h6.yesterday(NOW)))
|
||||
|
||||
# ⚠️ AND THE BYTE CLASS THAT FIXTURE CANNOT REACH - FCST-05's HIGH. The row
|
||||
# above is INVALID JSON but it is PERFECTLY VALID UTF-8, so it is decoded and
|
||||
# then refused by `json`, which raises `json.JSONDecodeError`. A file holding
|
||||
# one byte that is not UTF-8 at all fails EARLIER, in the decode `open(...,
|
||||
# encoding="utf-8")` performs before `json` sees a character, and
|
||||
# `UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError`:
|
||||
# NOT a `JSONDecodeError`, and NOT an `OSError` either. `_load()` caught
|
||||
# `(json.JSONDecodeError, OSError)`, so it escaped `_load()` -> `_open()` ->
|
||||
# `PriceHistory.__init__` - unguarded in `Controller.__init__` - AND THE ADD-ON
|
||||
# DID NOT START, on a cache whose own contract says "a corrupt cache must not
|
||||
# stop the controller".
|
||||
#
|
||||
# The precondition is asserted first, because it is the whole reason the check
|
||||
# above could not see this: if `"{ this is not json"` were undecodable, the two
|
||||
# fixtures would be one fixture and this would grade nothing (MUTATION.md
|
||||
# habit 1).
|
||||
check("the two unreadable-file fixtures are DIFFERENT FAILURES: the one above "
|
||||
"is valid UTF-8 that is not JSON, and the one below is not UTF-8 at all. "
|
||||
"A file that is merely bad JSON cannot reach the decode step, which is "
|
||||
"why the suite had this hole",
|
||||
open(_p6, "rb").read().decode("utf-8") == "{ this is not json"
|
||||
and raises(lambda: _UTF8_BOMB.decode("utf-8"), UnicodeDecodeError))
|
||||
|
||||
_d6b = tmpdir()
|
||||
_p6b = os.path.join(_d6b, "price_history.json")
|
||||
open(_p6b, "wb").write(_UTF8_BOMB)
|
||||
_h6b = derived(lambda: PriceHistory(_p6b))
|
||||
check(f"a single non-UTF-8 byte in the cache does NOT stop the store from "
|
||||
f"being constructed {_h6b!r}. `PriceHistory(...)` is unguarded in "
|
||||
f"`Controller.__init__`, so an exception here is an add-on that does not "
|
||||
f"start - which is what shipped",
|
||||
isinstance(_h6b, PriceHistory))
|
||||
check(f"...and it degrades the SAME WAY the bad-JSON file does: the reason is "
|
||||
f"kept, named and reported "
|
||||
f"{(_h6b.last_error if isinstance(_h6b, PriceHistory) else _h6b)!r}",
|
||||
isinstance(_h6b, PriceHistory)
|
||||
and "unreadable" in (_h6b.last_error or "")
|
||||
and "codec can't decode byte" in (_h6b.last_error or "")
|
||||
and _h6b.yesterday(NOW).kind == "no_data"
|
||||
and "unreadable" in why(_h6b.yesterday(NOW)))
|
||||
_w6b = derived(lambda: _h6b.words)
|
||||
_plan6b = derived(lambda: run(resolve_tariff(
|
||||
CFG, NOW, _until, transport=transport_for(_body_today),
|
||||
now=NOW, history=_h6b)).origin)
|
||||
check(f"...and NOTHING downstream raises on it either: not `words` ({_w6b!r}), "
|
||||
f"not the shipped `resolve_tariff()` path ({_plan6b!r}). §6: absent "
|
||||
f"machinery must not disable the loop, and a JSON file is machinery",
|
||||
isinstance(_w6b, str) and _plan6b == "provider")
|
||||
|
||||
_p7 = os.path.join(tmpdir(), "price_history.json")
|
||||
json.dump({"version": SCHEMA + 99, "days": {}}, open(_p7, "w", encoding="utf-8"))
|
||||
_h7 = PriceHistory(_p7)
|
||||
@@ -1008,11 +1102,49 @@ check("a healthy but EMPTY store says something different, because it is a "
|
||||
and words_of(_h_words).startswith("empty")
|
||||
and "nothing to carry forward" in words_of(_h_words))
|
||||
_ph.PriceHistory._remember(_h_words, series_of(YDAY_H, YESTERDAY_MWH), NOW)
|
||||
check(f"...and a store holding a day names the DATE and the HOUR COUNT "
|
||||
f"{words_of(_h_words)!r} - 'a cache exists' and 'the cache holds "
|
||||
f"yesterday, all 24 hours of it' are different facts, and only the "
|
||||
f"second says the horizon will be full tomorrow morning",
|
||||
words_of(_h_words) == f"{YDAY_D} (24 h)")
|
||||
check(f"...and a store holding a day names the DATE, the POINT COUNT and the "
|
||||
f"HOUR COUNT {words_of(_h_words)!r} - 'a cache exists' and 'the cache "
|
||||
f"holds yesterday, all 24 hours of it' are different facts, and only "
|
||||
f"the second says the horizon will be full tomorrow morning",
|
||||
words_of(_h_words) == f"{YDAY_D} (24 points over 24 h)")
|
||||
|
||||
# ⚠️ AND THE TWO NUMBERS ARE NOT THE SAME NUMBER - FCST-05's LOW. The row
|
||||
# shipped as `f"{d} ({_n_points(rec)} h)"`, which counts POINTS and prints them
|
||||
# under the HOUR's name. On an hourly feed the two are equal and nothing shows;
|
||||
# on the quarter-hourly feed this add-on already supports (`_published_by_hour`
|
||||
# keeps a TUPLE per hour precisely for it, and §7 below drives one) the row
|
||||
# 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.
|
||||
#
|
||||
# MUTATION.md's rule: this check asserts a DIFFERENCE, so the fixture is run
|
||||
# with the two numbers actually made different. 96 != 24, asserted first.
|
||||
_h_q = store()
|
||||
_QUARTERS = [u + timedelta(minutes=15 * q) for u in YDAY_H for q in range(4)]
|
||||
_ph.PriceHistory._remember(
|
||||
_h_q, PriceRead.series(
|
||||
[PricePoint(start=brussels(u),
|
||||
eur_per_kwh=allin(YESTERDAY_MWH[brussels(u).hour]
|
||||
+ brussels(u).minute),
|
||||
provisional=False) for u in _QUARTERS],
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", NOW),
|
||||
NOW)
|
||||
_qw = words_of(_h_q)
|
||||
check(f"the quarter-hourly fixture really is sub-hourly and its two counts "
|
||||
f"really differ ({len(_QUARTERS)} points, "
|
||||
f"{len({brussels(u).hour for u in _QUARTERS})} local hours), or 'points' "
|
||||
f"and 'hours' are the same observation and the row grades nothing",
|
||||
len(_QUARTERS) == 96
|
||||
and len({brussels(u).hour for u in _QUARTERS}) == 24
|
||||
and len({u.minute for u in _QUARTERS}) == 4)
|
||||
check(f"...and the row reports BOTH, each under its own unit: {_qw!r}. "
|
||||
f"A day of 96 quarter-hourly prints is 96 points over 24 h, and the "
|
||||
f"figure that says whether tomorrow's fill has a whole day to carry is "
|
||||
f"the HOUR one",
|
||||
_qw == f"{YDAY_D} (96 points over 24 h)")
|
||||
check(f"...and specifically the row does NOT say '96 h' {_qw!r} - a point "
|
||||
f"count wearing the hour's name is H1's defect, and this row is where "
|
||||
f"an operator would have read it",
|
||||
isinstance(_qw, str) and "(96 h)" not in _qw and " (96 points " in _qw)
|
||||
check(f"a file that only PARTLY loaded says so in the row {_w14!r}, or the "
|
||||
f"days it dropped read as days that were simply never written",
|
||||
isinstance(_w14, str) and (_h14.last_error or "") in _w14
|
||||
@@ -1023,7 +1155,7 @@ check("...and `words` never raises, whatever the file holds. It is read once "
|
||||
"from Controller.__init__ and again on every status render, and neither "
|
||||
"call site guards it - an exception here is an add-on that does not start",
|
||||
all(isinstance(words_of(h), str)
|
||||
for h in (_missing, _h6, _h7, _h14, _h_words)))
|
||||
for h in (_missing, _h6, _h6b, _h7, _h14, _h_words)))
|
||||
|
||||
# ⚠️ AND A FAILED WRITE LEAVES NOTHING BEHIND. `_save()` writes to a `mkstemp`
|
||||
# and renames - atomic, so a power cut cannot leave a truncated file that
|
||||
@@ -1067,6 +1199,7 @@ check(f"...and a SUCCESSFUL write leaves exactly the one file "
|
||||
# provider series with no invented points and no §11.4 degradation.
|
||||
for _label, _hist in (("no durable volume", _missing),
|
||||
("unreadable file", _h6),
|
||||
("a non-UTF-8 byte in the file", _h6b),
|
||||
("wrong schema", _h7)):
|
||||
_plan = run(resolve_tariff(CFG, NOW, _until,
|
||||
transport=transport_for(_body_today), now=NOW,
|
||||
@@ -1080,6 +1213,387 @@ for _label, _hist in (("no durable volume", _missing),
|
||||
and not any(p.provisional for p in _plan.read.points))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5c. FCST-05 - the WRITE path is as defensive as the read path")
|
||||
|
||||
# THE DEFECT. `_remember()` merged a day's held points with
|
||||
#
|
||||
# held = {q["start"]: q for q in raw
|
||||
# if isinstance(q, dict) and "start" in q}
|
||||
# rec["points"] = sorted(held.values(), key=lambda q: q["start"])
|
||||
#
|
||||
# which trusts a value read off /data to be a HASHABLE, SORTABLE STRING. It is
|
||||
# whatever is in the file. `_usable_days()` required `rec["points"]` to be a
|
||||
# LIST and never looked at the elements, and `_parse_points()` - the READ path -
|
||||
# was properly defensive, so `yesterday()` survived and the §5 `_p8` fixture
|
||||
# passed. Two standards for what a stored point is; the weaker one on the path
|
||||
# that WRITES.
|
||||
#
|
||||
# ⚠️ AND THE PLACEMENT IS THE WHOLE FIXTURE. `_p8` files its malformed rows
|
||||
# under a day the feed is NOT writing to, so `_remember()` never touches that
|
||||
# record and the defect is invisible. What reaches it is a malformed row under
|
||||
# TODAY - the record the running feed merges into on every refresh - so that is
|
||||
# asserted before anything is graded (MUTATION.md habit 1), and the harmless
|
||||
# placement is run as the accepted baseline it varies from (habit 7).
|
||||
#
|
||||
# ⚠️ AND THE BLAST RADIUS IS WHY IT IS NOT MERELY UNTIDY. `carry()` is reached
|
||||
# from `resolve_tariff()` from `run_tariff_refresh`, which catches `Exception`:
|
||||
# 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 permanently, with
|
||||
# no self-heal, because `_remember()` raises before it reaches `_save()`.
|
||||
|
||||
_BAD_STARTS = (
|
||||
("an int", 7), # TypeError: '<' between 'str' and 'int'
|
||||
("a dict", {"a": 1}), # TypeError: unhashable as a dict key
|
||||
("null", None), # TypeError: '<' between 'str', 'NoneType'
|
||||
("a list", [1, 2]), # TypeError: unhashable as a dict key
|
||||
("a string that is not an instant", "nope"), # sorts fine, never a point
|
||||
)
|
||||
|
||||
|
||||
def _seeded(day, bad_start):
|
||||
"""A store on disk holding ONE malformed row under `day`, plus a good one."""
|
||||
d = tmpdir()
|
||||
path = os.path.join(d, "price_history.json")
|
||||
good = {"start": brussels(at_local(day, 5)).isoformat(),
|
||||
"eur_per_kwh": allin(YESTERDAY_MWH[5]), "provisional": False}
|
||||
json.dump({"version": SCHEMA, "days": {day: {
|
||||
"product": "dynamic_dayahead", "provider": "generic_dayahead",
|
||||
"points": [good, {"start": bad_start, "eur_per_kwh": 0.2,
|
||||
"provisional": False}]}}},
|
||||
open(path, "w", encoding="utf-8"))
|
||||
return path, PriceHistory(path)
|
||||
|
||||
|
||||
_TODAY_FEED = series_of(TODAY_H, TODAY_MWH)
|
||||
check(f"the fixture files its malformed row under {TODAY_D}, which is exactly "
|
||||
f"the Brussels day the feed being carried writes to "
|
||||
f"{sorted({_ph._bdate(p.start) for p in _TODAY_FEED.points})} - under any "
|
||||
f"other day `_remember()` never opens the record and this whole section "
|
||||
f"grades nothing, which is why the suite already had this hole",
|
||||
{_ph._bdate(p.start) for p in _TODAY_FEED.points} == {TODAY_D})
|
||||
|
||||
# The accepted baseline: the SAME row, one day over. Shipped and broken alike
|
||||
# pass this, and that is the point of running it (habit 7).
|
||||
_pB, _hB = _seeded(YDAY_D, 7)
|
||||
_cB = derived(lambda: _hB.carry(_TODAY_FEED, since=NOW, until=_until, stamp=NOW))
|
||||
check("baseline: the same malformed row under a day the feed is NOT writing "
|
||||
"to is carried without incident - one variable moves between this and "
|
||||
"every check below, and it is the DAY",
|
||||
not isinstance(_cB, _Raised) and _cB.kind == "series")
|
||||
|
||||
for _shape, _bad in _BAD_STARTS:
|
||||
_pM, _hM = _seeded(TODAY_D, _bad)
|
||||
_wload = words_of(_hM)
|
||||
_c1 = derived(lambda h=_hM: h.carry(_TODAY_FEED, since=NOW, until=_until,
|
||||
stamp=NOW))
|
||||
_saw = repr(_c1) if isinstance(_c1, _Raised) else _c1.kind
|
||||
check(f"a `start` that is {_shape} ({_bad!r}) under TODAY's own record "
|
||||
f"does not take the refresh down: `carry()` returns a series "
|
||||
f"({_saw}). It raised "
|
||||
f"`TypeError` out of the shipped path, and `run_tariff_refresh` "
|
||||
f"catches `Exception`, so the add-on stayed up while every refresh "
|
||||
f"failed for good",
|
||||
not isinstance(_c1, _Raised) and _c1.kind == "series")
|
||||
# ⚠️ THE VERDICT IS NOT THE CLAIM. "It did not raise" is satisfied by a
|
||||
# store that silently kept the poison row; what this section promises is
|
||||
# that the row is GONE FROM THE FILE, so the next start is clean.
|
||||
_disk = json.load(open(_pM, encoding="utf-8"))
|
||||
_rows = _disk["days"][TODAY_D]["points"]
|
||||
# DERIVED, not a constant: the surviving rows are exactly the DISTINCT
|
||||
# instants the feed published for that day plus the fixture's one good
|
||||
# seeded row - which is 05:00, an hour the feed also prints, so it merges.
|
||||
_want = len({p.start.astimezone(UTC) for p in _TODAY_FEED.points}
|
||||
| {at_local(TODAY_D, 5).astimezone(UTC)})
|
||||
check(f"...and the row is DROPPED FROM /data, not carried: {len(_rows)} "
|
||||
f"row(s) on disk against {_want} distinct instants, every one of "
|
||||
f"them a point. `_remember()` used to raise BEFORE `_save()`, so "
|
||||
f"there was no self-heal at all - the store came back poisoned on "
|
||||
f"every restart",
|
||||
all(_ph._point_of(r) is not None for r in _rows)
|
||||
and len(_rows) == _want)
|
||||
# ...and the store stops reporting health, at BOTH moments it can be read:
|
||||
# when it is opened, and after the refresh that cleaned it.
|
||||
# ⚠️ THE COUNTS ARE ASSERTED TOO, NOT ONLY THE NOTE. The seeded record holds
|
||||
# ONE point and one row that is not one, so a row printing "2 points" would
|
||||
# be reporting the poison as held prices - which is the half of this defect
|
||||
# that reached the operator: `words` said "2026-01-15 (1 h)" while every
|
||||
# tariff refresh was dying on that record.
|
||||
check(f"...and the status row does not call that store healthy - at open "
|
||||
f"{_wload!r}: ONE point held, and the row that is not a point named "
|
||||
f"rather than counted",
|
||||
isinstance(_wload, str)
|
||||
and _wload.startswith(f"{TODAY_D} (1 point over 1 h) [")
|
||||
and "1 malformed point row(s) in" in _wload)
|
||||
# ⚠️ AND THE TWO NOTES ARE GRADED APART, BECAUSE THEY MASK EACH OTHER
|
||||
# OTHERWISE (MUTATION.md TEST-16). `_usable_days()` records what it FOUND
|
||||
# at open and `_remember()` records what it DROPPED on the refresh; both
|
||||
# land in `last_error`, so a check asking only "does the row mention a
|
||||
# malformed point" is answered by whichever rung is still standing - the
|
||||
# first draft of this check was exactly that, and deleting `_remember()`'s
|
||||
# note left the whole 29-file suite green at -1 B. The tense is the
|
||||
# difference an operator reads: "in" is still there, "dropped from" is
|
||||
# gone, and after the refresh only the second is true.
|
||||
_wafter = words_of(_hM)
|
||||
check(f"...nor after the refresh that dropped it {_wafter!r}. It said "
|
||||
f"'{TODAY_D} (1 h)' - the status row reporting a full cache while "
|
||||
f"every tariff refresh was dying on it",
|
||||
isinstance(_wafter, str)
|
||||
and "malformed point row(s) dropped from" in _wafter
|
||||
and TODAY_D in _wafter)
|
||||
check(f"...and the row MOVED from what was found to what was dropped "
|
||||
f"{_wload.split('[')[-1]!r} -> {_wafter.split('[')[-1]!r}, so the "
|
||||
f"two notes are not one note read twice",
|
||||
isinstance(_wload, str) and isinstance(_wafter, str)
|
||||
and "malformed point row(s) in" in _wload
|
||||
and "malformed point row(s) in" not in _wafter
|
||||
and "malformed point row(s) dropped from" not in _wload)
|
||||
# ...and a SECOND refresh over the same store is clean, which is the state
|
||||
# a restarted add-on comes up in.
|
||||
_hM2 = PriceHistory(_pM)
|
||||
check(f"...and the store reopened from that file is CLEAN: the whole day "
|
||||
f"held and nothing to report {words_of(_hM2)!r}, so the note clears "
|
||||
f"itself rather than becoming permanent furniture",
|
||||
words_of(_hM2) == f"{TODAY_D} ({_want} points over {_want} h)"
|
||||
and _hM2.last_error is None)
|
||||
|
||||
# ...and the shipped path, end to end, through the caller that catches
|
||||
# `Exception` and would otherwise have hidden this for good.
|
||||
_pP, _hP = _seeded(TODAY_D, 7)
|
||||
_o1 = derived(lambda: run(resolve_tariff(
|
||||
CFG, NOW, _until, transport=transport_for(_body_today), now=NOW,
|
||||
history=_hP)).origin)
|
||||
_o2 = derived(lambda: run(resolve_tariff(
|
||||
CFG, NOW, _until, transport=transport_for(_body_today), now=NOW,
|
||||
history=_hP)).origin)
|
||||
check(f"the SHIPPED `resolve_tariff()` path survives it TWICE ({_o1!r}, "
|
||||
f"{_o2!r}) - once for the refresh that finds the poison and once for "
|
||||
f"the one after it. A guard that refuses once and dies on the next call "
|
||||
f"is a delay, not a guard (MUTATION.md habit 9)",
|
||||
_o1 == "provider" and _o2 == "provider")
|
||||
|
||||
# ⚠️ AND THE MERGE KEY IS THE INSTANT, WHICH IS WHAT RULE 4 SAYS. The old key
|
||||
# was `q["start"]` - the SPELLING - so the same instant written at two offsets
|
||||
# was held twice, and the lexicographic sort beside it put the second 02:00 of
|
||||
# the 25-hour October day (`+01:00`) BEFORE the first (`+02:00`). Aware
|
||||
# datetimes hash and compare by instant, so the validated `start` is the key.
|
||||
_d15 = tmpdir()
|
||||
_p15 = os.path.join(_d15, "price_history.json")
|
||||
_alt = at_local(YDAY_D, 6)
|
||||
json.dump({"version": SCHEMA, "days": {YDAY_D: {
|
||||
"product": "dynamic_dayahead", "provider": "generic_dayahead",
|
||||
"points": [{"start": _alt.astimezone(UTC).isoformat(),
|
||||
"eur_per_kwh": 0.999, "provisional": False}]}}},
|
||||
open(_p15, "w", encoding="utf-8"))
|
||||
_h15 = PriceHistory(_p15)
|
||||
check(f"the two spellings really are one instant and really are two strings "
|
||||
f"({_alt.astimezone(UTC).isoformat()!r} vs "
|
||||
f"{brussels(_alt).isoformat()!r}), or 'merged by instant' and 'merged by "
|
||||
f"spelling' are the same observation",
|
||||
_alt.astimezone(UTC).isoformat() != brussels(_alt).isoformat()
|
||||
and _alt.astimezone(UTC) == brussels(_alt))
|
||||
_ph.PriceHistory._remember(
|
||||
_h15, series_of([_alt], YESTERDAY_MWH), NOW)
|
||||
_y15 = _h15.yesterday(NOW)
|
||||
_n15 = len(_y15.points) if _y15.kind == "series" else _y15.kind
|
||||
_v15 = _y15.points[0].eur_per_kwh if _y15.kind == "series" else None
|
||||
check(f"...and a revision of that hour REPLACES it rather than sitting beside "
|
||||
f"it: {_n15} point held, priced {_v15} and not 0.999. Rule 4 says "
|
||||
f"'MERGE BY INSTANT'; the key was the string",
|
||||
_y15.kind == "series" and len(_y15.points) == 1
|
||||
and abs(_y15.points[0].eur_per_kwh - allin(YESTERDAY_MWH[6])) < 1e-9)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5d. FCST-06 - the OTHER way to be a store that cannot persist")
|
||||
|
||||
# THE DEFECT. Section 5 above grades "a store that cannot persist records
|
||||
# nothing" against `not ok` - the missing-durable-volume SYMPTOM - and that is
|
||||
# the only condition the rule was ever enforced on. `ok` stays TRUE on a volume
|
||||
# that is present and cannot be written: a read-only remount, a full disk. In
|
||||
# that state `_remember()` had already populated `self._days`, `_save()` logged
|
||||
# and returned, and the store went on answering a series for the rest of the
|
||||
# process's life. Measured on 5059e28, twice, with a `/data` bind-mounted
|
||||
# read-only and again on a full tmpfs:
|
||||
#
|
||||
# last_error = 'could not write ([Errno 30] Read-only file system)'
|
||||
# words = '2026-01-14 (24 points over 24 h); 2026-01-15 (24 points
|
||||
# over 24 h) [could not write (...)]'
|
||||
# yesterday()= series, 24 points
|
||||
# ON DISK = {} <- and gone on the next restart
|
||||
#
|
||||
# `words` and `yesterday()` were describing two different stores, which is
|
||||
# exactly what section 5's own comment refuses - reached by the condition the
|
||||
# rule did not name.
|
||||
#
|
||||
# ⚠️ THE LEVERS ARE REAL FAILURES, NOT PATCHED ONES, AND THERE ARE TWO OF THEM
|
||||
# ON PURPOSE. The fix is written against the OUTCOME - `_save()` answering "no"
|
||||
# - and not against an errno, so one cause would grade a coincidence. A
|
||||
# DIRECTORY where the file goes fails `os.replace`; a volume that moves out
|
||||
# from under an already-open store fails `mkstemp`. Different syscall,
|
||||
# different errno, one answer (habit 10: grade the family).
|
||||
|
||||
def npts(read):
|
||||
"""A read's point count, or "no_data". Never raises.
|
||||
|
||||
⚠️ NOT `len(read.points)`. On a `no_data` that attribute RAISES `Absence`
|
||||
by design, so a mutation that empties a store where this section expects
|
||||
one would take the harness down with a traceback instead of reporting a
|
||||
FAIL - and MUTATION.md's rule is that "it answered nothing" and "it broke
|
||||
on the way to answering" must not grade the same. Found by driving M4.
|
||||
"""
|
||||
return read.match(series=lambda r: len(r.points),
|
||||
no_data=lambda _r: "no_data")
|
||||
|
||||
|
||||
_d16 = tmpdir()
|
||||
_p16 = os.path.join(_d16, "price_history.json")
|
||||
_h16 = PriceHistory(_p16)
|
||||
os.mkdir(_p16) # `os.replace` cannot complete onto a directory
|
||||
check(f"the fixture is an `ok` store on a directory that IS there "
|
||||
f"(ok={_h16.ok}, last_error={_h16.last_error!r}) - not the "
|
||||
f"no-durable-volume case section 5 already grades, or this section is "
|
||||
f"that one again (habit 1)",
|
||||
_h16.ok and _h16.last_error is None and os.path.isdir(_d16))
|
||||
_carried16 = _h16.carry(series_of(YDAY_H + TODAY_H, TODAY_MWH),
|
||||
since=NOW, until=_until, stamp=NOW)
|
||||
_y16 = _h16.yesterday(NOW)
|
||||
check(f"a write that could not complete is REPORTED and leaves the store "
|
||||
f"`ok` (ok={_h16.ok}, {(_h16.last_error or '')[:44]!r}). `ok` is about "
|
||||
f"the DIRECTORY, and a full volume and a read-only remount both leave "
|
||||
f"it True - which is why testing `ok` alone could not see this",
|
||||
_h16.ok and "could not write" in (_h16.last_error or ""))
|
||||
check(f"...and the file really does not hold the day "
|
||||
f"(isfile={os.path.isfile(_p16)}), or 'it answers what is not there' is "
|
||||
f"not a measurement (habit 1)",
|
||||
not os.path.isfile(_p16))
|
||||
check(f"A STORE THAT COULD NOT WRITE RECORDS NOTHING, exactly as one with no "
|
||||
f"durable volume does: it holds {sorted(_h16._days)} and `yesterday()` "
|
||||
f"is {_y16.kind!r}. Retaining the merge is an in-memory yesterday that "
|
||||
f"answers all day and is gone on the next restart - the "
|
||||
f"silently-forgetting cache this module's docstring exists to refuse",
|
||||
_h16._days == {} and _y16.kind == "no_data")
|
||||
check(f"...and `words` and `yesterday()` AGREE about that store "
|
||||
f"{_h16.words[:44]!r}: neither names a day the file does not hold, and "
|
||||
f"both name the write failure. They disagreed - `words` printed two "
|
||||
f"full days BESIDE the error while `yesterday()` answered 24 points",
|
||||
YDAY_D not in _h16.words and TODAY_D not in _h16.words
|
||||
and "could not write" in _h16.words
|
||||
and _y16.kind == "no_data" and "could not write" in why(_y16))
|
||||
check(f"...and it DEGRADES rather than raising: the carry still returned a "
|
||||
f"real series ({_carried16.kind!r}, {npts(_carried16)} points) with "
|
||||
f"nothing carried. §6 and this module's opening - an add-on that will "
|
||||
f"not start because /data filled up is a WORSE defect than the one this "
|
||||
f"section is about",
|
||||
_carried16.kind == "series"
|
||||
and not _carried16.match(
|
||||
series=lambda r: any(p.provisional for p in r.points),
|
||||
no_data=lambda _r: True))
|
||||
_plan16 = derived(lambda: run(resolve_tariff(
|
||||
CFG, NOW, _until, transport=transport_for(_body_today), now=NOW,
|
||||
history=_h16)).origin)
|
||||
check(f"...and the SHIPPED `resolve_tariff()` path is unmoved by it "
|
||||
f"({_plan16!r}): a provider plan, no §11.4 degradation, on a store that "
|
||||
f"cannot write a byte",
|
||||
_plan16 == "provider")
|
||||
# Habit 9: the same wrong input a SECOND time. A refresh runs 48 times a day,
|
||||
# and a volume that is full at 14:00 is still full at 14:30.
|
||||
_h16.carry(series_of(YDAY_H, YESTERDAY_MWH), since=NOW, until=_until,
|
||||
stamp=NOW)
|
||||
check(f"...and the SECOND failing refresh behaves the same rather than "
|
||||
f"accumulating: still {sorted(_h16._days)} held and "
|
||||
f"{_h16.yesterday(NOW).kind!r}. The write fails 48 times a day, so the "
|
||||
f"call that matters is the one after the first (habit 9)",
|
||||
_h16._days == {} and _h16.yesterday(NOW).kind == "no_data")
|
||||
|
||||
# ⚠️ 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. This is also the fixture that separates a deep snapshot from
|
||||
# `dict(self._days)`: a one-level copy shares the day RECORD the merge rebinds
|
||||
# `points` on, so the "restored" store would answer the 24 points the merge
|
||||
# made and the file never took.
|
||||
_d17 = os.path.join(tmpdir(), "data")
|
||||
os.mkdir(_d17)
|
||||
_p17 = os.path.join(_d17, "price_history.json")
|
||||
_h17 = PriceHistory(_p17)
|
||||
_ph.PriceHistory._remember(_h17, series_of(YDAY_H[:12], YESTERDAY_MWH), NOW)
|
||||
_first17 = _h17.yesterday(NOW)
|
||||
check(f"a HALF day is persisted first ({npts(_first17)} points, "
|
||||
f"last_error={_h17.last_error!r}), or 'the merge is undone and the "
|
||||
f"store is not' grades nothing (habit 1)",
|
||||
_h17.last_error is None and os.path.isfile(_p17)
|
||||
and npts(_first17) == 12)
|
||||
_persisted17 = open(_p17, encoding="utf-8").read()
|
||||
_moved17 = _d17 + ".moved"
|
||||
os.rename(_d17, _moved17) # the volume goes out from under an OPEN store:
|
||||
# `mkstemp` fails, a different call and a
|
||||
# different errno from the fixture above, and
|
||||
# the bytes already written stay intact
|
||||
_ph.PriceHistory._remember(_h17, series_of(YDAY_H[12:], YESTERDAY_MWH), NOW)
|
||||
_y17 = _h17.yesterday(NOW)
|
||||
_n17 = npts(_y17)
|
||||
check(f"the second lever failed the write by a DIFFERENT route "
|
||||
f"{(_h17.last_error or '')[:44]!r} - `mkstemp`, not `os.replace`, and "
|
||||
f"ENOENT, not EISDIR. The rule is enforced on the outcome, so it must "
|
||||
f"be driven by more than one cause (habit 10)",
|
||||
"could not write" in (_h17.last_error or "")
|
||||
and "No such file" in (_h17.last_error or ""))
|
||||
check(f"...and the store keeps the last state that IS ON DISK, not an empty "
|
||||
f"one: {_n17} points held - the 12 it wrote before the volume went, not "
|
||||
f"the 24 the unwritten merge made, and not nothing. The MERGE is undone; "
|
||||
f"the store is not",
|
||||
npts(_y17) == 12)
|
||||
_ph.PriceHistory._remember(_h17, series_of(YDAY_H[12:], YESTERDAY_MWH), NOW)
|
||||
_y17b = _h17.yesterday(NOW)
|
||||
check(f"...and again on the second failing refresh ({npts(_y17b)} points), "
|
||||
f"because a volume that has gone is still gone 30 minutes later "
|
||||
f"(habit 9)",
|
||||
npts(_y17b) == 12)
|
||||
os.rename(_moved17, _d17) # the volume comes back: what a RESTART finds
|
||||
_r17 = PriceHistory(_p17).yesterday(NOW)
|
||||
check(f"...and that is EXACTLY what a restart finds ({npts(_r17)} points, the "
|
||||
f"file byte-for-byte as it was left), which is the whole claim: the "
|
||||
f"store answers what survives a restart and nothing else",
|
||||
open(_p17, encoding="utf-8").read() == _persisted17
|
||||
and npts(_r17) == 12
|
||||
and _r17.match(series=lambda a: _y17.match(
|
||||
series=lambda b: [p.start for p in a.points]
|
||||
== [p.start for p in b.points],
|
||||
no_data=lambda _r: False), no_data=lambda _r: False))
|
||||
|
||||
# ⚠️ AND "CANNOT PERSIST" IS ONE PREDICATE, ASKED ONCE. FCST-04's M4 and
|
||||
# FCST-05's HIGH were both a rule enforced on the conditions somebody thought
|
||||
# of rather than on the family; this is the third of the shape and the last
|
||||
# place it can be spelt twice. `_save()` returns whether the file now holds
|
||||
# what memory holds - no volume is one False, an `OSError` is another - and
|
||||
# `_remember()` keeps only what that answer says was kept.
|
||||
check(f"`_save()` answers for BOTH symptoms and is the only thing that does: "
|
||||
f"no durable volume -> {_ph.PriceHistory._save(_missing)!r}, a write "
|
||||
f"that fails -> {_ph.PriceHistory._save(_h16)!r}, a store that "
|
||||
f"persisted -> {_ph.PriceHistory._save(_hok)!r}",
|
||||
_ph.PriceHistory._save(_missing) is False
|
||||
and _ph.PriceHistory._save(_h16) is False
|
||||
and _ph.PriceHistory._save(_hok) is True)
|
||||
check(f"...and asking it once leaves the two symptoms with ONE behaviour: "
|
||||
f"no volume holds {sorted(_missing._days)}/"
|
||||
f"{_missing.yesterday(NOW).kind!r} and a failed write holds "
|
||||
f"{sorted(_h16._days)}/{_h16.yesterday(NOW).kind!r}. A third cause "
|
||||
f"nobody has thought of needs no third branch - which is how this defect "
|
||||
f"arrived, twice",
|
||||
(_missing._days, _missing.yesterday(NOW).kind)
|
||||
== (_h16._days, _h16.yesterday(NOW).kind))
|
||||
check("...and `_remember()` no longer tests a symptom of its own: the source "
|
||||
"holds no `self.ok` test between the docstring and the merge. This one "
|
||||
"IS about the code's shape - two places testing two symptoms is the "
|
||||
"defect, not a behaviour (habit 4's stated exception)",
|
||||
"if not self.ok:" not in inspect.getsource(_ph.PriceHistory._remember)
|
||||
and "if not self.ok:" in inspect.getsource(_ph.PriceHistory._save))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("6. rule 4 - hours accumulate within a day; they do not replace it")
|
||||
|
||||
@@ -778,9 +778,20 @@ check("a dead feed with NO static_profile has nothing to fall back to, and "
|
||||
_fb_none.origin == "none" and not _fb_none.has_prices)
|
||||
|
||||
# A live feed still wins over the profile.
|
||||
_body = json.dumps({"data": [
|
||||
{"start": (THU + timedelta(hours=i)).isoformat(), "price_eur_mwh": 40 + i}
|
||||
for i in range(24)]}).encode()
|
||||
# ⚠️ COLUMNAR (TARIFF-08). `api.energy-charts.info` publishes parallel
|
||||
# `unix_seconds`/`price` arrays and no `data` key; the `{"data": [...]}` body
|
||||
# this used to build was invented by the parser it was grading, which is how a
|
||||
# provider that could never parse a live response shipped green. Real captured
|
||||
# bodies are replayed in test_price.py section 2b - the prices here are
|
||||
# synthetic, the SHAPE is not.
|
||||
_body = json.dumps({
|
||||
"unix_seconds": [int((THU + timedelta(hours=i)).timestamp())
|
||||
for i in range(24)],
|
||||
"price": [40 + i for i in range(24)],
|
||||
"unit": "EUR / MWh",
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||||
"from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated": False}).encode()
|
||||
|
||||
|
||||
async def _live(url, timeout_s):
|
||||
|
||||
Reference in New Issue
Block a user