Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a5281e97f | ||
|
|
6eba18968c | ||
|
|
958f5cd218 | ||
|
|
c45893ffad | ||
|
|
5059e28860 | ||
|
|
375a1e29d2 | ||
|
|
c2787da539 | ||
|
|
b59f5ce31b | ||
|
|
0618194d9c | ||
|
|
587751b115 | ||
|
|
3de4453956 | ||
|
|
f6f3f227c1 | ||
|
|
91b0d5e4dc | ||
|
|
806390d8b1 |
@@ -1,5 +1,332 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.**
|
||||
PKG-06's and PKG-07's rule: the Supervisor keys the built image by `version:`,
|
||||
so `ha apps update` is a silent no-op on an unbumped change. This branch
|
||||
deliberately does **not** pick the number, because the right one depends on a
|
||||
fact the branch cannot observe - whether 0.9.0's image has already been built
|
||||
and pulled. If it has not, this belongs inside 0.9.0's section before it ships;
|
||||
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 -
|
||||
`TariffPlan.firm_points()`, `cheap_now()`'s refusal of a provisional hour, and
|
||||
`arbitrage()`'s refusal 3, which drops provisional hours from the trade set
|
||||
rather than refusing the whole series. Nothing in `app/` called the fill, so no
|
||||
series ever contained a carried point and every one of those consumers was
|
||||
exercising its all-firm branch.
|
||||
|
||||
⚠️ **THE DEFECT THAT PRODUCED WAS NOT AN ERROR ANYWHERE.** `load_prices()`
|
||||
returns what the feed published and nothing widened it to the requested horizon,
|
||||
so on a dynamic site between midnight and the 14-16 h CET day-ahead publication
|
||||
the plan covered the rest of today and **nothing more** - typically 10-24 of the
|
||||
48 hours `price_horizon_h` asked for. `arbitrage()` then took `max - min` over
|
||||
that truncated window and reported a spread. The number was real, the window was
|
||||
half the one configured, and **nothing said so**: tomorrow evening's peak, the
|
||||
hour a morning charge would be sold into, was not in the series at all, so the
|
||||
spread it would have created was never compared against anything.
|
||||
|
||||
**What changes:**
|
||||
|
||||
* `app/price_history.py` (new) keeps the **published** prices of the last few
|
||||
Brussels days in `/data/price_history.json`, and `resolve_tariff()` now fills
|
||||
an unpublished hour of the horizon from yesterday's same-wall-clock hour,
|
||||
flagged provisional. ⚠️ **"An" unpublished hour, not "every" one:** the fill
|
||||
can carry only an hour yesterday actually published, so a partial yesterday
|
||||
leaves the local hours it never held **out of the series** - §11.4 for those
|
||||
hours, and asking for one raises rather than answering. Correct behaviour,
|
||||
and the wording is now the behaviour.
|
||||
* ⚠️ **A sub-hourly feed keeps every print.** `eneco_eddy` is the imbalance
|
||||
product and settles per **quarter**; the fill walks whole hours to decide
|
||||
what needs carrying, and that is not a rule about what a published hour may
|
||||
contain. Every published point inside the horizon passes through by identity,
|
||||
and a carried point is added only for a whole hour with no published coverage
|
||||
at all. The first draft keyed the published set by hour - last point wins -
|
||||
and a 96-point quarter-hourly feed reached the optimiser as 15 points with
|
||||
its spread down from 0.143 to 0.014 EUR/kWh, on an all-firm, fully-published
|
||||
series.
|
||||
* The horizon is **whole hours at both ends**: `since` is floored to the hour
|
||||
that contains it, and so is `until`. Asked for 48 hours at 08:17, the plan is
|
||||
48 hours long and not 49.
|
||||
* `resolve_tariff(history=...)` is **required and keyword-only**. A caller with
|
||||
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, 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
|
||||
widens what the optimiser can **see**, never what it may **do**. What does
|
||||
change a trade is that the filled series is windowed to `[since, until)`, so
|
||||
**hours that have already elapsed are no longer in the spread**. Before, a cheap
|
||||
night that had already gone sat in the series as the day's cheapest hour - and a
|
||||
cycle cannot buy in an hour that is past, so the trade was quietly never taken.
|
||||
`test_price_history.py` section 2 grades this by finding the cheapest firm hour
|
||||
with and without the fill and showing it **moves**.
|
||||
|
||||
⚠️ **A DEAD FEED IS STILL §11.4, NOT YESTERDAY.** The fill runs only on a
|
||||
`series` read; a feed that published nothing still degrades to the configured
|
||||
`static_profile`. The two are indistinguishable to the optimiser - both are
|
||||
100 % provisional, so arbitrage refuses on the firm-pair rung and `cheap_now()`
|
||||
is False - so preferring yesterday would change a documented degradation for a
|
||||
difference only the status page can see.
|
||||
`DECISIONS/FCST-04-the-day-that-is-carried.md` carries that and two more.
|
||||
|
||||
⚠️ **A BROKEN CACHE IS NOT A BROKEN CONTROLLER.** No `/data`, an unreadable
|
||||
file, a schema this build does not know (including a `version` of `1.0`, which
|
||||
`== 1`), a **day record that is not a record**, a malformed point - each its own
|
||||
state with its own reason, none of them a price and none of them an empty
|
||||
series. In every one the plan is still a real provider series and the controller
|
||||
still regulates; the horizon is just as short as it was before this card.
|
||||
`.words` never raises, because `Controller.__init__` reads it unguarded and a
|
||||
status line that throws is an add-on that does not start.
|
||||
|
||||
⚠️ **AND WHERE THERE IS NO `/data`, NOTHING IS RECORDED AT ALL** - not even in
|
||||
memory. An in-memory yesterday answers for one run and is gone on the next
|
||||
restart, which is a cache that forgets without saying so; the honest pair is
|
||||
`unavailable` on the status row and no prices carried.
|
||||
|
||||
⚠️ **"YESTERDAY" IS A LOCAL DATE, AND THE ARITHMETIC IS ON THE DATE.**
|
||||
`brussels()` renders at a fixed offset, so subtracting `timedelta(days=1)` from
|
||||
it subtracts 24 **absolute** hours - and the Brussels day before a
|
||||
spring-forward is 23 hours long. Between 00:00 and 00:59 on the morning after
|
||||
the March transition that lands on the day *before* yesterday. **No option was
|
||||
added and `config.yaml` is untouched.**
|
||||
|
||||
## 0.9.0
|
||||
|
||||
⚠️ **MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK - AND THAT DISTINCTION IS THE
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# FCST-04 — what "yesterday" is, and what the fill is allowed to reach
|
||||
|
||||
Four decisions, recorded because each had a defensible alternative that a later
|
||||
reader would otherwise re-open from scratch.
|
||||
|
||||
---
|
||||
|
||||
## 1. The fill runs on a `series` read only. A dead feed still goes to §11.4.
|
||||
|
||||
`fill_from_yesterday()` accepts a `no_data` read and will happily build a
|
||||
100 %-carried series from yesterday alone. `resolve_tariff()` does not let it:
|
||||
a feed that published nothing still falls to `_degrade()` and §11.4's own row —
|
||||
*"No price data → fall back to the static profile, mark the plan provisional,
|
||||
no arbitrage."*
|
||||
|
||||
**Why not prefer yesterday's real prices to a coarse day/night profile?**
|
||||
Because it buys nothing the optimiser can use, and it silently replaces a
|
||||
fallback the operator configured.
|
||||
|
||||
Measured, not assumed — and stated at the reach it actually has:
|
||||
|
||||
- **This one reaches control.** A fully-carried series is 100 % provisional, so
|
||||
`TariffPlan.arbitrage()` finds fewer than two firm hours and refuses on
|
||||
§8.3's firm-pair rung. That is the verdict the optimiser reads: `arbitrage.py`
|
||||
calls `plan.arbitrage(...)` on `fresh_tariff_plan()`, and it is the only path
|
||||
by which a provider plan moves energy. The §11.4 static fallback refuses on
|
||||
the same rung, for the same reason.
|
||||
- **This one does not, and saying so is the point.** `TariffPlan.cheap_now()`
|
||||
also returns False on a fully-carried series, because `firm_points()` is
|
||||
empty — but it has **no production caller on the provider plan**.
|
||||
`self.cheap` comes from `static_status()`, which builds its *own* static plan
|
||||
and calls `cheap_now()` on that. So it is a property of the type, checked in
|
||||
`test_static_tariff.py`, and it is not evidence about the shipped control
|
||||
path. The conclusion below holds on the first bullet alone.
|
||||
|
||||
The two paths therefore differ only in which numbers the status page shows.
|
||||
|
||||
So the choice is between two identical control behaviours, and the tie-breaker
|
||||
is that `static_profile` is a documented option an operator fills in **for this
|
||||
case**. Overriding it for a cosmetic difference is a behaviour change nobody
|
||||
asked for.
|
||||
|
||||
⚠️ **The consequence, stated so it is not discovered later as a bug:**
|
||||
`fill_from_yesterday()`'s `no_data` branch is not reached from production.
|
||||
It stays checked in `test_price_forecast.py`. Turning it on is a product
|
||||
decision — it would want a rule about how old a carried profile may be before it
|
||||
stops being offered at all — and not a wiring one.
|
||||
|
||||
---
|
||||
|
||||
## 2. "Yesterday" is a date, not the newest record.
|
||||
|
||||
`newest()` is the obvious accessor and it is the one that decays. An add-on down
|
||||
from Friday to Monday would answer Friday's prices to a fill whose only
|
||||
vocabulary is `provisional: true` — a flag that says "this is a guess", never
|
||||
"this guess is three days old". Nothing downstream can tell the difference and
|
||||
nothing reports it.
|
||||
|
||||
So `yesterday(stamp)` computes the Brussels date before `stamp`'s Brussels date
|
||||
and looks up **that** record. Anything else is `no_data` with a reason naming
|
||||
both the date wanted and the dates held.
|
||||
|
||||
The cost is real and accepted: after an outage of more than a day, the first
|
||||
morning back has no fill. That is the honest state — there is no yesterday —
|
||||
and the horizon is short for one morning rather than wrong for several.
|
||||
|
||||
---
|
||||
|
||||
## 3. What is recorded is the provider's read, and the mechanism is ordering.
|
||||
|
||||
Feeding the filled series back into the store is how this rots: day 2 carries
|
||||
day 1's guesses, day 3 carries day 2's carry of them, and after a week of a dead
|
||||
feed the "yesterday" being carried is a fossil of the last real publication.
|
||||
Every point still carries `provisional=True`, so the **flag never lies** — what
|
||||
decays is the number, behind a flag that is telling the truth.
|
||||
|
||||
⚠️ **No value-level check can catch that**, and claiming one would be worse than
|
||||
claiming none. A carried point and a published-provisional point (`eneco_eddy`'s
|
||||
current quarter, §9.2) are the same three fields carrying the same flag. The
|
||||
difference is provenance, and provenance is not in the value.
|
||||
|
||||
The defence is therefore an **ordering**, not a validation:
|
||||
|
||||
- `carry()` is the only public entry point. It records its argument, then
|
||||
returns the fill.
|
||||
- The filled series exists only as that return value. The object never sees it
|
||||
again.
|
||||
- `_remember()` is private and called from exactly one place.
|
||||
- `test_price_history.py` parses every shipped module under `app/`, `tools/`,
|
||||
`scenarios/` and `estop/` and fails if any of them **uses the name**
|
||||
`_remember` — an attribute, a bare name, a `def`, or the string constant a
|
||||
`getattr` would need. By name rather than by substring, so a comment that
|
||||
merely mentions the rule does not fail it and a call in `tools/` cannot pass
|
||||
unseen.
|
||||
- and it pins the order two ways: the **line numbers** of the two calls in
|
||||
`carry()`, and — the one that matters — the **behaviour**. A feed carrying
|
||||
yesterday alongside today, into an empty store, must fill the whole horizon;
|
||||
reversed, the lookup finds nothing and the plan is only the hours the feed
|
||||
published. Driven, so a reversal is red.
|
||||
|
||||
⚠️ **The first version of that ordering check graded nothing, and this file
|
||||
said it did.** It compared positions in `ast.walk`, which is breadth-first:
|
||||
`self._remember(...)` is a statement and `self.yesterday(...)` is an *argument*
|
||||
of `fill_from_yesterday(...)`, one level deeper, so `_remember` came out first
|
||||
whichever statement was written first. Measured — shipped and reversed both
|
||||
scored the same. A decorative check that a document cites as a guarantee is
|
||||
worse than no check, which is why the correction is recorded here rather than
|
||||
quietly made.
|
||||
|
||||
That ordering has a second benefit worth keeping: a feed that returns yesterday
|
||||
alongside today refreshes yesterday's record from live data *before* it is
|
||||
consumed as the profile.
|
||||
|
||||
---
|
||||
|
||||
## What this ticket does not claim
|
||||
|
||||
It does not make a trade possible that yesterday's prices justify. Carried hours
|
||||
are dropped from the trade set by §8.3, and that is not a limitation to be fixed
|
||||
later — it is the rule.
|
||||
|
||||
The one change that **does** move energy came from the windowing, not the
|
||||
carrying: the filled series covers `[since, until)`, so hours that have already
|
||||
elapsed are no longer in the spread. A cheap night that has gone used to be the
|
||||
series' cheapest hour, and a cycle cannot buy in an hour that is past — so the
|
||||
trade was silently never taken. `test_price_history.py` section 2 grades that by
|
||||
finding the cheapest firm hour with and without the fill and showing it moves.
|
||||
|
||||
⚠️ **And section 1's identity check now grades identity.** It used to compare
|
||||
`eur_per_kwh` between `_before` and `_after`, which come from two *separate*
|
||||
`resolve_tariff()` calls — each parsing the feed body into its own objects, so
|
||||
`is` could never have held between them however the fill were written. FCST-03's
|
||||
whole anti-copy argument is `filled.points[i] is fetched.points[j]`, and floats
|
||||
cannot grade it. The value comparison is kept and now says what it is (the fill
|
||||
moved the window, it did not touch a price); identity is graded on a read the
|
||||
file constructs and still holds a reference to.
|
||||
|
||||
---
|
||||
|
||||
## 4. A sub-hourly feed passes through whole; the fill only decides which hours are missing.
|
||||
|
||||
`eneco_eddy` is the Belgian imbalance product and settles **per quarter**, and
|
||||
`price.py`'s `_validate_raw()` accepts any strictly-increasing spacing — so a
|
||||
published hour may legitimately hold four points.
|
||||
|
||||
FCST-04's first caller keyed the published set as `{_utc_hour(p.start): p for p
|
||||
in r.points}`. It reads perfectly ordinary and it keeps the **last** point of
|
||||
each hour. Measured on a 96-point quarter-hourly feed — all firm, fully
|
||||
published, nothing to carry: the plan reached the optimiser as **15 points**,
|
||||
the arbitrage spread fell from **0.143 to 0.014 EUR/kWh**, and the cheapest
|
||||
"hour" became a `:45` point whose `at()` window runs into the next hour. That is
|
||||
not the windowing effect above; it is a feed losing 84 % of its own prints, and
|
||||
no `provisional` flag would ever have reported it.
|
||||
|
||||
**The alternative was rejected:** refusing sub-hourly feeds at `_validate_raw()`
|
||||
would have made the count correct by breaking the one supported product that
|
||||
legitimately publishes per quarter.
|
||||
|
||||
**What was done instead** keeps FCST-03's existing contract rather than weakening
|
||||
it: *every* published point inside the window passes through **by identity**,
|
||||
and a carried point is added only for a whole hour with **no** published
|
||||
coverage at all. A carried hour is still a single hourly point whatever the
|
||||
feed's own spacing is, because yesterday's profile is only known per wall-clock
|
||||
hour — and carried hours are provisional, so they are out of the trade set
|
||||
anyway.
|
||||
|
||||
⚠️ **`_audit()` got stricter, not looser, and that was the condition on doing
|
||||
this at all.** The old post-condition refused any repeated `_utc_hour` — a count
|
||||
that cannot express four points in one hour. It is replaced by two rules that
|
||||
**subsume** it:
|
||||
|
||||
- a **published** point must *be* one of that hour's published points, by
|
||||
identity, and may be emitted only once;
|
||||
- a **carried** point may stand only in an hour that does not appear in the
|
||||
published set at all, must be flagged `True`, and may not repeat.
|
||||
|
||||
So a duplicated carried hour — the DST mispricing the count existed to catch —
|
||||
still raises; so does the same published point emitted twice; and a carried
|
||||
point sharing an hour with a real print now raises too, which the count never
|
||||
looked at. Each of those three is driven by its own mutation in
|
||||
`test_price_forecast.py`.
|
||||
@@ -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
|
||||
@@ -1424,6 +1445,85 @@ distinct state from "prices, all of them zero": the plan is marked provisional
|
||||
and arbitrage is disabled, per the degradation matrix. It never falls back to a
|
||||
price of zero, which would read as free electricity.
|
||||
|
||||
#### The hours the feed has not published yet (FCST-04)
|
||||
|
||||
A day-ahead auction publishes tomorrow's prices once a day, at **14–16 h CET**.
|
||||
Before that, the feed is serving today and nothing else — so a `price_horizon_h`
|
||||
of 48 asks for 48 hours and gets back the ten or fifteen that are left in the
|
||||
day. Until FCST-04 those other hours were simply **absent** from the plan: not
|
||||
priced badly, not flagged, just missing, and nothing said so.
|
||||
|
||||
Now the add-on keeps the **published prices of the last few days** in
|
||||
`/data/price_history.json` and fills an unpublished hour of the horizon with
|
||||
yesterday's price for the same Brussels wall-clock hour, flagged
|
||||
`provisional: true`. Two things follow, and the second one matters more:
|
||||
|
||||
- **The plan says it is guessing** — `provisional: true` on every carried hour,
|
||||
which is what the optimiser reads. Before, a half-length plan looked
|
||||
completely settled.
|
||||
- **Nothing may trade on a carried hour.** §8.3 forbids acting on a provisional
|
||||
price, so arbitrage drops those hours from the trade set entirely — it needs a
|
||||
buy hour and a sell hour that were *both* published. Carrying a price forward
|
||||
widens what the optimiser can **see**; it never widens what it may **do**.
|
||||
|
||||
**"An unpublished hour", not "every unpublished hour"** — the fill can only
|
||||
carry an hour yesterday actually published. If yesterday's record is itself
|
||||
partial (a first day, an outage, a feed that only ever serves the rest of the
|
||||
day), the local hours it never held are **left out of the series**: §11.4
|
||||
applies to those hours, and asking the plan for one raises rather than
|
||||
answering. An hour with no price is not a free hour, and the fill does not
|
||||
invent a neighbour, a mean or a zero for it.
|
||||
|
||||
**A quarter-hourly feed keeps all four of its prints.** `eneco_eddy` settles
|
||||
per quarter, and the fill decides *which whole hours need carrying* — never what
|
||||
a published hour may contain. Every point the provider published inside the
|
||||
horizon is passed through untouched; a carried point is added only for a whole
|
||||
hour the feed did not print at all.
|
||||
|
||||
**What you can actually see on the status page.** *Price history* (below) says
|
||||
what is held. *Arbitrage* carries the strategy's own sentence, which names the
|
||||
firm/provisional split whenever that split is what refused a trade. *Tariff*
|
||||
does **not** report the fill: it is the static-profile or Home Assistant
|
||||
price-entity sentence, regenerated on the 30 s tick. The provider plan's own
|
||||
line — `dynamic_dayahead from generic_dayahead, 48 h, provisional` — goes to the
|
||||
**add-on log** at every refresh, alongside `§8.3: 33 of 48 hours carried forward
|
||||
from yesterday's profile and flagged provisional; 15 published`.
|
||||
|
||||
The plan is also now cut to the horizon you asked for, which means **hours that
|
||||
have already elapsed are no longer in it**. That is a real change to arbitrage:
|
||||
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, 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
|
||||
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 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.
|
||||
It is a single number rather than a curve because no supported supplier
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,6 +68,7 @@ from . import peakguard
|
||||
from . import packshare
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option
|
||||
from .price_history import PriceHistory
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .tariff import PeakStore, PeakTracker
|
||||
from .store import migrate_maint_keys, migrate_unit_keys
|
||||
@@ -165,7 +166,8 @@ def pack_energy_of(inv):
|
||||
|
||||
class Controller:
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||
peak_db_path: str = "/data/tariff.db"):
|
||||
peak_db_path: str = "/data/tariff.db",
|
||||
price_history_path: str = "/data/price_history.json"):
|
||||
self.o = opts
|
||||
self.hass = hass
|
||||
self.store = store
|
||||
@@ -582,6 +584,42 @@ class Controller:
|
||||
self.peak.restore(self.p1.averager)
|
||||
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
|
||||
|
||||
# FCST-04. The §8.3 carry-forward store: the published prices of the
|
||||
# last few Brussels days, so the hours a day-ahead feed has not printed
|
||||
# yet can be filled from yesterday's profile and FLAGGED, instead of
|
||||
# being absent from the series entirely.
|
||||
#
|
||||
# ⚠️ IT IS AN INSTRUMENT'S BACKING FILE, NOT A STRATEGY. It commands
|
||||
# nothing and claims nothing; what it changes is how many hours the
|
||||
# plan covers. Before this, a dynamic site between midnight and the
|
||||
# 14-16 h CET publication had a series covering only the rest of today
|
||||
# - typically 10-24 of the 48 hours `price_horizon_h` asked for - and
|
||||
# `TariffPlan.arbitrage()` took its spread over that truncated window
|
||||
# without anything reporting that it had. Every carried hour is
|
||||
# provisional, so refusal 3 drops it from the trade set: CARRYING a
|
||||
# price widens what the optimiser can SEE, never what it may act on.
|
||||
#
|
||||
# ⚠️ AND THE CAVEAT THAT BELONGS WITH THAT SENTENCE, because this is
|
||||
# the one place a maintainer reads it on its own. THE OTHER HALF OF
|
||||
# THIS CHANGE DOES MOVE ENERGY, and it is the windowing rather than the
|
||||
# carrying: the filled series covers `[since, until)`, so hours that
|
||||
# have ALREADY ELAPSED are no longer in the spread. A cheap night that
|
||||
# has gone used to sit in the series as the day's cheapest hour, and no
|
||||
# cycle can buy in an hour that is past - so the trade was quietly
|
||||
# never taken. `resolve_tariff()`'s docstring, DOCS.md and the CHANGELOG
|
||||
# all carry this beside the guarantee; it may not appear here without it.
|
||||
#
|
||||
# ⚠️ A BROKEN OR ABSENT STORE IS NOT AN ERROR HERE. It answers `no_data`,
|
||||
# the fill carries nothing, and the plan is the pre-FCST-04 one minus
|
||||
# the elapsed hours - short, but honest and still refusing on the same
|
||||
# rules. It must not be an error in the other sense either: a day
|
||||
# record this build cannot read is DROPPED by `PriceHistory._load()`
|
||||
# and reported in `.words`, because both the constructor above and the
|
||||
# `.words` read below are unguarded, and a cache that raises here is an
|
||||
# add-on that does not start.
|
||||
self.price_history = PriceHistory(price_history_path)
|
||||
_LOG.info("§8.3 price history: %s", self.price_history.words)
|
||||
|
||||
# SAFETY-07. The guard TARIFF-01 was built for. It is stateless by
|
||||
# design (peakguard.py holds no history between calls), so the only
|
||||
# state here is what an EDGE needs: what the last cycle concluded, so
|
||||
@@ -3455,7 +3493,8 @@ class Controller:
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
plan = await resolve_tariff(
|
||||
self.o, now, now + timedelta(hours=horizon), now=now)
|
||||
self.o, now, now + timedelta(hours=horizon), now=now,
|
||||
history=self.price_history)
|
||||
self.tariff_plan, self.tariff_at = plan, now
|
||||
_LOG.info("tariff refreshed: %s", plan.words)
|
||||
except Exception as err: # noqa: BLE001
|
||||
@@ -4304,6 +4343,13 @@ class Controller:
|
||||
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||
("Cheap window", "yes" if self.cheap else "no"),
|
||||
("Tariff", self.tariff_words),
|
||||
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
|
||||
# between a 48-hour plan and a 12-hour one on a weekday morning.
|
||||
# It is its own row rather than a clause of "Tariff" because it
|
||||
# answers a different question: "Tariff" says what the plan IS, and
|
||||
# this says what the plan could still be filled from if the feed is
|
||||
# late again tomorrow.
|
||||
("Price history", self.price_history.words),
|
||||
# OPT-04. The strategy's own sentence, not a re-derivation of it -
|
||||
# every refusal it can make already carries a reason a human can
|
||||
# act on, and "Arbitrage: no" would throw all of them away.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,12 +48,24 @@ So the flag is not passed through anywhere in this file:
|
||||
the object the provider produced is the object in the output series - so
|
||||
there is no copy that could lose or gain a flag. `filled.points[i] is
|
||||
fetched.points[j]` is a checkable property, and test_price_forecast.py
|
||||
checks it.
|
||||
checks it. EVERY published point in the window is passed through, not one
|
||||
per hour: see the sub-hourly note below.
|
||||
* `_audit()` RE-DERIVES the expected flag for every output point from the set
|
||||
of hours the feed actually published, and RAISES if any point disagrees.
|
||||
It is a post-condition, not a filter: it never fixes a flag, because a
|
||||
module that quietly corrects its own provenance is one whose provenance
|
||||
cannot be trusted.
|
||||
|
||||
⚠️ IT GRADES THE POINTS THAT ARE EMITTED AND NOTHING ABOUT THE ONES THAT
|
||||
ARE NOT (FCST-05). `_audit()` is a provenance check, NOT a completeness
|
||||
check: fed FCST-04's own decimation defect - four published points in an
|
||||
hour, only the last emitted - it ACCEPTS, and it accepts an empty output
|
||||
too. Measured, and the FCST-03 audit it replaced was equally blind, so
|
||||
this is a limit rather than a regression. What actually guards against a
|
||||
decimated series is the point COUNT and the pass-through IDENTITY checks
|
||||
in `test_price_forecast.py` (section on the quarter-hourly feed) and
|
||||
`test_price_history.py`; see `_audit()`'s own docstring for why the
|
||||
completeness rule is not expressible from its two arguments.
|
||||
* and test_price_forecast.py walks THIS FILE'S AST and fails if any
|
||||
`PricePoint(...)` in it passes anything but a literal for `provisional`, so
|
||||
the pass-through idiom cannot come back in a later edit and pass review by
|
||||
@@ -90,6 +102,24 @@ which is the row the spec actually writes for "no price data". If NOTHING can
|
||||
be produced, the result is `PriceRead.no_data(reason)` and not an empty series,
|
||||
because an empty series is not constructible on purpose (price.py trap 3).
|
||||
|
||||
⚠️ A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR, and the hourly walk
|
||||
below is about WHICH HOURS NEED FILLING - never about what a published hour is
|
||||
allowed to contain. `eneco_eddy` is the Belgian imbalance product and settles
|
||||
per QUARTER, `_validate_raw()` accepts any strictly-increasing spacing, and a
|
||||
published hour may therefore hold four points. So EVERY published point inside
|
||||
the window is passed through, in order, and a carried point is added only for a
|
||||
whole hour with NO published coverage at all.
|
||||
|
||||
The alternative was measured rather than reasoned about, because FCST-04's
|
||||
first caller shipped it: keying the published set as `{_utc_hour(p.start): p
|
||||
for p in r.points}` reads perfectly ordinary and keeps the LAST point of each
|
||||
hour, dropping the other three. On a 96-point quarter-hourly feed - all firm,
|
||||
fully published, nothing to carry - the series came out 15 points long, the
|
||||
arbitrage spread collapsed from 0.143 to 0.014 EUR/kWh, and the cheapest
|
||||
"hour" became a `:45` point whose `at()` window runs into the next hour. A feed
|
||||
losing 84 % of its own prints is not a windowing effect and is not something a
|
||||
provisional flag would ever have reported.
|
||||
|
||||
|
||||
AC2: THE IRREVERSIBLE-ACTION GATE
|
||||
=================================
|
||||
@@ -156,6 +186,25 @@ def _utc_hour(instant: datetime) -> datetime:
|
||||
return u.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _published_by_hour(read: PriceRead) -> dict:
|
||||
"""UTC hour -> the provider's OWN points that start inside it, in order.
|
||||
|
||||
⚠️ A TUPLE PER HOUR, NOT A POINT, AND THAT IS THE WHOLE OF THE SUB-HOURLY
|
||||
RULE. `{_utc_hour(p.start): p for p in r.points}` reads identically, is one
|
||||
character shorter, and silently keeps one point per hour - the last one
|
||||
written. The mapping answers two DIFFERENT questions here: "does this hour
|
||||
need filling at all?" is `u in published`, and "what does the output carry
|
||||
for it?" is all of `published[u]`. Collapsing them is how a quarter-hourly
|
||||
feed loses three quarters of its prints.
|
||||
"""
|
||||
out: dict = {}
|
||||
read.match(
|
||||
series=lambda r: [out.setdefault(_utc_hour(p.start), []).append(p)
|
||||
for p in r.points],
|
||||
no_data=lambda _r: [])
|
||||
return {u: tuple(ps) for u, ps in out.items()}
|
||||
|
||||
|
||||
def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
|
||||
"""A point filled from yesterday's profile. §8.3: ALWAYS provisional.
|
||||
|
||||
@@ -172,37 +221,122 @@ def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
|
||||
def _audit(points, published) -> None:
|
||||
"""Post-condition: every flag matches the point's TRUE provenance (AC1).
|
||||
|
||||
`published` maps a UTC hour to the point the provider produced for it.
|
||||
`published` maps a UTC hour to the TUPLE of points the provider produced
|
||||
for it (`_published_by_hour()`). An hour ABSENT from that mapping is an hour
|
||||
the feed did not print, and a carried point is the only thing that may
|
||||
stand in it.
|
||||
|
||||
The rule, in the two forms an output point can take:
|
||||
|
||||
* PUBLISHED - the point must BE one of `published[u]`, by identity, and
|
||||
may be emitted only once. Its flag is whatever the feed said, never
|
||||
forced to False: an imbalance print is published AND provisional (§9.2,
|
||||
TARIFF-02), and a check that "corrected" it would be a bug wearing a
|
||||
guarantee.
|
||||
* CARRIED - the hour must not appear in `published` AT ALL, the flag must
|
||||
be exactly True, and that hour may hold no second point.
|
||||
|
||||
⚠️ WHAT REPLACED "AT MOST ONE POINT PER HOUR", AND WHAT THAT DID AND DID
|
||||
NOT CHANGE. This audit began as FCST-03's `seen` set: any repeated
|
||||
`_utc_hour` raised, beside an identity rule `p is not src`. A bare hour
|
||||
count cannot express a quarter-hourly feed, where four distinct published
|
||||
points share one hour legitimately - so the count was replaced by the two
|
||||
rules above, which subsume it.
|
||||
|
||||
⚠️ AND IT IS NOT STRICTER. FCST-04's version of this paragraph claimed two
|
||||
strengthenings and BOTH WERE FALSE; FCST-05 measured it. A differential
|
||||
over the whole domain this audit can express - every published mask over 3
|
||||
hours crossed with every output series of length 0-3 drawn from
|
||||
{published, published-provisional, carried, carried-unflagged,
|
||||
copy-of-published} - found ZERO divergence in EITHER direction:
|
||||
|
||||
OLD raised, NEW accepted (WEAKER): 0
|
||||
OLD accepted, NEW raised (STRONGER): 0
|
||||
|
||||
The two specific claims, one at a time:
|
||||
|
||||
* "so does the same published point emitted twice" - the OLD `seen`
|
||||
count already raised on that, because emitting one point twice is one
|
||||
HOUR twice. Preserved by the identity/`emitted` rules, not added.
|
||||
* "a carried point may no longer share an hour with a published one,
|
||||
which the count never checked at all" - true of the COUNT, and the old
|
||||
IDENTITY rule raised on it anyway: a carried point in a published hour
|
||||
took the `else` branch and failed `p is not src`. The message was about
|
||||
a copy rather than about a carry, which is the only thing that moved.
|
||||
|
||||
The one real behavioural change is the intended RELAXATION - several
|
||||
distinct published points in one hour, which the old audit could not
|
||||
express at all and refused. That relaxation is why this was rewritten, and
|
||||
it stays. A right decision with a fabricated reason is still a defect here.
|
||||
|
||||
⚠️ AND THE REAL LIMIT, WHICH IS NOT A REGRESSION AND IS NOT SMALL: THIS
|
||||
AUDIT DOES NOT CHECK COMPLETENESS. It grades every point that IS emitted
|
||||
and says nothing about the ones that are not. Fed the H1 decimation
|
||||
directly - four published points in an hour, output emitting only the last
|
||||
- it ACCEPTS; so does two of four, and so does an EMPTY output. So `_audit`
|
||||
is NOT what stops H1 recurring. What stops it is the point COUNT and the
|
||||
IDENTITY checks in `test_price_history.py` and `test_price_forecast.py`,
|
||||
which compare the emitted series against the feed's own prints.
|
||||
|
||||
It is not made complete here, and the reason is structural rather than
|
||||
effort: `published` is `_published_by_hour(read)` over the WHOLE read,
|
||||
unwindowed, while `fill_from_yesterday()` emits only [since, until). A
|
||||
published point outside the window is legitimately absent, so "every
|
||||
published point appears" is not a true post-condition of these two
|
||||
arguments - it would need the window passed in, which is a different
|
||||
signature and a different check.
|
||||
|
||||
⚠️ AND THAT IS MEASURED, NOT ASSUMED. FCST-05 built the rule - raise unless
|
||||
every point of every `published[u]` is emitted, +162 B - and ran it. It
|
||||
takes the 1 h, 12 h and 48 h HORIZON checks in test_price_forecast.py RED,
|
||||
on the shipped fill and a healthy feed: at an off-the-hour instant the
|
||||
feed's already-elapsed prints are outside [since, until) and are correctly
|
||||
dropped, and the rule calls that a provenance failure. (The differential
|
||||
beside those checks scores the same rule as 49 STRONGER cases, so it is a
|
||||
real change of behaviour and not a no-op.) The rule as stated is therefore
|
||||
not merely unimplemented, it is WRONG - and stating a false post-condition
|
||||
is MUTATION.md habit 16's defect: an instrument that asserts a completeness
|
||||
it does not have is worse than no instrument.
|
||||
|
||||
⚠️ RAISES, NEVER REPAIRS. A module that silently corrects its own
|
||||
provenance is one whose provenance means nothing; the only safe response to
|
||||
"this point's flag disagrees with where it came from" is to refuse to emit
|
||||
the series at all.
|
||||
"""
|
||||
seen = set()
|
||||
carried_hours = set()
|
||||
emitted = set()
|
||||
for p in points:
|
||||
u = _utc_hour(p.start)
|
||||
if u in seen:
|
||||
raise ValueError(f"the filled series repeats {u.isoformat()}; a "
|
||||
f"duplicated hour is how a DST day gets mispriced")
|
||||
seen.add(u)
|
||||
src = published.get(u)
|
||||
prov = Provenance.CARRIED if src is None else Provenance.PUBLISHED
|
||||
if src is None:
|
||||
if u in carried_hours:
|
||||
raise ValueError(
|
||||
f"the filled series repeats the carried hour "
|
||||
f"{u.isoformat()}; a duplicated hour is how a DST day gets "
|
||||
f"mispriced")
|
||||
carried_hours.add(u)
|
||||
if p.provisional is not True:
|
||||
raise ValueError(
|
||||
f"{u.isoformat()} is {prov.value} and is flagged "
|
||||
f"provisional={p.provisional!r}. §8.3: an hour filled from "
|
||||
f"yesterday's profile is provisional whether or not the "
|
||||
f"number turns out to be right - the flag is a claim about "
|
||||
f"PROVENANCE, not about accuracy.")
|
||||
f"{u.isoformat()} is {Provenance.CARRIED.value} and is "
|
||||
f"flagged provisional={p.provisional!r}. §8.3: an hour "
|
||||
f"filled from yesterday's profile is provisional whether "
|
||||
f"or not the number turns out to be right - the flag is a "
|
||||
f"claim about PROVENANCE, not about accuracy.")
|
||||
else:
|
||||
if p is not src:
|
||||
if not any(p is q for q in src):
|
||||
raise ValueError(
|
||||
f"{u.isoformat()} is {prov.value} but the series carries a "
|
||||
f"COPY of that point rather than the point itself. A copy "
|
||||
f"is where the provisional flag gets lost; published points "
|
||||
f"pass through by identity.")
|
||||
f"{u.isoformat()} is {Provenance.PUBLISHED.value} but the "
|
||||
f"series carries a COPY of that point rather than the "
|
||||
f"point itself. A copy is where the provisional flag gets "
|
||||
f"lost; published points pass through by identity.")
|
||||
if id(p) in emitted:
|
||||
raise ValueError(
|
||||
f"the filled series emits the published point at "
|
||||
f"{p.start.isoformat()} TWICE. The provider's own points "
|
||||
f"are strictly increasing (`_validate_raw`), so a repeat "
|
||||
f"is never a second print - it is a duplicated hour, which "
|
||||
f"is how a DST day gets mispriced")
|
||||
emitted.add(id(p))
|
||||
|
||||
|
||||
def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
@@ -217,11 +351,21 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
|
||||
Returns a `PriceRead`:
|
||||
|
||||
* a series, whose points are the provider's own objects for the hours it
|
||||
published and `_carried()` points for the rest, ordered by instant;
|
||||
* a series, carrying the provider's own objects - ALL of them, which on
|
||||
a quarter-hourly feed is four inside one hour - for the hours it
|
||||
published, and one `_carried()` point for each remaining whole hour,
|
||||
ordered by instant;
|
||||
* `no_data` when neither source can produce a single hour - never an
|
||||
empty series, which is not constructible (price.py trap 3).
|
||||
|
||||
⚠️ THE WINDOW IS WHOLE HOURS AT BOTH ENDS. `since` is floored to the hour
|
||||
that CONTAINS it, because the current hour is still a tradeable hour; and
|
||||
so is `until`, which is the half FCST-04's first draft left out. With
|
||||
`since` floored and `until` compared raw, a 48 h horizon asked for at 08:17
|
||||
emitted 49 hours - the hour of `until` was still `< until` - and the count
|
||||
was one too many for every `now` that is not exactly on the hour, which is
|
||||
every `now` a running controller has.
|
||||
|
||||
⚠️ IDEMPOTENT AND RE-RUNNABLE, WHICH IS THE §5.4 LATE-PUBLICATION CONTRACT.
|
||||
Day-ahead publication at 14-16 h CET can be late or fail, so the fetcher
|
||||
retries and this runs again against the fresher read. Published hours always
|
||||
@@ -234,9 +378,7 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
raise ValueError(f"{name} must be tz-aware; a naive instant is "
|
||||
f"ambiguous for one hour every October")
|
||||
|
||||
published = read.match(
|
||||
series=lambda r: {_utc_hour(p.start): p for p in r.points},
|
||||
no_data=lambda _r: {})
|
||||
published = _published_by_hour(read)
|
||||
|
||||
# Yesterday's profile, keyed by BRUSSELS WALL-CLOCK HOUR. First point wins
|
||||
# when yesterday itself was a 25-hour day and printed local hour 2 twice.
|
||||
@@ -248,11 +390,12 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
|
||||
out = []
|
||||
u = _utc_hour(since)
|
||||
end = until.astimezone(timezone.utc)
|
||||
end = _utc_hour(until)
|
||||
while u < end:
|
||||
if u in published:
|
||||
out.append(published[u]) # BY IDENTITY. No copy.
|
||||
else:
|
||||
got = published.get(u)
|
||||
if got:
|
||||
out.extend(got) # BY IDENTITY, and ALL of them. No copy,
|
||||
else: # and no decimation to one an hour.
|
||||
src = by_local_hour.get(brussels(u).hour)
|
||||
if src is not None:
|
||||
out.append(_carried(u, src.eur_per_kwh))
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
"""FCST-04 - the day the carry-forward carries, kept somewhere a restart cannot reach.
|
||||
|
||||
WHAT WAS MISSING, AND WHY IT WAS INVISIBLE. FCST-03 shipped
|
||||
`price_forecast.fill_from_yesterday()` complete and checked, and shipped the
|
||||
consumers that were written to expect it - `TariffPlan.firm_points()`,
|
||||
`cheap_now()`'s refusal of a provisional hour, and `arbitrage()`'s refusal 3,
|
||||
which drops provisional hours from the trade set instead of refusing the whole
|
||||
series. Every one of those was live. The FILL ITSELF HAD NO CALLER: nothing in
|
||||
`app/` referenced `fill_from_yesterday`, so no series ever contained a carried
|
||||
point and every one of those consumers was exercising its all-firm branch.
|
||||
|
||||
The defect that produced is not an error anywhere. `load_prices()` returns
|
||||
whatever the feed published and nothing widens it to the requested horizon, so
|
||||
on a dynamic site between midnight and the 14-16 h CET day-ahead publication
|
||||
the series covers the rest of TODAY and nothing more - typically 10-24 of the
|
||||
48 hours `price_horizon_h` asked for. `TariffPlan.arbitrage()` then computes
|
||||
`max - min` over that truncated window and reports a spread. The number is
|
||||
real, the window is half the one the operator configured, and NOTHING SAYS SO:
|
||||
tomorrow evening's peak - the hour a morning charge would be sold into - is
|
||||
simply not in the series, so the spread it would have created cannot clear a
|
||||
threshold it was never compared against. A missing hour is not a wrong price;
|
||||
it is an hour the optimiser never learns exists.
|
||||
|
||||
So this module is the half FCST-03 left out, and it is deliberately the SMALL
|
||||
half: the fill's arithmetic, its DST rule and its provenance audit all stay in
|
||||
`price_forecast.py`, untouched. What lives here is the one thing that
|
||||
arithmetic needs and could not have: A YESTERDAY THAT SURVIVES A RESTART.
|
||||
|
||||
|
||||
WHY PERSISTENCE, AND NOT A VARIABLE ON THE REFRESH TASK
|
||||
======================================================
|
||||
An in-memory "the last full day I saw" costs nothing and is wrong exactly when
|
||||
it matters. The window the fill exists for is midnight -> publication; an
|
||||
add-on restarted at 08:00 - a config change, an HA update, a power cut - would
|
||||
come up with no yesterday at all and spend that whole window on the truncated
|
||||
series, which is the pre-FCST-03 behaviour arriving silently on the mornings
|
||||
after an operator touched something. The fill's own value is a function of
|
||||
surviving a restart, so the day it carries has to.
|
||||
|
||||
⚠️ AND NOT IN `/data/state.json`. That file holds the maintenance history and
|
||||
SAFETY-02's sign latch, and `Store.set()` rewrites the whole of it on every
|
||||
write. The tariff refresh runs every `TARIFF_REFRESH_S` (1800 s), so folding a
|
||||
price cache into it would put 48 extra whole-file rewrites a day through the
|
||||
file whose corruption reads as "no maintenance ever ran" and "never tripped".
|
||||
The cache is worth having; it is not worth putting anywhere near that. Separate
|
||||
file, separate blast radius.
|
||||
|
||||
⚠️ AND THE DIRECTORY IS NOT CREATED, which is `tariff.PeakStore`'s rule and its
|
||||
reason applies unchanged: `/data` is mounted by the Supervisor and always
|
||||
exists on a real install, so `makedirs` here could only ever succeed where the
|
||||
durable volume is MISSING - manufacturing a cache on the container's ephemeral
|
||||
layer that writes fine, reads fine, and is thrown away on the next restart.
|
||||
That is a yesterday that silently forgets, which is worse than one that says it
|
||||
cannot answer.
|
||||
|
||||
⚠️ AND THAT RULE IS CARRIED OUT IN `_remember()`, NOT ONLY IN `_save()`. FCST-04
|
||||
first shipped it half-done: `_save()` short-circuited on `not self.ok` while
|
||||
`_remember()` filled `self._days` anyway, so a store with no durable volume
|
||||
answered `words` = "unavailable" and `yesterday()` = A SERIES - an in-memory
|
||||
yesterday that works all day and is gone on the next restart, which is exactly
|
||||
the silently-forgetting cache this rule exists to refuse. A store that cannot
|
||||
persist now records nothing, and both of its answers say the same thing.
|
||||
|
||||
⚠️ AND "CANNOT PERSIST" IS ONE QUESTION, ASKED ONCE, ABOUT THE OUTCOME -
|
||||
FCST-06. The paragraph above enforced that rule against `not self.ok`, which is
|
||||
one SYMPTOM of not persisting: the durable volume is missing. There is a second,
|
||||
and FCST-06 measured it. With `ok=True` and the directory present, `_save()` can
|
||||
still fail - a read-only remount (`[Errno 30] Read-only file system`), a full
|
||||
volume (`[Errno 28] No space left on device`) - and it logs, records
|
||||
`last_error` and returns. `_remember()` had already populated `self._days`, so
|
||||
`words` read `'2026-01-14 (24 points over 24 h); 2026-01-15 (24 points over
|
||||
24 h) [could not write (...)]'` while the file on disk held `{}`, and
|
||||
`yesterday()` answered 24 REAL POINTS for the rest of the process's life. Gone
|
||||
on the next restart, and nothing said it would be - the same silently-forgetting
|
||||
cache, reached by the condition the rule did not name.
|
||||
|
||||
⚠️ SO IT IS NOT TWO RULES, AND IT IS DELIBERATELY NOT TWO TESTS. "Can this
|
||||
store persist?" CANNOT BE ANSWERED BEFORE THE ATTEMPT - a directory that existed
|
||||
and was writable a moment ago is not a write that completed - so it is answered
|
||||
AFTER it, once, by the only thing holding the evidence: `_save()` returns
|
||||
whether the file now holds what memory holds. A missing volume is one of its
|
||||
False cases and an `OSError` from the write is another, and `_remember()` KEEPS
|
||||
ONLY WHAT THAT ONE ANSWER SAYS WAS KEPT. There is no `not self.ok` test in
|
||||
`_remember()` any more, because there is nothing left for a second test to add -
|
||||
and a third symptom nobody has thought of is how this defect arrived both times.
|
||||
|
||||
⚠️ AND WHAT IS KEPT IS THE LAST STATE THAT IS ON DISK, NOT AN EMPTY ONE. The
|
||||
MERGE is undone, not the store. A site whose volume fills at 14:00 goes on
|
||||
answering the yesterday it wrote at 13:30 - that record survives a restart, so
|
||||
answering it is not a promise the store cannot keep. What it stops doing is
|
||||
adding to it. The invariant the rule was always describing, stated once:
|
||||
`self._days` NEVER HOLDS A POINT THE FILE DOES NOT. (Not "mirrors the file" -
|
||||
`_usable_days()` drops records the file still holds. The only direction that can
|
||||
invent a yesterday is the one named.)
|
||||
|
||||
|
||||
THE FOUR RULES THIS FILE IS SHAPED BY
|
||||
=====================================
|
||||
|
||||
1. IT RECORDS WHAT THE PROVIDER PUBLISHED, NEVER WHAT THE FILL PRODUCED.
|
||||
Feeding a filled series back in is the defect that makes the whole mechanism
|
||||
rot: day 2 carries day 1's guesses, day 3 carries day 2's carry of them, and
|
||||
after a week of a dead feed the "yesterday" being carried forward is a
|
||||
fossil of the last real publication with nothing anywhere reporting its age.
|
||||
Every point still carries `provisional=True`, so the flag never lies - what
|
||||
decays is the NUMBER, silently, behind a flag that is telling the truth.
|
||||
|
||||
⚠️ NO VALUE-LEVEL CHECK CAN CATCH THAT, and saying so is more useful than a
|
||||
guarantee that would not hold. A carried point and a published-provisional
|
||||
point (`eneco_eddy`'s current quarter, §9.2) are the same three fields with
|
||||
the same flag; the difference is provenance, and provenance is not in the
|
||||
value. So the mechanism is ORDERING, not validation: `carry()` is the only
|
||||
public entry point, it remembers the argument and returns the fill, and the
|
||||
filled series exists only as a return value that `carry()` itself never
|
||||
sees again. `_remember()` is private, is called from exactly one place, and
|
||||
`test_price_history.py` PARSES every shipped module under `app/`, `tools/`,
|
||||
`scenarios/` and `estop/` and fails if any of them uses the NAME - an
|
||||
attribute, a bare name, a def, or the string constant a `getattr` needs. By
|
||||
name rather than by substring, so a comment about the rule does not fail it
|
||||
and a call from `tools/` cannot pass unseen.
|
||||
|
||||
⚠️ AND THE ORDER IS GRADED BY DRIVING IT, NOT BY READING THE SOURCE. The
|
||||
first version of that check compared positions in `ast.walk`, which is
|
||||
breadth-first: `self._remember(...)` is a statement and `self.yesterday(...)`
|
||||
is an ARGUMENT of the fill, one level deeper, so `_remember` came out first
|
||||
whichever statement was written first - shipped and reversed scored the
|
||||
same. What grades it now is the behaviour: a feed carrying yesterday
|
||||
alongside today, into an EMPTY store, must fill the whole horizon, and
|
||||
reversed it fills nothing.
|
||||
|
||||
2. "YESTERDAY" MEANS YESTERDAY, NOT "THE NEWEST DAY I HAVE".
|
||||
`newest()` is the natural accessor and it is the one that decays: an add-on
|
||||
down from Friday to Monday would answer Friday's prices to a fill that flags
|
||||
them as one day old. The lookup is BY DATE - the Brussels day before the
|
||||
Brussels day of the instant asked about - and a record for any other date is
|
||||
NO_RECORD with a reason naming both dates. A three-day-old profile is not a
|
||||
worse yesterday; it is not a yesterday.
|
||||
|
||||
3. A STORE THAT CANNOT ANSWER SAYS SO, IN THE TYPE.
|
||||
`yesterday()` returns a `PriceRead`: a `series` when a full record exists,
|
||||
`no_data(reason)` for every other outcome - no file, no record for that
|
||||
date, an unreadable file, a record that parses to nothing. There is no
|
||||
empty-series answer and no `.get(..., [])`-shaped accessor, for
|
||||
`price.py`'s reason: an empty series is how "the cache is broken" gets to
|
||||
wear the shape of "yesterday was free". `fill_from_yesterday()` takes a
|
||||
`PriceRead` and handles `no_data` on its own, so the degraded case needs no
|
||||
branch at the call site - it fills nothing and the horizon stays short,
|
||||
which is exactly the behaviour that shipped before this ticket.
|
||||
|
||||
4. HOURS ACCUMULATE WITHIN A DAY; THEY DO NOT REPLACE IT.
|
||||
A feed that serves only the REMAINING hours of the day (a legitimate shape -
|
||||
nothing in the plugin contract promises a whole day) would, under
|
||||
replace-the-record, overwrite a complete 24-hour profile with the two points
|
||||
left at 22:00 and hand the next morning a yesterday with two hours in it. So
|
||||
a record is MERGED by instant: a later read updates the hours it carries and
|
||||
leaves the rest, and a revised price for an hour already held wins because
|
||||
it is the newer statement about the same hour.
|
||||
|
||||
⚠️ KEYED BY INSTANT, NOT BY LOCAL HOUR, AND THAT IS THE DST DECISION. Storing
|
||||
`hour -> price` would collapse the two 02:00s of a 25-hour October day into
|
||||
one, inventing a second and different collapsing rule beside the one
|
||||
`fill_from_yesterday()` already documents ("the first of its two 02:00 points
|
||||
is the one used, deterministically"). Storing the instants keeps both, in
|
||||
order, and lets the fill's own rule be the only one in the system.
|
||||
|
||||
WHAT THIS DOES NOT DO, ON PURPOSE
|
||||
=================================
|
||||
It does not extend the fill to a feed that published NOTHING. `resolve_tariff()`
|
||||
applies `carry()` only on a `series` read; a `no_data` read still falls to
|
||||
`_degrade()` and §11.4's row - "No price data -> fall back to the static
|
||||
profile, mark the plan provisional, no arbitrage" - which is the fallback the
|
||||
operator configured `static_profile` FOR. Preferring yesterday's feed over it
|
||||
would change a documented degradation without being asked, and it would buy
|
||||
nothing measurable: a fully-carried series is 100 % provisional, so
|
||||
`arbitrage()` finds fewer than two firm hours and refuses, and `cheap_now()` is
|
||||
False, exactly as on the §11.4 fallback. The two differ only in which numbers
|
||||
the status page shows. `fill_from_yesterday()` handles a `no_data` read anyway,
|
||||
and that branch is therefore not reached from production - it stays checked,
|
||||
and turning it on is a product decision, not a wiring one. See
|
||||
DECISIONS/FCST-04-the-day-that-is-carried.md.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .price import PricePoint, PriceProduct, PriceRead, brussels
|
||||
from .price_forecast import fill_from_yesterday
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price.history")
|
||||
|
||||
# How many Brussels days of published prices the SORT-ORDER cap keeps. The fill
|
||||
# needs exactly one - yesterday - and rule 2 refuses anything older, so this is
|
||||
# not a horizon: it is the number of days a record may sit unused before
|
||||
# pruning removes it, and it is small because a bigger cache would only hold
|
||||
# days no lookup can ask for. Three covers today, yesterday, and the day a
|
||||
# fetch spanning midnight writes ahead of itself.
|
||||
#
|
||||
# ⚠️ IT IS A CAP, NOT THE RETENTION RULE, AND THREE HAS NO SLACK. The steady
|
||||
# state is already {yesterday, today, tomorrow}; a feed that publishes to D+2,
|
||||
# or ONE stray point in the wrong year - which sorts LAST and is therefore
|
||||
# immortal under a newest-N cap - pushes yesterday out of a three-slot store
|
||||
# and keeps it out, permanently and silently. So `_prune()` PINS the two days a
|
||||
# lookup can actually ask for on top of the cap; see its docstring. The store
|
||||
# is then bounded at KEEP_DAYS + 2 records rather than at KEEP_DAYS.
|
||||
KEEP_DAYS = 3
|
||||
|
||||
SCHEMA = 1
|
||||
|
||||
|
||||
def _bdate(instant: datetime) -> str:
|
||||
"""The Brussels calendar date of an instant, as `YYYY-MM-DD`.
|
||||
|
||||
⚠️ BRUSSELS, NOT UTC. 23:30 UTC in July is already tomorrow in Belgium, and
|
||||
a cache keyed on the UTC date would file the last 90-120 minutes of every
|
||||
day under the wrong one - so a lookup on the following morning would find a
|
||||
"yesterday" missing its own evening hours, which are the expensive ones.
|
||||
"""
|
||||
return brussels(instant).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _yesterday_of(instant: datetime) -> str:
|
||||
"""The Brussels date of the day BEFORE `instant`'s Brussels day.
|
||||
|
||||
⚠️ THE ARITHMETIC IS ON THE LOCAL DATE, NOT ON THE INSTANT, AND THAT IS A
|
||||
DST DEFECT THIS FILE SHIPPED. `brussels()` renders at the FIXED offset in
|
||||
force at that instant, so `brussels(stamp) - timedelta(days=1)` subtracts 24
|
||||
ABSOLUTE hours from a wall clock - and the Brussels day before a
|
||||
spring-forward is 23 hours long. Measured at 2026-03-30T00:30+02:00, the
|
||||
first hour of the morning after the March transition: it returned
|
||||
2026-03-28, so the fill carried a TWO-DAY-OLD profile flagged as yesterday's
|
||||
(or, with only the last three days held, answered `no_data` naming a date
|
||||
that is not yesterday).
|
||||
|
||||
A calendar day minus one day is `date` arithmetic, and a `date` has no
|
||||
offset to lose. The 25-hour October day is right for the same reason.
|
||||
"""
|
||||
return (brussels(instant).date() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _point_of(row):
|
||||
"""One stored row -> a `PricePoint`, or `None` if the row is not one.
|
||||
|
||||
THE ONE VALIDATOR, AND THE ASYMMETRY IT CLOSES (FCST-05's MEDIUM). This
|
||||
file used to hold TWO standards for what a stored point is. The READ path
|
||||
(`_parse_points()`) was properly defensive - every field checked, a
|
||||
malformed row dropped and reported. The WRITE path was
|
||||
`{q["start"]: q for q in raw if isinstance(q, dict) and "start" in q}`
|
||||
followed by `sorted(..., key=lambda q: q["start"])`, which trusts
|
||||
`q["start"]` to be a hashable, sortable string it never checked. Measured,
|
||||
with the malformed row under the day the feed is currently writing to:
|
||||
|
||||
start is an int -> TypeError: '<' not supported between instances
|
||||
of 'str' and 'int'
|
||||
start is a dict -> TypeError: cannot use 'dict' as a dict key
|
||||
start is null -> TypeError: '<' not supported between 'str' and
|
||||
'NoneType'
|
||||
start is a list -> TypeError: cannot use 'list' as a dict key
|
||||
|
||||
⚠️ AND THE BLAST RADIUS IS PERMANENT, WHICH IS WHY IT IS NOT MERELY UNTIDY.
|
||||
`_remember()` is reached through `carry()` -> `resolve_tariff()` ->
|
||||
`run_tariff_refresh`, which catches `Exception` - so the add-on stays UP
|
||||
and EVERY refresh raises forever. The plan retires after
|
||||
`TARIFF_MAX_AGE_S`, and arbitrage and the cheap window are off from then
|
||||
on. There is no self-heal, because `_remember()` raises before it reaches
|
||||
`_save()`. `_usable_days()` only required `rec["points"]` to be a LIST; the
|
||||
elements were unchecked on the write path, and the existing fixture put its
|
||||
malformed row under a day the feed was NOT writing to, so `_remember()`
|
||||
never touched it.
|
||||
|
||||
So both paths call this, and neither of them has a standard of its own.
|
||||
Returning `None` rather than raising is the contract: a caller decides
|
||||
whether a bad row is dropped (both of them do) and how it is reported.
|
||||
"""
|
||||
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
|
||||
is written this way because `words` is read from `Controller.__init__` and
|
||||
from every status render: a status LINE that raises takes the add-on down,
|
||||
and "the cache is broken" must never be able to mean "the controller does
|
||||
not start". That is exactly what FCST-04 shipped - `{"days": {"2026-01-14":
|
||||
"oops"}}` gave `ok=True`, `last_error=None`, and an `AttributeError` out of
|
||||
`words`, `yesterday()` and `resolve_tariff()` alike, with no self-heal
|
||||
because `_remember()` raised before it could rewrite the file.
|
||||
|
||||
⚠️ AND IT COUNTS ONLY ROWS THAT ARE POINTS. A row `_point_of()` refuses is
|
||||
not held prices, and a row count that included it would report a store as
|
||||
healthier than it is - which is the second half of FCST-05's MEDIUM: the
|
||||
status row said `'2026-01-15 (1 h)'` about a record whose single row was
|
||||
killing every tariff refresh. What is dropped is named in `last_error`, and
|
||||
`words` prints that beside the counts.
|
||||
"""
|
||||
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
|
||||
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:
|
||||
"""The published price series of the last few Brussels days, in /data.
|
||||
|
||||
⚠️ `ok` IS ABOUT THE FILE, NOT ABOUT THE DATA. A healthy store with no
|
||||
record for yesterday is `ok` and answers `no_data` - a normal state on a
|
||||
site's first day, and a different thing from a store that could not be
|
||||
opened. Both are visible: `ok`/`last_error` describe the file, and
|
||||
`yesterday()`'s reason describes the answer.
|
||||
|
||||
⚠️ AND `ok` IS NOT "CAN PERSIST" (FCST-06). It says the directory was
|
||||
there when the store opened, which a full volume and a read-only remount
|
||||
both leave true. The one thing that answers "can this store persist" is
|
||||
`_save()`'s return value, and `self._days` NEVER HOLDS A POINT THE FILE DOES
|
||||
NOT, because `_remember()` undoes a merge that answer refused. That is what
|
||||
makes `words` and `yesterday()` describe the same store in EVERY degraded
|
||||
state rather than only in the ones somebody enumerated.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str = "/data/price_history.json"):
|
||||
self.path = path
|
||||
self.ok = False
|
||||
self.last_error: str | None = None
|
||||
self._days: dict = {}
|
||||
self._open()
|
||||
|
||||
# -- the file ------------------------------------------------------------
|
||||
def _open(self) -> None:
|
||||
parent = os.path.dirname(self.path)
|
||||
# See the module note: NOT created. A cache on the ephemeral layer is
|
||||
# one that forgets without saying so.
|
||||
if parent and not os.path.isdir(parent):
|
||||
self.last_error = f"no durable volume at {parent}"
|
||||
# INFO, not ERROR: on a dev host or in a test container there is no
|
||||
# /data and nothing is wrong.
|
||||
_LOG.info("no price history: %s - §8.3 carry-forward has nothing "
|
||||
"to carry and the horizon stays as short as the feed",
|
||||
self.last_error)
|
||||
return
|
||||
self.ok = True
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
with open(self.path, "r", encoding="utf-8") as fh:
|
||||
blob = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
_LOG.info("no price history yet at %s - the first refresh writes "
|
||||
"one, and the fill has nothing to carry until the day "
|
||||
"after that", self.path)
|
||||
return
|
||||
except (ValueError, OSError) as err:
|
||||
# A corrupt cache must not stop the controller, and must not be
|
||||
# silently re-created either: the record is discarded, the reason
|
||||
# is kept, and `yesterday()` reports it rather than answering
|
||||
# "no record", which would read as a normal first day.
|
||||
#
|
||||
# ⚠️ `ValueError`, NOT `json.JSONDecodeError`, AND THAT IS
|
||||
# FCST-05's HIGH. Reading this file has two failure families: the
|
||||
# BYTES (`OSError`) and the CONTENT - and the content family has at
|
||||
# least two members, because `open(..., encoding="utf-8")` decodes
|
||||
# before `json` ever sees a character. `UnicodeDecodeError` is
|
||||
# `ValueError -> UnicodeError -> UnicodeDecodeError`; it is NOT a
|
||||
# `json.JSONDecodeError` and it is NOT an `OSError`
|
||||
# (`issubclass(UnicodeDecodeError, OSError)` is False). So ONE
|
||||
# non-UTF-8 byte in /data/price_history.json escaped `_load()` ->
|
||||
# `_open()` -> `PriceHistory.__init__`, which is unguarded at
|
||||
# main.py's `Controller.__init__`, AND THE ADD-ON DID NOT START.
|
||||
# Measured: `UnicodeDecodeError: 'utf-8' codec can't decode byte
|
||||
# 0xff in position 39`. Valid-UTF-8-invalid-JSON, a UTF-8 BOM and a
|
||||
# non-dict day record all degraded correctly - it was the one byte
|
||||
# class left uncovered, which is the same class as the defect the
|
||||
# `_usable_days()` rework was raised for.
|
||||
#
|
||||
# The tuple names the FAMILY rather than the two members known
|
||||
# today, because "the file's content is not readable as JSON" is
|
||||
# exactly what this handler says, and a third `ValueError` subclass
|
||||
# nobody has thought of is how this defect arrived the first time.
|
||||
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 "
|
||||
"next refresh has written a full day",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
found = blob.get("version") if isinstance(blob, dict) else None
|
||||
# ⚠️ THE TYPE IS PART OF THE VERSION. `1.0 == 1` in Python, so a float
|
||||
# written by some other tool would pass a bare `!=` and be read as
|
||||
# schema 1 - a version check that cannot tell 1 from 1.0 is one that
|
||||
# would not notice a writer this build has never met.
|
||||
if not isinstance(blob, dict) or isinstance(found, bool) \
|
||||
or not isinstance(found, int) or found != SCHEMA:
|
||||
self.last_error = f"schema {found!r}, not {SCHEMA}"
|
||||
_LOG.warning("price history at %s has %s - discarding it rather "
|
||||
"than guessing what its points mean",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
days = blob.get("days")
|
||||
if not isinstance(days, dict):
|
||||
self.last_error = "no `days` object"
|
||||
_LOG.warning("price history at %s has %s", self.path, self.last_error)
|
||||
return
|
||||
self._days = self._usable_days(days)
|
||||
|
||||
def _usable_days(self, days: dict) -> dict:
|
||||
"""The day records that are shaped like day records. The rest are dropped.
|
||||
|
||||
⚠️ THE ASYMMETRY THAT PRODUCED THE DEFECT: `_parse_points()` below is
|
||||
carefully defensive about every field of a POINT, and `_load()` was not
|
||||
defensive about the record holding them at all. `{"version": 1, "days":
|
||||
{"2026-01-14": "oops"}}` therefore loaded as `ok=True`,
|
||||
`last_error=None` - and then `rec.get(...)` raised `AttributeError` out
|
||||
of `words`, `yesterday()` and `resolve_tariff()`. `PriceHistory(...)`
|
||||
and `.words` are both unguarded in `Controller.__init__`, so THE ADD-ON
|
||||
DID NOT START; and it never self-healed, because `_remember()` raised on
|
||||
the same record before it could reach `_save()`.
|
||||
|
||||
A broken cache is a short horizon. It is not a controller that will not
|
||||
regulate a battery, and §6's rule - absent machinery must not disable
|
||||
the loop - does not stop applying because the machinery is a JSON file.
|
||||
|
||||
Dropped, not repaired: an unusable record has nothing in it to keep, and
|
||||
the next `_remember()` writes a fresh one under that date.
|
||||
|
||||
⚠️ AND THE ROWS INSIDE A KEPT RECORD ARE COUNTED, NOT ONLY THE RECORD
|
||||
(FCST-05). This checked that `rec["points"]` is a LIST and stopped
|
||||
there, so a record holding one row with a `start` of `7` loaded as
|
||||
healthy: `last_error=None`, and `words` said `'2026-01-15 (1 h)'` about
|
||||
a store whose next `_remember()` would raise on that row and go on
|
||||
raising forever. The rows are NOT dropped here - `_parse_points()` is
|
||||
the read path's one filter and stays so - but they are COUNTED, so the
|
||||
store stops reporting health it does not have from the moment it is
|
||||
opened. `_remember()` drops them on the next refresh and `_save()`
|
||||
rewrites the file, after which a restart clears the note.
|
||||
"""
|
||||
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 = "; ".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) -> 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 False
|
||||
tmp = None
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path))
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump({"version": SCHEMA, "days": self._days}, fh, indent=1)
|
||||
# Atomic: a power cut mid-write must not leave a truncated file
|
||||
# that parses as an empty day.
|
||||
os.replace(tmp, self.path)
|
||||
tmp = None # it IS the file now; do not unlink
|
||||
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,
|
||||
stamp: datetime) -> PriceRead:
|
||||
"""Remember what this read published, then §8.3-fill the missing hours.
|
||||
|
||||
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
|
||||
recorded FIRST, so a feed that hands back yesterday alongside today
|
||||
refreshes yesterday's record from live data before it is consumed as
|
||||
the profile; and the filled series - the one thing that must never be
|
||||
recorded - exists only as this method's return value, which this object
|
||||
never sees again. See rule 1, including how that order is graded.
|
||||
|
||||
Returns whatever `fill_from_yesterday()` returns: a series over the
|
||||
whole hours of [since, until), carrying EVERY point the provider
|
||||
published inside it - by identity, and four of them in one hour on a
|
||||
quarter-hourly feed - plus one carried point for each remaining whole
|
||||
hour FOR WHICH YESTERDAY HELD THAT WALL-CLOCK HOUR. An hour neither
|
||||
source can price is left out (§11.4 for that hour), and `no_data` comes
|
||||
back when that is every hour.
|
||||
"""
|
||||
self._remember(read, stamp)
|
||||
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
|
||||
since=since, until=until, stamp=stamp)
|
||||
|
||||
# -- the halves, private because rule 1 is an ordering ------------------
|
||||
def _remember(self, read: PriceRead, stamp: datetime) -> None:
|
||||
"""File a provider read's points under their own Brussels days.
|
||||
|
||||
⚠️ PRIVATE, AND CALLED FROM EXACTLY ONE PLACE. A caller that can reach
|
||||
this can record a filled series, which is the decay in rule 1. There is
|
||||
no value-level defence against that - a carried point and a published
|
||||
provisional point are the same three fields - so the defence is that
|
||||
nothing outside this file names it, and `test_price_history.py` checks
|
||||
the tree for that rather than trusting the underscore.
|
||||
"""
|
||||
points = read.match(series=lambda r: r.points, no_data=lambda _r: ())
|
||||
if not points:
|
||||
return
|
||||
product = read.match(series=lambda r: r.product.value,
|
||||
no_data=lambda _r: None)
|
||||
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(
|
||||
day, {"product": product, "provider": provider, "points": []})
|
||||
# Rule 4: MERGE by instant. A later read updates the hours it
|
||||
# carries and leaves the rest; a revised price for an hour already
|
||||
# held wins, because it is the newer statement about that hour.
|
||||
#
|
||||
# ⚠️ EVERY HELD ROW GOES THROUGH `_point_of()` (FCST-05's MEDIUM).
|
||||
# This was `{q["start"]: q for q in raw if isinstance(q, dict) and
|
||||
# "start" in q}` sorted by `q["start"]` - a key and a sort key that
|
||||
# trusted a value read off disk to be a hashable, sortable string.
|
||||
# An `int`, `null`, a `dict` or a `list` there raised `TypeError`
|
||||
# out of `carry()` on the day the feed was writing to, and
|
||||
# `run_tariff_refresh` caught it, so the add-on stayed up while
|
||||
# EVERY refresh failed for good. A row that is not a point is now
|
||||
# dropped and reported here, exactly as the read path drops it, and
|
||||
# `_save()` below writes the record back clean - which is the
|
||||
# self-heal the raising version could never reach.
|
||||
#
|
||||
# ⚠️ AND THE MERGE KEY IS THE VALIDATED INSTANT, NOT ITS SPELLING.
|
||||
# Aware datetimes hash and compare by instant, so this is rule 4 as
|
||||
# rule 4 is written. The old string key merged by SPELLING, and the
|
||||
# old lexicographic sort put the second 02:00 of the 25-hour
|
||||
# October day (`+01:00`) BEFORE the first (`+02:00`). Only the
|
||||
# stored order was affected - `_parse_points()` re-sorts by instant
|
||||
# on the way out - but the store now holds what it says it holds.
|
||||
raw = rec.get("points")
|
||||
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)
|
||||
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))
|
||||
|
||||
def _prune(self, stamp: datetime) -> None:
|
||||
"""Drop records no lookup can reach. A sort-order cap, plus two pins.
|
||||
|
||||
⚠️ NOTHING IS DELETED BY ARITHMETIC AGAINST THE CLOCK. A clock that
|
||||
jumped - the failure mode WITNESS-03 spent a session on - would
|
||||
otherwise delete every record in one pass and leave the fill with
|
||||
nothing on the morning after, which is the one morning it is for.
|
||||
Deleting on a bad clock is unrecoverable; holding a few days too many
|
||||
is not, so the cap is `sorted(...)[-KEEP_DAYS:]` and stays that way.
|
||||
|
||||
⚠️ AND A CAP ALONE IS DEFEATED BY ONE POINT IN THE WRONG YEAR. Sorting
|
||||
is lexicographic on the date string, so a stray far-future record sorts
|
||||
LAST and is immortal - it consumes a slot for good, and with the steady
|
||||
state already {yesterday, today, tomorrow} filling all three, yesterday
|
||||
is what falls out. A feed that publishes to D+2 does the same thing
|
||||
without any stray at all. So the two days `yesterday()` can ACTUALLY be
|
||||
asked for are pinned on top of the cap.
|
||||
|
||||
`stamp` is READ here, which it was not before: the pin is the only use
|
||||
of it, and it is additive. Pinning can only RETAIN a record, never
|
||||
delete one, so a jumped `stamp` pins two dates that are not held and
|
||||
changes nothing - the WITNESS-03 property is preserved by construction
|
||||
rather than by remembering to check it.
|
||||
"""
|
||||
if len(self._days) <= KEEP_DAYS:
|
||||
return
|
||||
keep = set(sorted(self._days)[-KEEP_DAYS:])
|
||||
keep |= {_bdate(stamp), _yesterday_of(stamp)}
|
||||
for day in [d for d in self._days if d not in keep]:
|
||||
del self._days[day]
|
||||
|
||||
def yesterday(self, stamp: datetime) -> PriceRead:
|
||||
"""The published series of the Brussels day BEFORE `stamp`'s, or no_data.
|
||||
|
||||
⚠️ THE DATE IS COMPUTED, NOT LOOKED UP. See rule 2: `newest()` would
|
||||
answer Friday's prices to a Monday fill, and the fill would flag them as
|
||||
one day old because that is the only thing its flag can say.
|
||||
"""
|
||||
if stamp.tzinfo is None or stamp.utcoffset() is None:
|
||||
raise ValueError(
|
||||
f"yesterday({stamp!r}): a naive datetime is not an instant, "
|
||||
f"and 'which Brussels day was yesterday' is exactly the "
|
||||
f"question a wall-clock reading cannot answer.")
|
||||
want = _yesterday_of(stamp)
|
||||
if self.last_error and not self._days:
|
||||
return PriceRead.no_data(
|
||||
f"the price history at {self.path} is {self.last_error}, so "
|
||||
f"§8.3 has no profile to carry forward for {want}; the plan "
|
||||
f"covers only the hours the feed published")
|
||||
rec = self._days.get(want)
|
||||
if rec is None:
|
||||
have = ", ".join(sorted(self._days)) or "no days at all"
|
||||
return PriceRead.no_data(
|
||||
f"no published prices recorded for {want} (held: {have}), so "
|
||||
f"§8.3 has no profile to carry forward. A record for another "
|
||||
f"day is not a yesterday")
|
||||
points, bad = _parse_points(rec.get("points"))
|
||||
if not points:
|
||||
why = bad or "it is empty"
|
||||
return PriceRead.no_data(
|
||||
f"the {want} record holds no usable point ({why}), and an "
|
||||
f"empty series is not a free day")
|
||||
try:
|
||||
product = PriceProduct(rec.get("product"))
|
||||
except ValueError:
|
||||
return PriceRead.no_data(
|
||||
f"the {want} record names product {rec.get('product')!r}, "
|
||||
f"which is not a PriceProduct. The optimiser switches on that "
|
||||
f"enum, so a record that cannot name one cannot be a series")
|
||||
if bad:
|
||||
_LOG.warning("price history: %s in the %s record were dropped; "
|
||||
"%d point(s) carried forward", bad, want, len(points))
|
||||
return PriceRead.series(points, product,
|
||||
str(rec.get("provider") or "price_history"),
|
||||
stamp)
|
||||
|
||||
# -- observability -------------------------------------------------------
|
||||
@property
|
||||
def words(self) -> str:
|
||||
"""One line for the status page. Says what is held, or why nothing is.
|
||||
|
||||
⚠️ THIS MUST NOT RAISE, EVER. It is read once from `Controller.__init__`
|
||||
and again on every status render, and neither call site guards it - so
|
||||
an exception here is an add-on that does not start. It went out that
|
||||
way: a day record that was a string instead of a dict raised
|
||||
`AttributeError` from the `len(...)` below. `_load()` now drops such a
|
||||
record and `_held()` is total, so this has two independent reasons not
|
||||
to fail on a broken file.
|
||||
|
||||
⚠️ AND IT COUNTED POINTS AND CALLED THEM HOURS (FCST-05's LOW). The row
|
||||
was `f"{d} ({_n_points(rec)} h)"`; on the quarter-hourly feed this
|
||||
add-on already supports that reads `'2026-01-13 (96 h)'` for a 24-hour
|
||||
day. Both numbers are printed now - see `_held()` - because they are
|
||||
two different facts and only one of them was ever being shown.
|
||||
"""
|
||||
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(_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.
|
||||
return held + (f" [{self.last_error}]" if self.last_error else "")
|
||||
|
||||
|
||||
def _parse_points(raw) -> tuple:
|
||||
"""Canonical dicts -> PricePoints, and a phrase naming what was dropped.
|
||||
|
||||
⚠️ A MALFORMED POINT IS DROPPED, NOT DEFAULTED, and the drop is reported.
|
||||
`float(row.get("eur_per_kwh", 0))` is the shape of the whole family of
|
||||
defects this repo has been removing: a cache that lost a field would answer
|
||||
"that hour was free" to a fill that flags it as an ordinary guess.
|
||||
"""
|
||||
if not isinstance(raw, list):
|
||||
return (), "the `points` field is not a list"
|
||||
out, dropped = [], 0
|
||||
for row in raw:
|
||||
# ⚠️ `_point_of()` IS THE ONE VALIDATOR, and this loop used to BE it.
|
||||
# The write path had its own, weaker standard; see `_point_of()` for
|
||||
# what that cost. One standard, two callers, no asymmetry to re-open.
|
||||
pt = _point_of(row)
|
||||
if pt is None:
|
||||
dropped += 1
|
||||
continue
|
||||
out.append(pt)
|
||||
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
|
||||
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
|
||||
|
||||
|
||||
class NoHistory:
|
||||
"""The explicit "this caller keeps no history" - a NAME, not a `None`.
|
||||
|
||||
⚠️ IT EXISTS SO THE PARAMETER CAN BE REQUIRED. `history=None` meaning "skip
|
||||
the fill" is the shape this repo keeps removing: a caller who forgets the
|
||||
argument gets the truncated-horizon behaviour of the bug this ticket is
|
||||
about, silently, and the code reads as though the fill is happening. With
|
||||
no default there is nothing to forget, and a caller with no durable volume
|
||||
- a check, a replay harness - has to say `NoHistory()` and be visible in
|
||||
the diff.
|
||||
|
||||
It carries out the ordering faithfully: it records nothing and carries
|
||||
nothing, so `carry()` returns the provider's read unchanged and the plan is
|
||||
exactly what it was before FCST-04.
|
||||
"""
|
||||
|
||||
ok = False
|
||||
last_error = "no history is kept by this caller"
|
||||
words = "not kept"
|
||||
|
||||
def carry(self, read: PriceRead, *, since, until, stamp) -> PriceRead:
|
||||
return read
|
||||
|
||||
def yesterday(self, stamp: datetime) -> PriceRead:
|
||||
return PriceRead.no_data(
|
||||
"this caller keeps no price history (NoHistory), so §8.3 has no "
|
||||
"profile to carry forward")
|
||||
@@ -1019,7 +1019,7 @@ def static_status(opts: dict, now: datetime):
|
||||
|
||||
|
||||
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
transport=None, now=None) -> TariffPlan:
|
||||
transport=None, now=None, *, history) -> TariffPlan:
|
||||
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
|
||||
|
||||
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
|
||||
@@ -1033,9 +1033,42 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
price_provider off + profile -> the CONFIGURED product (§9.2 rows 3-4)
|
||||
price_provider off + none -> no prices at all (§11.4, nothing to
|
||||
fall back to)
|
||||
provider series -> the feed (§9.2 rows 1-2)
|
||||
provider series -> the feed, §8.3-FILLED (§9.2 rows 1-2)
|
||||
provider no_data + profile -> §11.4 FALLBACK, provisional, no arbitrage
|
||||
provider no_data + none -> no prices at all
|
||||
|
||||
⚠️ `history` IS REQUIRED, KEYWORD-ONLY, AND HAS NO DEFAULT (FCST-04). It is
|
||||
the §8.3 carry-forward store - `price_history.PriceHistory`, or the explicit
|
||||
`price_history.NoHistory()` for a caller that keeps none. A default of
|
||||
`None` meaning "skip the fill" would give a caller who forgot the argument
|
||||
the TRUNCATED HORIZON this ticket exists to remove, silently, while the code
|
||||
read as though the fill were happening. There is nothing to forget now.
|
||||
|
||||
⚠️ AND THE FILL RUNS ONLY ON A `series` READ. A feed that published nothing
|
||||
still falls to `_degrade()` and §11.4's own row - the static profile the
|
||||
operator configured FOR that case - rather than to yesterday's feed. The two
|
||||
are indistinguishable to the optimiser (both are 100 % provisional, so
|
||||
`arbitrage()` finds fewer than two firm hours and refuses, and `cheap_now()`
|
||||
is False), so preferring yesterday would change a documented degradation for
|
||||
a difference only the status page can see. See price_history.py's closing
|
||||
note and DECISIONS/FCST-04-the-day-that-is-carried.md.
|
||||
|
||||
⚠️ THE FILL IS PER WHOLE HOUR AND ITS COVERAGE IS CONDITIONAL. Every point
|
||||
the provider published inside the window passes through BY IDENTITY - four
|
||||
of them in one hour on a quarter-hourly product like `eneco_eddy` - and a
|
||||
carried point is added only for a whole hour the feed did not print at all
|
||||
AND yesterday did. An hour neither source can price is left out, which is
|
||||
§11.4 for that hour: `plan.price_at()` raises rather than answering, and an
|
||||
hour with no price is not a free one.
|
||||
|
||||
⚠️ THE FILLED SERIES IS WINDOWED TO [since, until), AND THAT IS A CHANGE.
|
||||
`load_prices()` returns whatever the feed sent, past hours included, and
|
||||
`TariffPlan.arbitrage()` took its spread over all of it - so an hour that
|
||||
had already gone could be the cheapest hour of a cycle nothing can now buy
|
||||
in. `fill_from_yesterday()` walks the requested horizon and emits exactly
|
||||
that, so the spread is over hours that are still ahead. Both directions of
|
||||
the difference are real: the window gains the unpublished hours it was
|
||||
missing and loses the elapsed ones it should never have had.
|
||||
"""
|
||||
stamp = now or datetime.now(timezone.utc)
|
||||
pid = str(price_option(opts, "price_provider") or "off").strip()
|
||||
@@ -1044,6 +1077,20 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
|
||||
read = await load_prices(opts, since, until, transport)
|
||||
return read.match(
|
||||
series=lambda r: _provider_plan(opts, r, since, until, stamp, history),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
|
||||
|
||||
def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
|
||||
"""§8.3: record what the feed published, carry yesterday into the gaps.
|
||||
|
||||
The fill can itself return `no_data` - a feed whose points all fall outside
|
||||
the horizon, with nothing in the history to fill it from - and that is the
|
||||
§11.4 row arrived at one step later, so it degrades exactly as a failed
|
||||
fetch does rather than being special-cased here.
|
||||
"""
|
||||
filled = history.carry(read, since=since, until=until, stamp=stamp)
|
||||
return filled.match(
|
||||
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -272,16 +273,19 @@ check("the provenance audit REFUSES a carried hour flagged non-provisional - "
|
||||
"the 'stale but plausible' point, which is SAFETY-18's frozen-but-fresh "
|
||||
"meter one layer up",
|
||||
raises(lambda: _fc._audit([_stale_copy], {}), ValueError))
|
||||
# ⚠️ `published` MAPS AN HOUR TO A TUPLE, not to a point (FCST-04 H1). An hour
|
||||
# may legitimately hold four published points - see the quarter-hourly block
|
||||
# below - so the audit takes the whole set and checks membership by identity.
|
||||
check("...and refuses a published hour that arrived as a COPY rather than as "
|
||||
"the published object, which is where a flag gets lost",
|
||||
raises(lambda: _fc._audit(
|
||||
[PricePoint(start=TODAY_PART.points[0].start,
|
||||
eur_per_kwh=TODAY_PART.points[0].eur_per_kwh,
|
||||
provisional=TODAY_PART.points[0].provisional)],
|
||||
{D0: TODAY_PART.points[0]}), ValueError))
|
||||
{D0: (TODAY_PART.points[0],)}), ValueError))
|
||||
check("...and never repairs a flag: the audit's only outcomes are silence and "
|
||||
"an exception, so a series it passed was already correct",
|
||||
_fc._audit(list(FILLED.points), {u: TODAY_PART.points[i]
|
||||
_fc._audit(list(FILLED.points), {u: (TODAY_PART.points[i],)
|
||||
for i, u in enumerate(DAY[:PUBLISHED_N])})
|
||||
is None)
|
||||
|
||||
@@ -298,6 +302,231 @@ check("a PUBLISHED point that the feed itself flagged provisional keeps that "
|
||||
and all(p.provisional for p in _eddy_filled.points[:2])
|
||||
and _eddy_filled.points[0] is _eddy.points[0])
|
||||
|
||||
# ⚠️ AND A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR (FCST-04 H1).
|
||||
# `eneco_eddy` settles per QUARTER and `_validate_raw()` accepts any
|
||||
# strictly-increasing spacing, so a published hour can hold four points. The
|
||||
# fill walks WHOLE HOURS to decide what needs carrying; that must not become a
|
||||
# rule about what a published hour may contain. Measured on FCST-04's first
|
||||
# draft, which keyed the published set `{_utc_hour(p.start): p ...}`: a
|
||||
# 96-point feed came out 15 points long and its arbitrage spread fell from
|
||||
# 0.143 to 0.014 EUR/kWh, on an ALL-FIRM, fully-published series.
|
||||
_QN = 16 # four hours of quarters
|
||||
_QUARTERS = PriceRead.series(
|
||||
[PricePoint(start=brussels(D0 + timedelta(minutes=15 * n)),
|
||||
eur_per_kwh=round(0.100000 + 0.011300 * n, 6),
|
||||
provisional=False)
|
||||
for n in range(_QN)],
|
||||
PriceProduct.REALTIME, "eneco_eddy", D0)
|
||||
_QHOURS = {_fc._utc_hour(p.start) for p in _QUARTERS.points}
|
||||
check(f"the quarter-hourly fixture really is sub-hourly - {_QN} points across "
|
||||
f"{len(_QHOURS)} hours - or every check below passes for an hourly feed "
|
||||
f"too and grades nothing",
|
||||
len(_QUARTERS.points) == _QN and len(_QHOURS) == _QN // 4)
|
||||
_qfill = derived(lambda: fill_from_yesterday(_QUARTERS, yesterday=YESTERDAY,
|
||||
since=D0, until=D1, stamp=D0))
|
||||
_qfirm = derived(lambda: [p for p in _qfill.points if not p.provisional])
|
||||
check(f"every one of the {_QN} published points survives the fill BY IDENTITY "
|
||||
f"({len(_qfirm)} kept). One-per-hour keying would leave {_QN // 4}, and "
|
||||
f"the spread over the hours the feed actually published would collapse "
|
||||
f"with them",
|
||||
len(_qfirm) == _QN
|
||||
and all(any(p is q for q in _QUARTERS.points) for p in _qfirm))
|
||||
check(f"...and the {len(DAY) - len(_QHOURS)} whole hours it published NOTHING "
|
||||
f"for are carried, ONE point each: a carried hour is hourly whatever the "
|
||||
f"feed's spacing is, because yesterday's profile is only known per "
|
||||
f"wall-clock hour",
|
||||
_qfill.kind == "series"
|
||||
and len([p for p in _qfill.points if p.provisional])
|
||||
== len(DAY) - len(_QHOURS))
|
||||
check("...and the filled series is still ordered by instant across the two "
|
||||
"kinds, so `at()` and `window()` are not reading a shuffled list",
|
||||
_qfill.kind == "series"
|
||||
and [p.start.astimezone(UTC) for p in _qfill.points]
|
||||
== sorted(p.start.astimezone(UTC) for p in _qfill.points))
|
||||
|
||||
_PUB_Q = _fc._published_by_hour(_QUARTERS)
|
||||
# ⚠️ `derived`, NOT A RAW CALL. Under the decimating mutation this check
|
||||
# exists for, `_audit` RAISES here - and a harness that dies on the mutation it
|
||||
# is grading reports a traceback instead of a FAIL, which MUTATION.md forbids.
|
||||
check("the audit ACCEPTS four published points inside one hour, which is the "
|
||||
"case the repeated-hour COUNT it replaced could not express",
|
||||
derived(lambda: _fc._audit(list(_QUARTERS.points), _PUB_Q)) is None)
|
||||
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 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 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
|
||||
# the two implementations agree and the check would grade nothing.
|
||||
_off = D0 + timedelta(hours=8, minutes=17, seconds=43)
|
||||
for _h in (1, 12, 48):
|
||||
_n = derived(lambda h=_h: len(fill_from_yesterday(
|
||||
TODAY_PART, yesterday=YESTERDAY, since=_off,
|
||||
until=_off + timedelta(hours=h), stamp=_off).points))
|
||||
check(f"a {_h} h horizon asked for at {_off.isoformat()} - 17 min 43 s "
|
||||
f"past the hour, the shape a running controller always has - is "
|
||||
f"{_h} points long, not {_h + 1}",
|
||||
_n == _h)
|
||||
|
||||
# The structural half: the constructor has nothing to pass, and the file is
|
||||
# scanned so the pass-through idiom cannot come back looking ordinary.
|
||||
check("_carried() has NO `provisional` parameter, so a caller has nothing to "
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+287
-109
@@ -411,70 +411,91 @@ check("...and `phase` is NOT among them, because its value 'idle' IS the "
|
||||
"phase" not in _m9_vanished)
|
||||
shutil.rmtree(Path(_m9).parent, ignore_errors=True)
|
||||
|
||||
# ⚠️ AND `M12` BOUNDS THE **MOVED-NOT-LOST** RULE, WHICH NOTHING DID - AND ITS
|
||||
# FIRST DRAFT DID NOT EITHER, WHICH IS THE WORSE FAILURE OF THE TWO.
|
||||
# ⭐ `M12` IS BACK, AND THE CLAIM THAT KILLED IT WAS WRONG.
|
||||
#
|
||||
# The rule cited M3 as its bound, and the commit message cited it too. M3 drops
|
||||
# `sign_reason` from `LEGACY_UNIT_KEYS`; M9, the one actually meant, vanishes
|
||||
# only FLAT keys, so neither can satisfy the `unit/` gate the rule sits behind.
|
||||
# THE BRANCH HAD NO CONTROL AT ALL.
|
||||
# M12 is SAFETY-44's end-to-end control for the off-roster narrowing: a mutant
|
||||
# makes one ROSTER unit's latch key vanish while a sibling holds the same
|
||||
# value, and it must go red when the narrowing is removed. INSTR-01's first
|
||||
# draft DELETED it, on the claim that the provenance clause made it
|
||||
# unrestorable and that "NO FIXTURE CAN RESTORE IT, measured both ways".
|
||||
#
|
||||
# ⚠️ THE FIRST FIX FOR THAT WAS INERT, AND REVIEW MEASURED IT: run M12's mutant
|
||||
# over `state-legacy.json` and the findings diff against the UNMUTATED tree is
|
||||
# EMPTY. It "passed" because that fixture's STAGED leg is already exit 1 on
|
||||
# pre-existing litter, and `"migrated unit/shed/" not in out` was trivially
|
||||
# true because a pre-DEV-02 FLAT store has no shed latch to lose - after
|
||||
# migration only the FIRST roster entry is ever latched. A control written
|
||||
# against a fixture that cannot hold the failure is habit 4 wearing the clothes
|
||||
# of its own fix, and it is WORSE than the honest "no control" it replaced,
|
||||
# because it reads as checked.
|
||||
# ⚠️ TWO MEASUREMENTS ARE NOT AN IMPOSSIBILITY PROOF, AND THIS ONE WAS FALSE.
|
||||
# Both arrangements I measured were SAME-STEP shaped, and the structural
|
||||
# argument - `run()` builds both snapshots from one state file, so "already
|
||||
# latched" and "made by this transition" are incompatible - is TRUE OF
|
||||
# SAME-STEP AND FALSE OF STAGED. `snapshot_over()` starts the after-leg over
|
||||
# the file the BEFORE-leg wrote, so on the staged leg a transition-made
|
||||
# destination is available and the two questions stop being incompatible.
|
||||
# Review found the third arrangement I had generalised past.
|
||||
#
|
||||
# ⭐ SO THE FIXTURE CAME FIRST. `state-both-latched.json` is a store at
|
||||
# SCHEMA_VERSION with `unit/garage/*` AND `unit/shed/*` both latched, by ONE
|
||||
# site event so the reasons match too - the arrangement in which the
|
||||
# suppression is most dangerous, and the one no existing fixture could produce.
|
||||
# The mutant then makes ONE roster unit's latch key VANISH while its sibling's
|
||||
# survives at the same value.
|
||||
# HOW IT WORKS. The fixture is `state-legacy.json` plus a second roster unit
|
||||
# already latched. The mutant makes `shed` POP its own latch key and come up
|
||||
# untripped - a truer SAFETY-02 loss than the original, which left `tripped`
|
||||
# True in memory. On the staged leg, losing shed's latch makes
|
||||
# `_grade_store_migration()` fall through `on_roster_latched` and RE-ARM
|
||||
# `unit/garage/sign_tripped`, which is absent from `before.data` - so the
|
||||
# destination is transition-made and C7 lets it through. The off-roster
|
||||
# `inverter-a` litter is present in BOTH snapshots on this leg, so it never
|
||||
# vanishes and never claims the destination, and C6 stays out of the way.
|
||||
#
|
||||
# THE DECISIVE MEASUREMENT, which is what the previous draft could not make:
|
||||
# reverting the `vanished_unit not in after.units` narrowing - restoring exactly
|
||||
# the rule review proved hides a real loss - turns this RED. Before, it was
|
||||
# 28/28 green.
|
||||
# ⚠️ AND WHAT ACTUALLY BROKE WAS THE PRECONDITION, NOT THE CONTROL. M12's
|
||||
# `exit == EXIT_OK` baseline does not survive a legacy-shaped fixture. That
|
||||
# precondition is the one SAFETY-44's own commit called "belt-and-braces,
|
||||
# load-bearing only if the exit-0 requirement is ever relaxed", saying that
|
||||
# what closed the first draft's gap is `_m12_new` BEING A SET DIFFERENCE. A
|
||||
# stable dirty baseline is fine for a set difference. So the control was
|
||||
# deleted on the strength of a precondition the same commit had already
|
||||
# characterised as not load-bearing - which is a worse mistake than the
|
||||
# original inert check, because it removed coverage rather than failing to add
|
||||
# it.
|
||||
_M12_OLD = 'tripped=bool(data.get(self.key("sign_tripped"), False)),'
|
||||
_M12_NEW = ('tripped=bool(data.pop(self.key("sign_tripped"), False)'
|
||||
+ chr(10) + ' if self.name == "shed"'
|
||||
+ chr(10) + ' else data.get(self.key("sign_tripped"), False)),')
|
||||
# ⚠️ THE MUTANT POPS **AND COMES UP UNTRIPPED**, which is the whole point and
|
||||
# which the first draft got wrong: `data.pop(k, False)` RETURNS the value, so
|
||||
# shed stayed latched in memory, `_grade_store_migration()` saw
|
||||
# `on_roster_latched` and never re-armed garage - no transition-made
|
||||
# destination, and the finding was reported whether or not C5 existed. The
|
||||
# control looked restored and discriminated nothing. `and False` is the
|
||||
# difference between "the key is gone" and "the LATCH is gone", and only the
|
||||
# second reaches the re-arm this arrangement needs.
|
||||
_M12_NEW = ('tripped=bool(data.pop(self.key("sign_tripped"), False) and False'
|
||||
+ chr(10) + ' if self.name == "shed" else'
|
||||
+ chr(10) + ' data.get(self.key("sign_tripped"), False)),')
|
||||
|
||||
|
||||
def m12_findings(tree):
|
||||
"""The finding SET of a staged-leg run over the legacy+shed fixture."""
|
||||
code, out = run(Path(tree), "state-legacy-plus-shed.json", "roster.yaml",
|
||||
"--paths", "staged")
|
||||
return code, set(re.findall(r"⚠️ ([A-Z ]+): (\S+)", out)), out
|
||||
|
||||
|
||||
_m12 = mutant_tree([("app/inverter.py", _M12_OLD, _M12_NEW)])
|
||||
_m12_code, _m12_out = run(Path(_m12), "state-both-latched.json", "roster.yaml")
|
||||
_m12_clean_code, _m12_clean_out = run(HERE, "state-both-latched.json", "roster.yaml")
|
||||
_m12_new = (set(re.findall(r"⚠️ ([A-Z ]+): (\S+)", _m12_out))
|
||||
- set(re.findall(r"⚠️ ([A-Z ]+): (\S+)", _m12_clean_out)))
|
||||
print(f" both-latched store, shipped -> exit {_m12_clean_code}; "
|
||||
f"one unit's latch key deleted -> exit {_m12_code}")
|
||||
print(f" findings the mutation ADDS: {sorted(_m12_new)}")
|
||||
# ⚠️ THE SECOND CONJUNCT IS IMPLIED BY THE FIRST TODAY - on a clean run the
|
||||
# finding regex matches nothing - and the commit message that introduced it
|
||||
# overclaimed by saying the exit code "cannot say" it. It is belt-and-braces,
|
||||
# load-bearing only if the exit-0 requirement is ever relaxed. WHAT ACTUALLY
|
||||
# CLOSED THE FIRST DRAFT'S GAP IS `_m12_new` BEING A SET DIFFERENCE: the
|
||||
# mutation must ADD a finding, which is the check the narrowing's removal turns
|
||||
# red. Said plainly here because the wrong half was credited once already.
|
||||
check("⚠️ PRECONDITION: the fixture is clean on the shipped tree AND does not "
|
||||
"already report the finding this mutation exists to produce - the second "
|
||||
"is implied by the first today and kept as a belt-and-braces guard",
|
||||
_m12_clean_code == RR.EXIT_OK
|
||||
and not any(k == "unit/shed/sign_tripped"
|
||||
for _, k in re.findall(r"⚠️ ([A-Z ]+): (\S+)", _m12_clean_out)))
|
||||
check("⭐ M12: a ROSTER unit's latch key that VANISHES is a finding even though "
|
||||
"a sibling on the same roster still holds True - the moved-not-lost rule "
|
||||
"suppresses a RENAME (a key leaving a name this roster does not carry), "
|
||||
"never a LOSS",
|
||||
_m12_code == RR.EXIT_FINDING
|
||||
and any(k == "unit/shed/sign_tripped" for _, k in _m12_new))
|
||||
check("...and the mutation ADDS findings rather than merely coinciding with "
|
||||
"ones the fixture already had - an empty diff here is what made the "
|
||||
"previous draft inert",
|
||||
bool(_m12_new))
|
||||
_m12_clean_code, _m12_clean, _ = m12_findings(HERE)
|
||||
_m12_code, _m12_mut, _ = m12_findings(Path(_m12))
|
||||
_m12_new = _m12_mut - _m12_clean
|
||||
print(f" staged leg, legacy+shed: shipped exit {_m12_clean_code}, "
|
||||
f"one roster unit's latch popped -> exit {_m12_code}")
|
||||
# ⚠️ THE PRINTED SET IS LOSSIER THAN IT LOOKS: the regex captures the first
|
||||
# TOKEN of each finding's text, so two findings of one kind collapse into one
|
||||
# tuple. Harmless here - the difference is one-directional and the pair M12
|
||||
# asserts on is exact - but do not read this line as an inventory.
|
||||
print(f" findings the mutation ADDS (first token only): {sorted(_m12_new)}")
|
||||
check("⚠️ PRECONDITION: the baseline is STABLE and does not already carry the "
|
||||
"finding this mutation exists to produce. It is NOT clean - a legacy "
|
||||
"fixture never is - and the SET DIFFERENCE is what makes that fine, "
|
||||
"which is the sentence the deletion should have read before acting on it",
|
||||
not any(k == "unit/shed/sign_tripped" for _, k in _m12_clean))
|
||||
# ⚠️ AND IT MATCHES THE **KIND** AS WELL AS THE KEY, which SAFETY-44's original
|
||||
# did not. Review measured it: renaming the finding kind in `diff()` from
|
||||
# "VANISHED STORE KEY" to "GONE STORE KEY" left M12 GREEN. A vanished latch
|
||||
# quietly downgraded to a softer heading is exactly the shape part 3 requires
|
||||
# every mutation to be graded against - "the SPECIFIC finding it should produce"
|
||||
# - and this file was not holding itself to its own rule.
|
||||
check("⭐ M12 RESTORED: a ROSTER unit's latch key that vanishes is a "
|
||||
"VANISHED STORE KEY finding even though a sibling holds True - and this "
|
||||
"is the STAGED leg, where `snapshot_over()` supplies a transition-made "
|
||||
"destination, so the provenance clause does not foreclose the narrowing",
|
||||
("VANISHED STORE KEY", "unit/shed/sign_tripped") in _m12_new)
|
||||
shutil.rmtree(Path(_m12).parent, ignore_errors=True)
|
||||
|
||||
|
||||
@@ -715,60 +736,39 @@ check("...and no leg's text has swallowed the CLOSING SUMMARY, which repeats "
|
||||
"red whenever any leg was",
|
||||
not any("FINDING(S) - this transition is not clean" in v
|
||||
for v in _L.values()))
|
||||
# ⚠️ TWO FURTHER HOLES IN THIS RULE ARE **KNOWN, MEASURED BY REVIEW, AND NOT
|
||||
# FIXED HERE** - deliberately, and this paragraph is the whole of the entry.
|
||||
# ⚠️ THIS BLOCK USED TO SAY THOSE HOLES WERE KNOWN AND NOT FIXED, AND IT IS
|
||||
# NOW STALE IN THE OTHER DIRECTION - INSTR-01 fixed them, and left the
|
||||
# paragraph saying "BOTH ARE REVERTED OUT OF THIS CARD, because THIS SUITE
|
||||
# CANNOT MEASURE EITHER ONE" describing a tree that no longer exists. Review
|
||||
# found it. A comment that outlives its subject is the same defect as a
|
||||
# citation that outlives its check.
|
||||
#
|
||||
# INJECTIVITY. `moved_to` is "any machine-read key with the same bare name
|
||||
# and the same value", so N vanished keys can all claim the SAME destination.
|
||||
# Review measured a rename carrying `inverter-a` -> `garage` correctly while
|
||||
# DROPPING `inverter-b` -> `shed`: both reported as migrated to
|
||||
# `unit/garage/sign_tripped`, zero store-key findings, `shed` unlatched.
|
||||
# WHERE EACH ONE NOW LIVES, and every clause of `moved_to` has a row in 5e:
|
||||
#
|
||||
# PROVENANCE. A destination is accepted regardless of whether the transition
|
||||
# put the value there, so a roster unit already latched in its own right is a
|
||||
# free suppression slot. Review measured a build deleting `old-a` while
|
||||
# renaming `old-b` -> `shed`: exit 0, NO FINDINGS, `old-a` reported as having
|
||||
# moved into a key that already held True.
|
||||
# INJECTIVITY (C6) R5, and removing the clause flips exactly R5
|
||||
# PROVENANCE (C7) R3, likewise
|
||||
# SAME VALUE (C3) R4
|
||||
# SAME BARE KEY(C2) R7 - which had NO row until review deleted the clause
|
||||
# and the whole 28-file suite stayed green
|
||||
# MACHINE-READ (C1) R6
|
||||
# OFF-ROSTER (C5) R2, and M12 end-to-end on the staged leg
|
||||
# PREFIX GATE (C4) NO 5e ROW, deliberately - `vanished_unit is not None`
|
||||
# is reached end-to-end by part 3's M9, and a synthetic
|
||||
# row would duplicate that rather than add separability.
|
||||
# Listed because C5 lives in the same `if` and omitting
|
||||
# one guard while naming the other reads as an oversight.
|
||||
#
|
||||
# ATTRIBUTION. Which vanished key gets a destination is decided by
|
||||
# `sorted()`, so on a build renaming `inverter-b` -> `garage` while DELETING
|
||||
# `inverter-a`, the deleted key sorts first and takes it: the report names the
|
||||
# DELETED key as migrated and the one that really moved as VANISHED. The run
|
||||
# is still exit 1, so this is a wrong sentence rather than a hidden loss - but
|
||||
# an operator chasing the named key looks at the wrong inverter.
|
||||
#
|
||||
# VALUE MATCHING. `and after.data.get(k) == value` survives the suite and is
|
||||
# load-bearing: without it the rule degenerates to BARE-NAME matching, and
|
||||
# review measured a deleted `unit/old-x/sign_reason` reported as having
|
||||
# migrated into a `sign_reason` holding a COMPLETELY DIFFERENT SENTENCE.
|
||||
# Essentially every off-roster vanished `unit/*` key would be suppressed.
|
||||
#
|
||||
# ⚠️ BOTH FIXES ARE ONE LINE AND BOTH WERE WRITTEN, AND BOTH ARE REVERTED OUT
|
||||
# OF THIS CARD, because THIS SUITE CANNOT MEASURE EITHER ONE. Three attempts:
|
||||
# a check over part 5's destinations passed with the whole injectivity
|
||||
# mechanism deleted (its two destinations are `sign_reason` and `sign_tripped`
|
||||
# - different bare keys, which can never contend); a dedicated fixture
|
||||
# discriminated nothing (both snapshots read one state file, so nothing
|
||||
# vanishes and there are no migrated lines either way); and rebuilding M12's
|
||||
# fixture to reach the provenance case disarmed M12 for the NARROWING, so all
|
||||
# three clause-removals came back green at once.
|
||||
#
|
||||
# ⚠️ THAT LAST ONE IS THE REASON THIS IS A SPLIT AND NOT A JUDGEMENT CALL: a
|
||||
# later fix to the same function silently disarmed the control for an earlier
|
||||
# one, and I only saw it because the removal was re-driven. Shipping two more
|
||||
# unmeasured clauses into the instrument, on a card whose subject is checks
|
||||
# that cannot reach what they name, would be the fourth instance in one PR.
|
||||
# They go on their own card, with review's measurements as the starting
|
||||
# evidence and a fixture built BEFORE the fix.
|
||||
#
|
||||
# ⚠️ ALL FOUR ARE CLAUSES OF **ONE COMPREHENSION**, WHICH IS THE STRUCTURAL
|
||||
# POINT AND NOT A LIST OF CHORES. `diff()`'s `moved_to` now carries four
|
||||
# conditions with four different reasons to exist, against one fixture set that
|
||||
# can reach only some of them at a time - so rebuilding a fixture to reach
|
||||
# clause N moves the others. That is exactly how (3) happened, and it is what
|
||||
# INSTR-01 has to design against rather than patch around.
|
||||
#
|
||||
# What IS measured here is the narrowing, by M12 above, both ways.
|
||||
# ⚠️ ONE HOLE IS STILL OPEN AND IS **UNMEASURED**, stated so nobody reads
|
||||
# `sorted()` as guarded: ATTRIBUTION. Which vanished key gets a destination is
|
||||
# decided by `sorted()` order, so on a build renaming `inverter-b` -> `garage`
|
||||
# while DELETING `inverter-a`, the deleted key sorts first and takes it - the
|
||||
# report names the DELETED key as migrated and the one that really moved as
|
||||
# VANISHED. Review measured that replacing `sorted(` with `list(` leaves the
|
||||
# suite green: every 5e case has at most one candidate destination, so no row
|
||||
# can see ordering. The run is still exit 1, so this is a WRONG SENTENCE rather
|
||||
# than a hidden loss - an operator chasing the named key looks at the wrong
|
||||
# inverter. Fixing it needs the destination's provenance rather than
|
||||
# first-come, which is more than a rename diff currently knows.
|
||||
check("⚠️ and the key that MOVED is not reported as lost: the before-snapshot "
|
||||
"holds `unit/inverter-a/sign_tripped = True` and the after-snapshot "
|
||||
"holds it under `garage`, three lines apart in the same report. A "
|
||||
@@ -873,6 +873,184 @@ check("the SAME state file onto a roster whose first entry IS `inverter-a` "
|
||||
code_ok == RR.EXIT_OK and "LATCH CAME UP DISARMED" not in out_ok)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5e. INSTR-01: `diff()`'s suppression rule, ONE FIXTURE PER CLAUSE")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ THIS SECTION EXISTS BECAUSE SAFETY-44 PRODUCED THREE INERT CONTROLS FOR
|
||||
# THIS ONE RULE, EACH INERT DIFFERENTLY:
|
||||
#
|
||||
# 1. a check over part 5's real destinations passed with the whole mechanism
|
||||
# deleted - its two destinations are `sign_reason` and `sign_tripped`,
|
||||
# DIFFERENT bare keys, which can never contend;
|
||||
# 2. a dedicated end-to-end fixture discriminated nothing - both snapshots
|
||||
# read ONE state file, so nothing vanishes and there are no migrated lines
|
||||
# with the clause or without it;
|
||||
# 3. rebuilding M12's fixture to reach a new clause DISARMED M12 for an older
|
||||
# one, and all three clause-removals went green at once.
|
||||
#
|
||||
# ⭐ (3) IS THE STRUCTURAL FINDING AND IT IS WHY THIS SECTION IS SHAPED THIS WAY.
|
||||
# `moved_to` is ONE comprehension carrying FIVE conditions with five different
|
||||
# reasons to exist, and an END-TO-END fixture cannot separate them: both
|
||||
# snapshots come from one state file, so the arrangement that reaches clause N
|
||||
# forecloses clause M. Rebuilding the fixture to reach one moves the others.
|
||||
#
|
||||
# So the rule is graded at the level it is written at - `diff()` on constructed
|
||||
# Snapshots - with ONE CASE PER CLAUSE. Nothing here is a fiction: the inputs
|
||||
# are the same three fields `diff()` reads (`data`, `machine_read`, `units`),
|
||||
# and the end-to-end controls above - M12 on the STAGED leg, and part 5 -
|
||||
# still grade the same rule through a real start. This is the separability
|
||||
# those cannot have, not a replacement for them.
|
||||
CLAUSE_CASES = {
|
||||
# name: (units, machine_read, before, after)
|
||||
"R1-rename-legit": (
|
||||
["garage"], ["unit/garage/sign_tripped"],
|
||||
{"unit/old/sign_tripped": True}, {"unit/garage/sign_tripped": True}),
|
||||
"R2-narrowing": (
|
||||
["garage", "shed"], ["unit/garage/sign_tripped", "unit/shed/sign_tripped"],
|
||||
{"unit/shed/sign_tripped": True}, {"unit/garage/sign_tripped": True}),
|
||||
"R3-provenance": (
|
||||
["garage"], ["unit/garage/sign_tripped"],
|
||||
{"unit/old/sign_tripped": True, "unit/garage/sign_tripped": True},
|
||||
{"unit/garage/sign_tripped": True}),
|
||||
"R4-value": (
|
||||
["garage"], ["unit/garage/sign_reason"],
|
||||
{"unit/old/sign_reason": "X-unique"},
|
||||
{"unit/garage/sign_reason": "Y-different"}),
|
||||
"R5-injectivity": (
|
||||
["garage"], ["unit/garage/sign_tripped"],
|
||||
{"unit/old-a/sign_tripped": True, "unit/old-b/sign_tripped": True},
|
||||
{"unit/garage/sign_tripped": True}),
|
||||
"R6-machine-read": (
|
||||
["garage"], [],
|
||||
{"unit/old/sign_tripped": True}, {"unit/garage/sign_tripped": True}),
|
||||
# ⚠️ R7 EXISTS BECAUSE THE BARE-KEY MATCH HAD NO ROW AT ALL, and review
|
||||
# deleted it and left the whole 28-file suite GREEN. The rule's own comment
|
||||
# says "THE TEST IS NOT 'the name appears somewhere'. It is: the SAME bare
|
||||
# key" - and nothing measured that sentence. It is load-bearing: a vanished
|
||||
# SAFETY-02 `sign_tripped` was suppressed by a SAFETY-42
|
||||
# `delivery_tripped` on a roster unit, i.e. by a DIFFERENT FAILSAFE's key.
|
||||
# `unit/*/delivery_tripped` is machine-read, so this is reachable on any
|
||||
# real two-latch store rather than a contrivance.
|
||||
"R7-bare-key": (
|
||||
["garage"], ["unit/garage/delivery_tripped"],
|
||||
{"unit/old/sign_tripped": True},
|
||||
{"unit/garage/delivery_tripped": True}),
|
||||
}
|
||||
# What the SHIPPED rule must answer. `migrated` is the suppression firing.
|
||||
CLAUSE_EXPECT = {
|
||||
"R1-rename-legit": ([], ["unit/old/sign_tripped"]),
|
||||
"R2-narrowing": (["unit/shed/sign_tripped"], []),
|
||||
"R3-provenance": (["unit/old/sign_tripped"], []),
|
||||
"R4-value": (["unit/old/sign_reason"], []),
|
||||
"R5-injectivity": (["unit/old-b/sign_tripped"], ["unit/old-a/sign_tripped"]),
|
||||
"R6-machine-read": (["unit/old/sign_tripped"], []),
|
||||
"R7-bare-key": (["unit/old/sign_tripped"], []),
|
||||
}
|
||||
# ⚠️ THE DRIVER **IMPORTS** THE TREE UNDER TEST AND THE TABLE STAYS HERE, so
|
||||
# expectation and implementation sit on opposite sides of the subprocess and
|
||||
# a mutation to the tool cannot also move the answer it is graded against.
|
||||
# (It is WRITTEN to a tempdir - see `clause_table()`. The first draft of this
|
||||
# sentence said "written into the tree", which is the read-only-CI bug the
|
||||
# same commit says it fixed, stated as if it were the design.)
|
||||
CLAUSE_DRIVER = """
|
||||
import json, sys
|
||||
import rehearse_roster as RR
|
||||
spec = json.load(sys.stdin)
|
||||
out = {}
|
||||
for name, case in spec.items():
|
||||
units, mread, before, after = case
|
||||
b = object.__new__(RR.Snapshot); a = object.__new__(RR.Snapshot)
|
||||
for snap, data in ((b, before), (a, after)):
|
||||
snap.label = name
|
||||
snap.data = dict(data)
|
||||
snap.units = list(units)
|
||||
snap.machine_read = set(mread)
|
||||
snap.entities = {}
|
||||
snap.latched = {u: False for u in units}
|
||||
snap.latch_reason = {u: "" for u in units}
|
||||
snap.flat_tripped = False
|
||||
snap.history = {}
|
||||
snap.read = set()
|
||||
snap.stranded_at_default = []
|
||||
snap.vanished_at_default = []
|
||||
snap.vanished_but_migrated = []
|
||||
f = RR.diff(b, a)
|
||||
out[name] = [
|
||||
sorted(x.text.split(" = ")[0] for x in f
|
||||
if x.kind == "VANISHED STORE KEY"),
|
||||
sorted(t.split(" = ")[0] for t in a.vanished_but_migrated),
|
||||
]
|
||||
print(json.dumps(out))
|
||||
"""
|
||||
|
||||
|
||||
def clause_table(tree) -> dict:
|
||||
"""Run CLAUSE_CASES through `diff()` in `tree`, as the tool would see it."""
|
||||
# ⚠️ THE DRIVER GOES IN A TEMPDIR, NOT IN THE TREE. `checks / unit` mounts
|
||||
# the checkout READ-ONLY, so writing beside the tool works on a dev box and
|
||||
# is `OSError: Read-only file system` in CI - a difference that would have
|
||||
# turned this section red on every PR and green for whoever wrote it.
|
||||
tools = Path(tree) / "tools"
|
||||
tmp = tempfile.mkdtemp(prefix="clause-drv-")
|
||||
drv = Path(tmp) / "_clause_driver.py"
|
||||
drv.write_text(CLAUSE_DRIVER, encoding="utf-8")
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(drv)],
|
||||
input=json.dumps(CLAUSE_CASES), capture_output=True, text=True,
|
||||
cwd=tmp,
|
||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PYTHONPATH": f"{tools}{os.pathsep}{Path(tree)}"})
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
assert proc.returncode == 0, (
|
||||
"the clause driver did not run, so every row below would be "
|
||||
f"fabricated:\n{proc.stdout}\n{proc.stderr}")
|
||||
return {k: (v[0], v[1]) for k, v in json.loads(proc.stdout).items()}
|
||||
|
||||
|
||||
_ct = clause_table(HERE)
|
||||
for _name in sorted(CLAUSE_CASES):
|
||||
_got, _want = _ct[_name], CLAUSE_EXPECT[_name]
|
||||
print(f" {_name:<18} vanished={_got[0]} migrated={_got[1]}")
|
||||
check(f"{_name}: the shipped rule answers as specified", list(_got) == [list(_want[0]), list(_want[1])])
|
||||
check("⚠️ ...and the LEGITIMATE case is still suppressed - a rule that reports "
|
||||
"every rename is a rule an operator turns off, which is the failure "
|
||||
"CONTROL-C exists to prevent",
|
||||
_ct["R1-rename-legit"][1] == ["unit/old/sign_tripped"])
|
||||
|
||||
# ⭐ AND EACH CLAUSE REMOVAL MUST FLIP **EXACTLY ITS OWN ROW**. That second half
|
||||
# is the acceptance criterion this card was filed with: a removal that flips
|
||||
# nothing is an unmeasured clause, and a removal that flips someone else's row
|
||||
# means the cases are not separable after all - which is (3) above, caught.
|
||||
CLAUSE_MUTATIONS = [
|
||||
("C2 same bare key", "R7-bare-key",
|
||||
' if k.rsplit("/", 1)[-1] == bare and after.data.get(k) == value',
|
||||
" if True and after.data.get(k) == value"),
|
||||
("C1 machine_read", "R6-machine-read",
|
||||
" k for k in after.machine_read",
|
||||
" k for k in after.data"),
|
||||
("C3 same value", "R4-value",
|
||||
" and after.data.get(k) == value\n", "\n"),
|
||||
("C5 off-roster", "R2-narrowing",
|
||||
" if vanished_unit is not None and vanished_unit not in after.units \\\n"
|
||||
" and moved_to:",
|
||||
" if vanished_unit is not None and moved_to:"),
|
||||
("C6 injectivity", "R5-injectivity",
|
||||
" and k not in claimed)", ")"),
|
||||
("C7 provenance", "R3-provenance",
|
||||
" and before.data.get(k) != value", " and True"),
|
||||
]
|
||||
for _label, _owns, _old, _new in CLAUSE_MUTATIONS:
|
||||
_t = mutant_tree([("tools/rehearse_roster.py", _old, _new)])
|
||||
_mt = clause_table(_t)
|
||||
_flipped = sorted(n for n in CLAUSE_CASES if _mt[n] != _ct[n])
|
||||
print(f" {_label:<16} removed -> rows that changed: {_flipped}")
|
||||
check(f"⭐ removing {_label} flips {_owns} - the clause is load-bearing and "
|
||||
f"this row is what measures it",
|
||||
_flipped == [_owns])
|
||||
shutil.rmtree(Path(_t).parent, ignore_errors=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("6. THE REVERSE DIRECTION: the retreat, and the return from it")
|
||||
|
||||
@@ -69,6 +69,7 @@ from app.price import (
|
||||
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
|
||||
plan_is_provisional, required_spread, uncertainty_of,
|
||||
)
|
||||
from app.price_history import NoHistory
|
||||
from app.static_tariff import (
|
||||
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
|
||||
parse_static_profile, resolve_tariff, static_plan, static_status,
|
||||
@@ -746,7 +747,8 @@ _cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
|
||||
price_feed_url="https://api.energy-charts.info/price?bzn=BE",
|
||||
price_timeout_s=1, price_markup_eur_kwh=0.03,
|
||||
price_taxes_eur_kwh=0.06)
|
||||
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead))
|
||||
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead,
|
||||
history=NoHistory()))
|
||||
check("a dead feed falls back to the static profile rather than to nothing",
|
||||
_fb.origin == "fallback" and _fb.has_prices)
|
||||
check("...with the SAME prices the configured-product path produces, because "
|
||||
@@ -769,15 +771,27 @@ check("...and the provisional plan has no cheap window either: §8.3 forbids an
|
||||
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
|
||||
_fb_none = run(resolve_tariff(opts(None, price_provider="generic_dayahead",
|
||||
price_timeout_s=1),
|
||||
THU, THU + timedelta(days=1), transport=_dead))
|
||||
THU, THU + timedelta(days=1), transport=_dead,
|
||||
history=NoHistory()))
|
||||
check("a dead feed with NO static_profile has nothing to fall back to, and "
|
||||
"says that rather than inventing a tariff",
|
||||
_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):
|
||||
@@ -785,7 +799,7 @@ async def _live(url, timeout_s):
|
||||
|
||||
|
||||
_live_plan = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1),
|
||||
transport=_live))
|
||||
transport=_live, history=NoHistory()))
|
||||
check("a live feed is used in preference to the static profile",
|
||||
_live_plan.origin == "provider"
|
||||
and _live_plan.product is PriceProduct.DAY_AHEAD)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"_comment": "SAFETY-44 SYNTHETIC FIXTURE - not a copy of any live state file; this session cannot read that one and did not try. It exists for ONE control: a store in which TWO ROSTER UNITS ARE LATCHED AT THE SAME VALUE. `state-legacy.json` cannot serve it - it is pre-DEV-02 and flat, so after migration only the FIRST roster entry is ever latched, and a rule that suppresses a vanished key when another key holds the same value can never be exercised against it. That is exactly how M12's first draft came to pass on the UNMUTATED tree. Both units are latched by ONE site event, so the reasons match too, which is the arrangement in which the suppression is most dangerous.",
|
||||
"schema_version": 1,
|
||||
"maint_schema_version": 1,
|
||||
"auto": true,
|
||||
"unit/garage/sign_tripped": true,
|
||||
"unit/garage/sign_reason": "SIGN_DISAGREEMENT: grid meter reversed, both units",
|
||||
"unit/shed/sign_tripped": true,
|
||||
"unit/shed/sign_reason": "SIGN_DISAGREEMENT: grid meter reversed, both units"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"_comment": "INSTR-01 SYNTHETIC FIXTURE - `state-legacy.json` with a SECOND roster unit already latched. Not a copy of any live state file. It exists to restore M12 END-TO-END on the STAGED leg, which is the arrangement SAFETY-44 and INSTR-01's first draft both missed: `snapshot_over()` starts the after-leg over the file the BEFORE-leg wrote, so a transition-made destination is available there and the two questions the provenance clause and the narrowing ask are NOT incompatible. Every same-step arrangement makes them incompatible; that is what was measured and over-generalised to 'no fixture can'.",
|
||||
"phase": "idle",
|
||||
"phase_started": "2026-08-07T10:00:00+00:00",
|
||||
"last_completed": "2026-08-07T18:42:00+00:00",
|
||||
"last_start_attempt": "2026-08-07T10:00:00+00:00",
|
||||
"sign_tripped": true,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: commanded -1800 W against a meter reading -1450 W for 190 s",
|
||||
"auto": true,
|
||||
"unit/shed/sign_tripped": true,
|
||||
"unit/shed/sign_reason": "SIGN_DISAGREEMENT: shed, latched before the switch"
|
||||
}
|
||||
@@ -545,6 +545,12 @@ def diff(before: Snapshot, after: Snapshot) -> list:
|
||||
# name and whose roster start writes them under another reported two
|
||||
# VANISHED findings about a pair of defaults, and CONTROL-C went red on a
|
||||
# build that was CORRECT.
|
||||
# ⚠️ `claimed` MAKES THE MOVE INJECTIVE. Without it N vanished keys can all
|
||||
# claim the SAME destination - and `DEFAULTS["sign_tripped"] is False`, so
|
||||
# `True` is the ONLY non-default value a latch can hold, which means they
|
||||
# collide by CONSTRUCTION rather than by coincidence. Two keys cannot both
|
||||
# have become one key; the second claimant has not moved anywhere.
|
||||
claimed = set()
|
||||
for key in sorted(set(before.data) - set(after.data)):
|
||||
value = before.data[key]
|
||||
bare = key.rsplit("/", 1)[-1]
|
||||
@@ -573,11 +579,24 @@ def diff(before: Snapshot, after: Snapshot) -> list:
|
||||
# here. Neither mutation exercises this branch. The sentence "what keeps
|
||||
# this from being a softening of the check" was not supported by the
|
||||
# thing it named - and a citation is the worst possible carrier for that,
|
||||
# because it reads as having been checked. The branch is now bounded by
|
||||
# M12, written for it, in test_rehearse.py.
|
||||
# because it reads as having been checked.
|
||||
#
|
||||
# ⚠️ AND THE REPLACEMENT CITATION WENT STALE THE SAME WAY. It read "the
|
||||
# branch is now bounded by M12" through a card that DELETED M12 and a
|
||||
# card that restored it on a different leg - eight lines below the
|
||||
# paragraph condemning exactly this. Every clause of this comprehension
|
||||
# is now graded by a row in test_rehearse.py part 5e, one case per
|
||||
# clause, and each clause-removal must flip exactly its own row. That
|
||||
# is a property of the section, not a pointer at a named check.
|
||||
# ⚠️ AND THE DESTINATION MUST BE ONE **THIS TRANSITION MADE**
|
||||
# (`before.data.get(k) != value`) AND NOT ALREADY SPOKEN FOR
|
||||
# (`k not in claimed`). Without the first, a roster unit that was
|
||||
# already latched in its own right is a free suppression slot that
|
||||
# nobody moved into.
|
||||
moved_to = sorted(
|
||||
k for k in after.machine_read
|
||||
if k.rsplit("/", 1)[-1] == bare and after.data.get(k) == value)
|
||||
if k.rsplit("/", 1)[-1] == bare and after.data.get(k) == value
|
||||
and before.data.get(k) != value and k not in claimed)
|
||||
# ⚠️ AND THE VANISHED KEY'S OWN UNIT MUST BE **OFF THE ROSTER**, WHICH
|
||||
# THE FIRST VERSION OF THIS RULE DID NOT REQUIRE AND WHICH IS WHAT MADE
|
||||
# IT A SOFTENING. The match was bare-key-plus-value over the whole of
|
||||
@@ -600,6 +619,7 @@ def diff(before: Snapshot, after: Snapshot) -> list:
|
||||
vanished_unit = key.split("/")[1] if key.startswith("unit/") else None
|
||||
if vanished_unit is not None and vanished_unit not in after.units \
|
||||
and moved_to:
|
||||
claimed.add(moved_to[0])
|
||||
after.vanished_but_migrated.append(
|
||||
f"{key} = {value!r} -> {moved_to[0]}")
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user