Compare commits

...
7 Commits
Author SHA1 Message Date
Claude Opus 5 17b7b447c4 TARIFF-09: ask the day-ahead feed for the horizon it was handed
checks / unit (pull_request) Successful in 7m10s
`generic_dayahead.fetch()` accepted `since` and `until` and requested the
bare `?bzn=BE`, which answers ONE Brussels day. Everything past today was
then filled from yesterday's profile by FCST-04, flagged provisional, and
§8.3 drops provisional hours from the trade set -- so the optimiser never
saw tomorrow as firm, at any hour of any day, and nothing reported it.

Measured on 4257e38 at 2026-09-10T09:00+02:00, 48 h horizon, live feed,
yesterday seeded through the shipped read path:

    60 firm points (15 whole hours) + 33 carried hours of 48 requested
    carried: 24 h on 2026-09-11, 9 h on 2026-09-12
    arbitrage(0.10): buy 11:45 and sell 19:45 -- BOTH on 2026-09-10
    firm local days = [2026-09-10]

The ceiling was structural: with a one-day window, at hour H a 48 h horizon
is at most 24-H firm and 24+H carried. At 23:00 that is 1 and 47, and the
remaining firm spread (0.0464 EUR/kWh) no longer clears the default 0.10
threshold, so arbitrage is refused outright. FCST-04's fill covers the
window until the ~13:00-14:00 CET auction; it was never meant to cover
every hour of every day.

The data exists and the endpoint serves it: verified 2026-09-10T07:45:49Z,
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns 192 quarter-hours across
two local days. Committed unmodified beside TARIFF-08's captures, with the
URL, retrieval time and sha256 recorded and RE-DERIVED by the replay.

THE RANGE IS DERIVED ON THE BRUSSELS CALENDAR, not the UTC one. 22:30 UTC
in summer is already tomorrow in Belgium, so a UTC-derived start asks for
the day the controller has just left and drops the day it is standing in,
for the last one or two hours of every day. That is `_bdate()`'s lesson one
layer up with a worse consequence: `_bdate()` misfiles an hour, this never
fetches it. `until` is exclusive, so local midnight belongs to the day
before it; an inverted window collapses to one day rather than a backwards
range. Both ends are graded and both are driven red by mutation.

After, same code path and window, a feed serving both days: 39 firm hours
and 9 carried (was 15 and 33), firm across TWO Brussels days, and the
permitted cycle buys on one day and sells on the next.

`price_feed_url` IS THE EXACT URL AND THE PLUGIN APPENDS NOTHING. `start`/
`end` are this feed's spelling and the provider declares a second host
whose parameters are different, so merging would produce a request nobody
typed and the short horizon anyway. The cost is real, so it is not silent:
a WARNING on the shipped logger at fetch time names the consequence and the
start..end that would otherwise have been asked for. `dated_feed_url()`
refuses to append to a base that already carries a query string, so the
blind-append shape cannot come back by edit.
DECISIONS/TARIFF-09-the-range-and-the-override.md argues the alternative.

Before publication nothing regresses and it is graded, not assumed: at
09:46 CEST the two-day request came back byte-identical to the one-day
body, and that stays a `series` of what exists with FCST-04 filling the
rest. A `no_data` there would switch a working site's arbitrage off every
morning -- the mutation that adds that rule is red by name.

The plugin contract is still NOT enforced: three shipped plugins point at
endpoints nobody has verified, and a rule obliging them to pass a range
would be satisfied by an invented parameter name -- TARIFF-08's defect
moved from the response to the request. Which plugins consume the range is
pinned as a literal instead.

11 mutations, all red, no survivors; each applied to a fresh copy of the
tree rather than reverted, through tools/mutation_run.sh. One is +0 B (a
single price digit inside the committed capture) and the re-derived sha256
catches it. MUTATION.md habit 20 records why a fixture could never have
reached this: a captured body is the answer to a URL, and no property of a
body says which URL was asked for. All 29 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 10:35:12 +02:00
admin 4257e38e5a Merge pull request 'TARIFF-08: read the feed the add-on actually ships pointed at' (#76) from TARIFF-08 into release/1.0
_points_from_json() expected doc['data'] to be a list of {start, price_eur_mwh}. api.energy-charts.info/price?bzn=BE returns a columnar document - parallel unix_seconds and price arrays - so the shipped parser raised SourceUnavailable('the feed carried no data list') against every real response. generic_dayahead had never parsed one. With the three fabricated supplier URLs of TARIFF-07, that left manual_csv as the only working provider in a five-provider table.

It survived to release because every fixture fed the invented shape: test_price.py, test_price_history.py and test_static_tariff.py all built {'data':[{'start':...,'price_eur_mwh':...}]}, so the suite validated the parser against the parser's own assumption - a closed loop that cannot notice the assumption is wrong. Section 11.4 then made the failure silent: a parse error degrades to the static profile, which is correct and says nothing about the cause.

Verified live 2026-09-10, end to end: fetch -> parse -> assemble() -> TariffPlan returns 96 quarter-hourly points at 900 s, origin provider, product DAY_AHEAD, arbitrage allowed with a firm spread of 0.4645 EUR/kWh. First time the pipeline has completed against a real feed. Belgium moved to a 15-minute MTU on 2025-10-01, so FCST-04's H1 fix - the fill was decimating sub-hourly points to one per hour - was never theoretical; it is the shape of the live feed, and it is what makes this deliverable.

The columnar shape is read by a SECOND function, not a sniff, and feeding the row shape to generic_dayahead is now a graded no_data so the sniff cannot return quietly. Equal length refused in both directions with both lengths in the reason; the unit refused if not EUR/MWh, because MAX_PLAUSIBLE_EUR_KWH only bounds prices that are too large and a EUR/kWh switch would otherwise be invisible; nulls dropped with a count, an all-null body named as no_data; a deprecated feed warns while it still works.

Two real bodies captured as fixtures - unmodified bytes, with URL, retrieval time, unit, licence-as-returned and sha256 recorded, and the checksums RE-DERIVED by the replay rather than asserted. The invented fixtures are renamed synthetic_row_feed at every call site so the closed loop cannot re-form. Only one of four network providers has a capture, and a registry-derived check says so in its own sentence: the other three have nothing to capture, which is TARIFF-07.

Evidence: 29/29 green. 12 mutations, no survivors, purged tree and -B per TEST-15. One first-draft line was decoration and the mutation said so - the parser called brussels() per instant while assemble() already renders, so removing it changed nothing. The line was deleted rather than fenced with a check, and the mutation re-aimed at assemble() where the rule lives. MUTATION.md habit 19.

Follow-up filed as TARIFF-09: generic_dayahead ignores the since/until it is handed, so the feed supplies 24 h of a 48 h horizon and tomorrow is never firm.
2026-09-10 09:14:50 +02:00
adminandClaude Opus 5 7a5281e97f Merge release/1.0 (FCST-06) into TARIFF-08
checks / unit (pull_request) Successful in 7m10s
One conflict, in CHANGELOG.md's `## Unreleased` block, exactly as expected
from two branches landing beside each other: both `_this branch_` table rows
and both sections are kept, FCST-06's first. Nothing else conflicted -
FCST-06's `test_price_history.py` section 5d and this branch's columnar
`feed_body()` merged cleanly - and all 29 test files are green on the result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 09:06:39 +02:00
adminandClaude Opus 5 6eba18968c TARIFF-08: the day-ahead feed is read in the shape it actually publishes
checks / unit (pull_request) Failing after 3s
`generic_dayahead` could not read one byte of `api.energy-charts.info`, and
never had 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 -
`unix_seconds` (96 UTC epoch seconds, 900 s apart), `price` (96 floats),
`unit: 'EUR / MWh'`. Driving the shipped parser against that body:
`SourceUnavailable: the feed carried no 'data' list`. It is the only shipped
provider whose endpoint anybody has reached, so dynamic pricing did not work.

AND §11.4 MADE IT SILENT, which is why it survived to release: a parse error
becomes no_data, becomes the static profile, becomes arbitrage off - the
CORRECT degradation, saying nothing about the cause. A site would have sat on
the fallback indefinitely.

AND 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": [...]})`, a shape invented
here and published by nobody, so the checks graded the parser against the
parser's guess - a closed loop no coverage inside it could ever have escaped.
MUTATION.md habit 19 records it as the fourteenth instance of habit 4 and the
first where the fixture and the code under test shared a SHAPE, not a number.

WHAT CHANGED

* `_points_from_columnar_json()` - a SECOND function, not a sniff. A parser that
  decides which document it received is one that can decide wrong, and a
  wrong-but-parseable response then becomes a price. Feeding the row shape to
  `generic_dayahead` is now a graded no_data.
* Unequal arrays are refused in both directions, with the two lengths in the
  reason. `zip()` stops at the shorter list silently, and a truncated `price`
  array would have produced a real series missing the evening peak - traded on
  as complete, where no data at all switches arbitrage off.
* A unit that is not EUR/MWh is refused. The plugin converts with a fixed
  0.001, and `MAX_PLAUSIBLE_EUR_KWH` only bounds prices that are too LARGE.
* `unix_seconds` are read as UTC instants explicitly; §9.3's Brussels rendering
  stays in `assemble()` rather than being copied into the plugin, where a
  mutation could not tell it from a no-op (M4 proved exactly that on a first
  draft, and the line was removed rather than fenced).
* A published null is dropped and logged with a count; an all-null body is
  no_data named by this parser rather than by the boundary validator.

FIXTURES

Two unmodified response bodies under `scenarios/`, captured by hand on
2026-09-10: quarter-hourly (2026-09-10, 96 points at 900 s) and hourly
(2025-09-10, 24 at 3600 s - the same endpoint before Belgium's 15-minute MTU).
`energy-charts-be-price-captures.json` records the URL, retrieval time, unit,
licence-as-returned and a sha256 of each, and the replay RE-DERIVES the
checksums, so a fixture edited to make a check pass stops being a capture. Data
CC BY 4.0, Bundesnetzagentur | SMARD.de, via Energy-Charts (Fraunhofer ISE),
published without changes - the same BE-zone licence check DATA-02 established.

The surviving invented fixtures are renamed `synthetic_row_feed` at every call
site. `engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy` are untouched
and have no capture: their URLs are unverified and withdrawing them is
TARIFF-07's open decision. Nothing should read their green checks as evidence
that those feeds parse.

MUTATIONS - 12, all red, no survivors. Purged tree, mounted `:ro`, `python3 -B`
(TEST-15). M1 drop the equal-length refusal; M2 make it one-sided; M3 drop the
unit check; M4 `assemble()` stops rendering at the Brussels offset; M5 read
`unix_seconds` as a naive local clock; M6 zero-fill a null instead of dropping
it; M7 drop the all-null refusal; M8 sniff for the row shape; M9 drop the
deprecated warning; M10 drop the dropped-nulls warning; M11 edit one price in
the committed capture; M12 point `generic_dayahead` back at the row parser -
red across all three price suites.

END TO END, LIVE, 2026-09-10: `resolve_tariff()` returns a `TariffPlan`,
`origin='provider'`, 96 quarter-hourly points, spacing [900] s, 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. All 29 test files green.

STILL OPEN: `generic_dayahead` 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 08:57:32 +02:00
admin 958f5cd218 Merge pull request 'FCST-06: cannot-persist is one question, asked after the attempt' (#75) from FCST-06 into release/1.0
The module claimed 'a store that cannot persist records nothing' and enforced it against `not self.ok` only. A store with ok=True whose _save() fails - a full volume, a read-only remount - kept answering yesterday() from memory until the next restart, which is the silently-forgetting cache the docstring rejects, reached by the condition the rule did not name.

Reproduced against real volumes rather than a monkeypatch: /data bind-mounted read-only (EROFS) and a full tmpfs (ENOSPC), with a healthy-/data control asserted either side. Before: yesterday() returned a 24-point series while the disk held {}.

Fixed in the code, not the docstring - weakening the doc would have documented a cache that forgets without saying so as intended.

The design answer is the part worth keeping: 'can this store persist' CANNOT be answered before the attempt, because a directory that was writable a moment ago is not a write that completed, so os.access or a probe write is a third symptom test wearing a predicate's name. It is answered after, once: _save() -> bool, true only if the file now holds what memory holds. The `not self.ok` test in _remember() is GONE rather than joined by a second one, and a check asserts its absence from the source. This is the same shape as FCST-04's M4 and FCST-05's HIGH - a rule enforced on the conditions someone thought of - and it is the first of the three to be closed by removing a test rather than adding one.

What is undone is the MERGE, not the store: a volume that fills at 14:00 leaves the yesterday written at 13:30 still answering. The snapshot is a deepcopy - measured, a shallow copy let a store holding 12 persisted points answer 24.

Evidence: 29/29 green. Six mutations, no survivors, purged tree each run with the mutated line greped off disk. One of them exposed an instrument defect now recorded in MUTATION.md: it printed 1 FAIL when the true figure was 3, because an f-string operand called len(read.points) and .points on a no_data raises Absence by design, killing the harness mid-section. One check earlier and it would have read as a survivor. A mutation's red count is a lower bound until the run is seen to reach the end of its section.
2026-09-10 08:52:09 +02:00
adminandClaude Opus 5 c45893ffad FCST-06: "cannot persist" becomes one question, asked once, about the outcome
checks / unit (pull_request) Successful in 7m14s
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` only, 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:

    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.

Route taken: `_remember()` refuses to retain what it could not write, and the
docstring's claim stands as written. It is now ONE derived predicate rather
than two places testing two symptoms. "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. No volume is one False, an OSError is another.
`_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, 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 stops adding to it. The snapshot is
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.

Not fatal, deliberately: §6 and this module's opening - a broken cache must not
be a broken controller, and an add-on that will not start because /data filled
up is a worse defect than this one. `_save()` reports and returns; the shipped
resolve_tariff() path still gives a provider plan with no §11.4 degradation.
What changed is that the failure is now acted on rather than only logged.

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 moved out from under an open store (mkstemp, ENOENT) -
because the rule is enforced on the outcome and one cause would grade a
coincidence. DOCS.md's "where there is no /data" sentence is widened to the
condition the code now holds to.

MUTATION.md gains what M4 exposed: a mutation whose red is a TRACEBACK reports
however many checks ran before it and grades none after it. M4 first printed
1 FAIL; the true figure is 3. An f-string operand read `len(read.points)`, and
`.points` on a no_data raises `Absence` by design - why()'s rule arriving in
what a check PRINTS rather than in what it asserts. `npts()` is now beside
why(), and a red count is a lower bound until the run is seen to reach the end
of its section.

6 mutations, purged tree, every one RED on named checks, no survivors:
M1 self._days = before -> dict(self._days)          +10 B  9 red
M2 _save() OSError handler returns True             -1 B   9 red
M3 _save() returns True on `not self.ok`            -1 B   3 red
M4 self._days = before -> self._days = {}           -4 B   3 red
M5 copy.deepcopy -> dict                            -9 B   3 red
M6 `if not self.ok: return` back in _remember()     +43 B  1 red

All 29 test files green. Nothing deployed; no option added; config.yaml and
app/ behaviour on a healthy /data untouched. No version number picked - that
is the open decision the Unreleased block names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 08:43:17 +02:00
admin 5059e28860 Merge pull request 'FCST-05: one byte stops stopping the add-on, and the write path gets the read path's standard' (#74) from FCST-05 into release/1.0
Both defects were found by the QA pass on merged FCST-04, and both are the same shape: a corrupt price cache taking the controller with it.

HIGH: _load() caught (JSONDecodeError, OSError) and UnicodeDecodeError is neither - it is a ValueError. One non-UTF-8 byte in /data/price_history.json escaped to Controller.__init__, which is unguarded, and the add-on did not start. The except is now (ValueError, OSError) - the content family beside the bytes family - rather than the two members known today, because a third ValueError subclass nobody thought of is exactly how this arrived. The new fixture writes real bytes and asserts the old fixture's string IS valid UTF-8, so the pair cannot collapse back into one.

MEDIUM: a malformed `start` under the day the feed was writing raised out of _remember() on every refresh forever - the add-on stayed up while the plan retired after 90 min and arbitrage stayed off, with no self-heal, and `words` reported the store healthy throughout. Read and write paths now share one validator, and the merge key is the validated instant (which also fixes a lexicographic sort putting the 25-hour day's second 02:00 first).

Also: the row counts what it says it counts, and two false claims of strengthening in _audit's docstring are corrected - a differential over the realisable domain returns 0 weaker, 0 stronger, and that differential is now a committed check with FCST-03's audit frozen beside it. PROC-01: a right decision with a fabricated reason is still a defect.

_audit completeness was NOT attempted, and the reason is measured rather than asserted: the rule as stated takes the horizon checks red on the shipped fill and a healthy feed, because `published` is the whole read while the fill emits only [since, until). The rule is wrong, not merely absent; making it right needs the window in the signature. Separate card.

Evidence: 29/29 green. 12 mutations, all red, each on a fresh tree copy fingerprinted against clean. One survivor was found and closed during the work - two rungs reported into one last_error, so a -1 byte deletion on the half of the fix that tells an operator the cache self-healed survived the whole suite. TEST-16's masking pattern, inside a check written to close a false-health defect. Recorded in MUTATION.md.
2026-09-10 01:13:41 +02:00
17 changed files with 2256 additions and 61 deletions
+219
View File
@@ -14,6 +14,225 @@ 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 |
| _this branch_ | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
### TARIFF-09 - the feed is asked for the range the caller wanted
**`generic_dayahead` took `since` and `until` and requested the bare feed URL,
which answers one Brussels day.** Everything past today was then filled from
yesterday's profile by FCST-04, flagged provisional, and §8.3 drops provisional
hours from the trade set - so **the optimiser never saw tomorrow as firm, at any
hour of any day.** Measured on `4257e38` at 2026-09-10 09:00 CEST over the
shipped 48 h horizon, live feed, yesterday seeded:
| | 48 h horizon, one-day feed |
|---|---|
| firm | **15 whole hours** (60 quarter-hour points) |
| carried, provisional | **33 hours** - 24 on D+1, 9 on D+2 |
| permitted cycle | buy 11:45, sell 19:45 - **both on 2026-09-10** |
| firm local days | `[2026-09-10]` |
The ceiling was structural, not incidental: with a one-day window, at hour *H*
of the local day a 48 h horizon is at most **24−H hours firm and 24+H carried**.
At 23:00 that is **1 firm hour and 47 carried**, and the remaining firm spread
(0.0464 EUR/kWh) no longer clears the default 0.10 round-trip threshold at all -
so arbitrage was refused outright. This is **not** what FCST-04's fill is for:
that covers the window until the ~13:00-14:00 CET auction, after which a healthy
feed publishes tomorrow in full.
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns 192 quarter-hours across two
local days, committed unmodified beside TARIFF-08's captures. The plugin simply
never asked.
**The range is now derived from `since`/`until` on the BRUSSELS calendar**, as
two inclusive dates. Not the UTC calendar: 22:30 UTC in summer is already
tomorrow in Belgium, so a UTC-derived start asks for the day the controller has
just left and drops the day it is standing in, for the last one or two hours of
every day. That is `price_history._bdate()`'s lesson one layer up, with a worse
consequence - `_bdate()` misfiles an hour, this never fetches it. `until` is
exclusive, so a horizon ending exactly at local midnight asks for one day fewer
than the naive reading gives, and an inverted window collapses to a single day
rather than a backwards range.
Same code path, same window, a feed serving both days:
| | before | after |
|---|---|---|
| firm hours of 48 | 15 | **39** |
| carried, provisional | 33 | 9 |
| firm local days | 1 | **2** |
| permitted cycle | one calendar day | **buys on one day, sells on the next** |
The 9 remaining carried hours are the tail of the horizon that runs into a third
Brussels day, which no day-ahead market publishes. FCST-04 still fills them and
still flags them; what changed is what is firm, never what may be acted on.
**Before publication nothing regresses, and that is graded rather than
assumed.** Asking for tomorrow before the auction returns fewer days than
requested - measured, at 09:46 CEST the two-day request came back byte-identical
to the one-day body - and that stays a `series` of what exists, with FCST-04
filling the remainder. A `no_data` there would switch a working site's arbitrage
off every morning.
⚠️ **`price_feed_url` is the EXACT URL and the plugin appends nothing to it, so
an overriding site keeps the short horizon.** That is a decision, not an
oversight: `start`/`end` are this feed's spelling, the provider declares a second
host whose parameters are different, and rewriting a URL an operator typed is
how a request nobody intended gets made. **It is not silent** - the plugin logs a
`WARNING` at fetch time naming the consequence and the `start..end` it would
otherwise have asked for. See
`DECISIONS/TARIFF-09-the-range-and-the-override.md`.
The plugin contract - `fetch(self, opts, since, until, transport)` hands every
plugin the range - is still **not** enforced, because three shipped plugins point
at endpoints nobody has verified and a rule obliging them to pass a range would
be satisfied by an invented parameter name. Which plugins consume the range is
pinned as a literal in `test_price.py` instead, so the next supplier has to
decide rather than inherit the silence.
### 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
@@ -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.
@@ -0,0 +1,141 @@
# TARIFF-09 — asking the feed for the horizon, and what `price_feed_url` means
Two decisions. The first had no defensible alternative once it was measured;
the second did, and the alternative is argued below rather than dismissed.
---
## 0. The measurement this card exists for
`generic_dayahead.fetch()` accepted `since` and `until` and requested the bare
`https://api.energy-charts.info/price?bzn=BE`. That URL names no range, and the
feed answers **one Brussels day**.
Measured on the merged tree (`4257e38`) at 2026-09-10 09:00 CEST, 48 h horizon,
live feed, yesterday seeded through the shipped read path:
```
60 firm points (15 whole hours) + 33 carried hours of 48 requested
carried: 24 h on 2026-09-11, 9 h on 2026-09-12
arbitrage(0.10): allowed, spread 0.4645, buy 11:45 and sell 19:45 -- BOTH on 2026-09-10
firm local days = [2026-09-10]
```
Everything past today was carry-forward, flagged provisional, and §8.3 drops
provisional hours from the trade set. **The optimiser never saw tomorrow as
firm, at any hour.** The ceiling was structural rather than incidental: with a
one-day window, at hour *H* of the local day a 48 h horizon is at most
**24−H hours firm and 24+H carried** — 15/33 at 09:00, and **1/47 at 23:00**,
where the remaining firm spread (0.0464 EUR/kWh) no longer even clears the
default 0.10 round-trip threshold and arbitrage is refused outright.
That is not what FCST-04's fill was built for. FCST-04 covers the
pre-publication window until the ~13:00–14:00 CET auction, after which a
healthy feed hands over tomorrow in full.
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns **192 quarter-hours across
two local days**, committed unmodified as
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`. The
plugin simply never asked.
After the change, same code path, same window, a feed serving both days:
**39 firm hours and 9 carried**, firm across **two** Brussels days, and the
permitted cycle buys on one day and sells on the other. Graded in
`test_price_history.py` section 1c against both feed states.
---
## 1. The range is derived on the **Brussels** calendar, from `since`/`until`
`since` and `until` are **instants**; the API takes **dates**. Those are not
the same question, and the conversion is where the day gets lost.
`brussels_date_range()` returns two inclusive `date`s:
* the first is `brussels(since).date()`. **Not `since.date()`, and not the UTC
date.** 22:30 UTC in summer is already tomorrow in Belgium, so a UTC-derived
start asks for the day the controller has just left and drops the day it is
standing in — for the last one or two hours of *every* day. This is
`price_history._bdate()`'s lesson one layer up, with a worse consequence:
`_bdate()` misfiles an hour, this never fetches it.
* the last is `brussels(until).date()`, **stepped back a day when `until`
falls exactly on local midnight**, because `until` is exclusive and that
instant is the first tick of a day nobody asked for. Without it every
whole-hour horizon requests one extra day — harmless on this feed, but it
makes the request a claim the caller never made.
* an empty or inverted window collapses to a single day rather than producing
a backwards range, which no feed has a defined answer for.
Both ends are graded, and both are driven red by mutation — including the two
that swap `brussels()` for the UTC date (`test_price.py` section 2c).
---
## 2. `price_feed_url` is the **exact URL**, used as given. The plugin appends
nothing.
The alternative — merge our `start`/`end` into whatever query string the
operator supplied — was considered and rejected. Three reasons, in the order
they decided it:
1. **`start`/`end` are energy-charts' spelling, and this provider declares a
second host.** `generic_dayahead.capabilities().egress_hosts` is
`("api.energy-charts.info", "web-api.tp.entsoe.eu")`. That second host's
day-ahead date parameters have not been verified by this card and are not
guessed at here — but they are not `start`/`end`. So an override to the
*other declared host* — a case the declaration itself anticipates — is
exactly where merging appends two parameters that host ignores, producing
the short horizon anyway, with a URL nobody typed as the receipt.
2. **Merging means re-serialising a URL somebody else wrote.** Parsing and
rebuilding reorders parameters, collapses duplicates and re-escapes values.
"Override the feed" is what the option already says it means, and an
override that is silently rewritten is not one.
3. **Blind appending is not on the table at all.** `dated_feed_url()` refuses
to add a query string to a base that already has one, so
`?bzn=BE?bzn=BE&start=…` cannot be produced by this module even by a later
edit that folds the parameters back into the constant.
⚠️ **The cost is real and it is therefore NOT a doc footnote.** An overriding
operator gets exactly the short horizon this card exists to fix. So the plugin
emits a `WARNING` on the shipped `goodwe.price` logger at **fetch time**, naming
the consequence — one Brussels day, the rest carried and flagged, §8.3 dropping
them from the trade set — and naming the `start..end` it *would* have asked
for. §11.4 is what makes a price defect quiet; an operator who has taken the
short horizon has to be able to find out from the log that they did.
The warning is graded, with a control that the default path stays silent, and
a mutation that downgrades it to `debug()` takes that check red.
**Fetch time rather than startup**, because that is where the URL actually in
use is known. The trade-off is one line per refresh (the tariff task, not the
30 s control tick). If the status page ever grows a price-source row, this is
the sentence it should carry.
---
## 3. The plugin contract: `fetch(self, opts, since, until, transport)`
The signature promises every plugin the range, and until this card **none of
the four networked plugins used it**. The obvious follow-up is to enforce the
promise. It is **not** enforced, and the reason is TARIFF-08's:
Three of the five shipped plugins point at endpoints nobody has ever verified
(TARIFF-07 is the open decision on withdrawing them). A rule obliging them to
pass a date range would be satisfied by an **invented parameter name** — which
is the parser-graded-against-its-own-guess defect moved from the response to
the request, and it would ship looking like a guarantee.
What exists instead is a **roster pin** in `test_price.py` section 2c: which
plugins reference their `since`/`until` is asserted as a literal, derived from
the registry and the AST. A sixth supplier, or a change to any of the three,
has to edit that line and make the decision rather than inherit the silence.
It is named as a source-shape pin, not as evidence that any feed parses;
`generic_dayahead`'s behaviour is graded separately by the URL it really
requests.
**Recommendation for whoever picks up TARIFF-07:** enforce it at the moment
those three endpoints are either verified or withdrawn, not before. If they are
withdrawn, `generic_dayahead` and `manual_csv` are the whole roster and the
rule is one line — `manual_csv` reads a whole file and is windowed downstream,
so it would be the single documented exemption.
+59 -5
View File
@@ -1360,7 +1360,7 @@ does, and only after you have read why.
| `price_taxes_eur_kwh` | Levies and VAT, EUR/kWh. Same rule |
| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below |
| `price_csv_path` | Where `manual_csv` reads its file |
| `price_feed_url` | Override the plugin's default feed. Only a host the plugin itself declares is accepted |
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
| `price_horizon_h` | How far ahead to ask for, hours |
| `price_timeout_s` | Per-request timeout |
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
@@ -1395,6 +1395,50 @@ 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.
* The request **names the days it wants**, derived from the horizon on the
**Brussels** calendar — `?bzn=BE&start=2026-09-09&end=2026-09-10`, two
inclusive local dates. Without that the feed answers a single day, and
everything past today would be carried forward from yesterday's profile and
flagged provisional — which §8.3 excludes from arbitrage, so tomorrow would
never be tradeable at any hour. The calendar is Brussels rather than UTC
because 22:30 UTC in summer is already tomorrow in Belgium.
**What you get before the auction publishes.** The day-ahead auction clears at
about 13:00–14:00 CET. Ask for tomorrow before that and the feed returns fewer
days than requested — that is normal, not a failure. The add-on keeps the
series of what exists and fills the remaining hours from yesterday's profile,
flagged provisional; arbitrage runs on the firm hours and ignores the flagged
ones. So a dynamic site's plan is part firm and part carried every morning and
fully firm once tomorrow is published, and neither state is an error.
⚠️ **If you set `price_feed_url`, you keep the short horizon.** The override is
sent exactly as you wrote it and the add-on adds no `start`/`end` to it, because
those are this feed's parameter names and an override may point somewhere with
different ones — rewriting a URL you typed is how a request nobody intended gets
made. The consequence is a **`WARNING` in the log on every price refresh**,
naming the range that would otherwise have been requested. If you only wanted a
different host and not a different horizon, the fix is to clear the option.
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
@@ -1488,10 +1532,20 @@ first fill is possible the day after the first refresh.
The same applies to a file that is *partly* broken: a day record this build
cannot read is dropped, the row says how many were dropped and why, and the rest
of the file still loads. And where there is no `/data` at all the store records
**nothing** — not even in memory. An in-memory yesterday would answer for one
run and vanish on the next restart, which is a cache that forgets without saying
so; `unavailable` and *no prices carried* are the honest pair.
of the file still loads. And a store that **cannot persist records nothing** —
not even in memory. That covers a missing `/data`, and it also covers a `/data`
that is there and cannot be **written**: a full volume, a read-only remount. An
in-memory yesterday would answer for one run and vanish on the next restart,
which is a cache that forgets without saying so; the row naming the reason and
*no prices carried* are the honest pair.
⚠️ **What a failed write does not do is throw away what is already in the
file.** A volume that fills at 14:00 leaves the yesterday written at 13:30 still
answering, because that record survives a restart — the store stops *adding* to
it, nothing more. And it never stops the add-on: a write that cannot complete is
logged and reported on the status row, never raised. A cache that cannot answer
is not allowed to be a controller that cannot regulate, and that applies to the
volume being full exactly as it applies to the volume being absent.
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
often near zero, which by itself strongly favours self-consumption over export.
+101
View File
@@ -142,6 +142,28 @@ 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
@@ -673,6 +695,84 @@ 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.
20. ⚠️ **A FIXTURE GRADES THE RESPONSE. NOTHING ABOUT THE REQUEST IS REACHABLE
FROM ONE, AND THE TRANSPORT HAD BEEN RECORDING IT ALL ALONG.** TARIFF-09,
and it is habit 19's closed loop with the other half still shut.
`generic_dayahead.fetch()` took `since` and `until` and asked for the bare
`?bzn=BE`, which answers ONE Brussels day - so §8.3 dropped every hour past
today from the trade set as provisional and the optimiser never saw
tomorrow as firm, at any hour of any day. Measured at 09:00: **15 firm
hours and 33 carried of 48**, both ends of the permitted cycle inside one
calendar day; at 23:00, **1 and 47**, with the firm spread no longer
clearing the round-trip cost.
TARIFF-08 had just fixed the RESPONSE side of this same plugin by
committing real bytes. It could not have caught this: a captured body is
the answer to a URL, and **no property of any body says which URL was
asked for**. `canned()` in `test_price.py` had recorded `(url, timeout_s)`
on every call since TARIFF-02 and not one check had ever asserted on the
first element - so the harness *knew* the plugin was ignoring its arguments
and nothing asked it. The fix is one line of habit: **where a plugin takes
arguments that shape a request, assert on the REQUEST off the injected
transport, not only on what comes back.** Section 2c does, and the
shipped-defect mutation (restore the bare URL, `-173 B`) is red on six
named checks in `test_price.py` and two in `test_price_history.py`.
⚠️ **AND A MUTATION THAT TURNS A READ INTO `no_data` CRASHES A SUITE
INSTEAD OF NAMING A CHECK, UNLESS THE CHECK EXPECTS IT.** `_NoData.points`
raises `Absence` by design, so `derived(...) < derived(...)` over two
absent reads is a `TypeError` and `[p for p in plan.read.points]` is a
traceback - habit 5's shape with an exception in place of a hang. The
mutation that matters here is the "defensive" one somebody writes after
reading this card: *refuse when the feed returned fewer days than we asked
for*. It is exactly what a healthy feed does every morning before the
~13:00-14:00 CET auction, and switching arbitrage off for it would be a
worse defect than the one being fixed. It is red BY NAME in
`test_price.py` (33 checks, the AC's own line among them) and takes
`test_price_history.py` down at **FCST-04's section 1**, whose `_before`
read is unguarded - a red, but an unnamed one. That is recorded rather than
retrofitted: guarding another card's section is a change to what its
checks report, and it belongs to whoever next touches it.
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
@@ -730,6 +830,7 @@ commit and PR bodies.
| **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. |
| **MAINT-05 round 3**, `migrate_maint_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **was a survivor — now CLOSED** | measured against head `65cddf3` under the purge, tree mounted `:ro`, `19/19 PASS`. It stamps the schema marker on a migration that LOST state, defeating the older `lost` guard as well as the new `misdirected` one, because every check that reached the marker did so on a store whose `kept` list was empty. Closed by `test_maintenance.py` **9d**; the mutation is now red on three named checks. |
| **MAINT-05 round 3**, `migrate_unit_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **found here — SAFETY-02's half, now CLOSED** | the SAME mutation one function up, on the LATCH migration rather than the history one. Measured a survivor against this branch under the purge, `:ro`, `19/19 PASS`, then closed by `test_inverter.py` part 10's new fixture — a store with `unit/inverter-a/sign_reason` already present (so `kept` is non-empty) whose `sign_tripped: true` write is dropped. Shipped: marker withheld. Mutant: marker stamped on a **disarmed latch**, and the next start skips the migration that would have re-armed it. |
| **TARIFF-09** 11 mutations over `app/price.py` and the two-day capture, all red, no survivors | **not at risk** | direction, and method: every mutation was applied to a FRESH `cp -a` of the clean worktree rather than reverted, so there is no revert to verify (habit 18) and no shared scratch path two runs could collide on. Each ran through `tools/mutation_run.sh`, which purges and fingerprints. Byte deltas recorded on the PR; one is **+0 B** - a single price digit edited inside the committed capture, the risky delta - and the re-derived sha256 catches it. |
| **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. |
**Nothing here says a past result is wrong.** The two survivors re-run above
+330 -4
View File
@@ -140,6 +140,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from textwrap import dedent
from urllib.parse import urlencode
_LOG = logging.getLogger("goodwe.price")
@@ -184,6 +185,87 @@ def brussels(instant: datetime) -> datetime:
return instant.astimezone(brussels_offset(instant))
def brussels_date_range(since: datetime, until: datetime) -> tuple:
"""The Brussels calendar days a half-open window [since, until) touches.
Returns `(first, last)`, two `date`s, BOTH INCLUSIVE - which is the shape
`api.energy-charts.info` takes: `?bzn=BE&start=2026-09-09&end=2026-09-10`
answered 192 quarter-hours across exactly those two local days, measured
2026-09-10T07:45:49Z and committed as
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`.
THE DEFECT THIS EXISTS TO PREVENT (TARIFF-09). `generic_dayahead.fetch()`
accepted `since` and `until` and requested the bare default URL, so the
feed answered ONE Brussels day. Everything past today was then carried
forward from yesterday's profile, flagged provisional, and §8.3 dropped all
of it from the trade set - so the optimiser never saw tomorrow as firm, at
any hour. Measured on the merged tree at 2026-09-10T09:00+02:00 over a 48 h
horizon: 15 firm whole hours and 33 carried, and both ends of the only
permitted cycle inside ONE calendar day. The ceiling was structural: at
hour H of the local day a one-day window gives at most 24-H firm hours and
24+H carried - 1/47 at 23:00, where the firm spread no longer even clears
the round-trip cost. That is not what FCST-04's fill is for; it covers the
pre-publication window until the ~13:00-14:00 CET auction, after which a
healthy feed hands over tomorrow in full.
⚠️ THE CALENDAR IS BRUSSELS, NEVER UTC, AND THIS IS `_bdate()`'s LESSON ONE
LAYER UP. `since`/`until` are INSTANTS; the API takes DATES, and the two are
not the same question. 22:30 UTC in summer is already tomorrow in Belgium,
so a range derived from `since.date()` asks for the wrong FIRST day for the
last one or two hours of every single day - and the day it drops is the one
the controller is standing in. price_history.py made this mistake at the
cache layer and documents the same arithmetic; here it decides what is
fetched at all, so getting it wrong loses the hours rather than misfiling
them.
⚠️ `until` IS EXCLUSIVE, SO LOCAL MIDNIGHT BELONGS TO THE DAY BEFORE IT.
A 48 h horizon from local midnight ends at local midnight two days later,
and that instant is the first tick of a day the caller did not ask for.
Taking `brussels(until).date()` unconditionally would request a THIRD day
on every whole-hour horizon. This feed answers such a request without
complaint, so nothing would report it - which is the reason to get it right
here rather than to rely on the server's tolerance: the range is a claim
about what the caller asked for, and it should be true.
⚠️ AND AN EMPTY OR INVERTED WINDOW COLLAPSES TO ONE DAY RATHER THAN TO A
BACKWARDS RANGE. `start` after `end` is a request no feed has a defined
answer for; some return everything, some return an error, and neither is a
thing to hand a battery controller.
"""
first = brussels(since).date()
edge = brussels(until)
last = edge.date()
if (edge.hour, edge.minute, edge.second, edge.microsecond) == (0, 0, 0, 0):
last = last - timedelta(days=1)
if last < first:
last = first
return first, last
def dated_feed_url(base: str, **params) -> str:
"""`base` plus a query string. Refuses a `base` that already carries one.
⚠️ THE REFUSAL IS THE POINT, NOT THE FORMATTING. Appending `&start=...` to
a URL that may already hold its own query string is how a plugin produces a
request nobody intended - `?bzn=BE?bzn=BE&start=...`, or a duplicated
parameter whose winner is the server's choice rather than ours. So the base
is required to be parameterless and the parameters are added exactly once,
by `urlencode`, which is also what keeps a date out of the escaping
question. A future edit that folds the query back into the constant is a
refused fetch with a sentence, not a silently malformed URL.
⚠️ IT IS NOT REACHED BY AN OPERATOR'S `price_feed_url`. See
`GenericDayAhead.fetch()`: an override is used exactly as given.
"""
if "?" in base or "#" in base:
raise SourceUnavailable(
f"the feed base {base!r} already carries a query string or "
f"fragment, and this builder appends parameters. Adding a second "
f"'?' produces a URL the server parses differently from the one "
f"anybody intended; put the fixed parameters in the call instead")
return f"{base}?{urlencode(params)}" if params else base
# --------------------------------------------------------------------------- #
# Absence, borrowed wholesale from sim/riglock.py (TEST-04).
# --------------------------------------------------------------------------- #
@@ -731,6 +813,24 @@ class PriceProvider(ABC):
`transport(url, timeout_s) -> bytes` is injected so the checks never
touch a network and `manual_csv` never needs one. `opts` is a
`PluginOptions`, so the markup and taxes are not reachable from here.
⚠️ `since`/`until` ARE THE RANGE THE CALLER WANTS, AND A PLUGIN THAT
IGNORES THEM GETS WHATEVER ITS FEED DEFAULTS TO (TARIFF-09). That is
not a small difference: `generic_dayahead` ignored both, the public
EPEX feed answered ONE Brussels day, and §8.3 then dropped every hour
past today from the trade set as provisional - so the optimiser never
saw tomorrow as firm, at any hour of any day, and nothing reported it.
This promise is NOT mechanically enforced, and the honest reason is
TARIFF-08's: three of the five shipped plugins point at endpoints
nobody has verified (TARIFF-07 is the open decision on withdrawing
them), so nobody knows how those feeds spell a date range. A rule
obliging them to pass one would be satisfied by an INVENTED parameter
name - the parser-graded-against-its-own-guess defect, moved from the
response to the request. What exists instead is a roster pin in
test_price.py: which plugins consume the range and which do not is
asserted as a literal, so a sixth supplier, or a change to one of the
three, forces the decision rather than inheriting the silence.
"""
@@ -979,9 +1079,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 +1247,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"
@@ -1001,12 +1271,68 @@ class GenericDayAhead(PriceProvider):
sources=frozenset({Component.ENERGY}),
egress_hosts=("api.energy-charts.info", "web-api.tp.entsoe.eu"))
FEED_BASE = "https://api.energy-charts.info/price"
BZN = "BE"
async def fetch(self, opts, since, until, transport):
url = opts.get("price_feed_url") or \
"https://api.energy-charts.info/price?bzn=BE"
"""Request the range the CALLER asked for, on the Brussels calendar.
⚠️ THE ARGUMENTS ARE USED. Until TARIFF-09 this method took `since` and
`until` and asked for the bare `?bzn=BE`, which answers ONE Brussels
day - so a 48 h horizon was 15 firm hours and 33 carried at 09:00, and
1 firm and 47 carried at 23:00, with the whole trade set inside a
single calendar day at every hour of every day. See
`brussels_date_range()` for the measurement and for why the calendar
is Brussels rather than UTC.
⚠️ AN OVERRIDE IS THE EXACT URL AND THIS METHOD APPENDS NOTHING TO IT,
WHICH COSTS THE OVERRIDING SITE THE RANGE. That is a decision, not an
oversight, and the sentence below is why it is not a footnote:
* `price_feed_url` says "override the plugin's default feed", and an
override that is silently rewritten is not one. Merging parameters
into an operator's query string means parsing and re-serialising a
URL they wrote - which reorders, de-duplicates and re-escapes it,
and can hand the server a request nobody typed.
* `start`/`end` are ENERGY-CHARTS' spelling, and this provider
DECLARES A SECOND HOST: `web-api.tp.entsoe.eu`. Nothing here has
verified that host's day-ahead parameters and nothing here should
guess them - but they are not `start`/`end`, so an override to the
other DECLARED host is exactly the case where appending our
vocabulary adds two parameters that host ignores and yields the
short horizon anyway, with nothing saying so.
* and blind appending is refused outright: `dated_feed_url()` will
not add a query string to a base that has one.
So the cost is real and it is REPORTED, at fetch time, on the shipped
logger, naming the horizon consequence rather than the substitution.
§11.4 is what makes a price defect quiet; an operator who has taken the
short horizon has to be able to find out from the log that they did.
"""
override = str(opts.get("price_feed_url") or "").strip()
if override:
url = override
_LOG.warning(
"price_feed_url is set, so %s is requesting %s exactly as "
"given and is adding NO date range to it. The public feed "
"answers a single Brussels day to a request that names no "
"range, and every hour past that day is then carried forward "
"from yesterday's profile and flagged provisional - §8.3 "
"drops provisional hours from the trade set, so arbitrage "
"would never see tomorrow as firm. Clear price_feed_url to "
"let this plugin ask for the %s..%s it was given.",
self.id, url, *(d.isoformat()
for d in brussels_date_range(since, until)))
else:
first, last = brussels_date_range(since, until)
url = dated_feed_url(self.FEED_BASE, bzn=self.BZN,
start=first.isoformat(),
end=last.isoformat())
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
+105 -18
View File
@@ -61,6 +61,38 @@ yesterday that works all day and is gone on the next restart, which is exactly
the silently-forgetting cache this rule exists to refuse. A store that cannot
persist now records nothing, and both of its answers say the same thing.
⚠️ AND "CANNOT PERSIST" IS ONE QUESTION, ASKED ONCE, ABOUT THE OUTCOME -
FCST-06. The paragraph above enforced that rule against `not self.ok`, which is
one SYMPTOM of not persisting: the durable volume is missing. There is a second,
and FCST-06 measured it. With `ok=True` and the directory present, `_save()` can
still fail - a read-only remount (`[Errno 30] Read-only file system`), a full
volume (`[Errno 28] No space left on device`) - and it logs, records
`last_error` and returns. `_remember()` had already populated `self._days`, so
`words` read `'2026-01-14 (24 points over 24 h); 2026-01-15 (24 points over
24 h) [could not write (...)]'` while the file on disk held `{}`, and
`yesterday()` answered 24 REAL POINTS for the rest of the process's life. Gone
on the next restart, and nothing said it would be - the same silently-forgetting
cache, reached by the condition the rule did not name.
⚠️ SO IT IS NOT TWO RULES, AND IT IS DELIBERATELY NOT TWO TESTS. "Can this
store persist?" CANNOT BE ANSWERED BEFORE THE ATTEMPT - a directory that existed
and was writable a moment ago is not a write that completed - so it is answered
AFTER it, once, by the only thing holding the evidence: `_save()` returns
whether the file now holds what memory holds. A missing volume is one of its
False cases and an `OSError` from the write is another, and `_remember()` KEEPS
ONLY WHAT THAT ONE ANSWER SAYS WAS KEPT. There is no `not self.ok` test in
`_remember()` any more, because there is nothing left for a second test to add -
and a third symptom nobody has thought of is how this defect arrived both times.
⚠️ AND WHAT IS KEPT IS THE LAST STATE THAT IS ON DISK, NOT AN EMPTY ONE. The
MERGE is undone, not the store. A site whose volume fills at 14:00 goes on
answering the yesterday it wrote at 13:30 - that record survives a restart, so
answering it is not a promise the store cannot keep. What it stops doing is
adding to it. The invariant the rule was always describing, stated once:
`self._days` NEVER HOLDS A POINT THE FILE DOES NOT. (Not "mirrors the file" -
`_usable_days()` drops records the file still holds. The only direction that can
invent a yesterday is the one named.)
THE FOUR RULES THIS FILE IS SHAPED BY
=====================================
@@ -148,6 +180,7 @@ and turning it on is a product decision, not a wiring one. See
DECISIONS/FCST-04-the-day-that-is-carried.md.
"""
import copy
import json
import logging
import os
@@ -333,6 +366,14 @@ class PriceHistory:
site's first day, and a different thing from a store that could not be
opened. Both are visible: `ok`/`last_error` describe the file, and
`yesterday()`'s reason describes the answer.
⚠️ AND `ok` IS NOT "CAN PERSIST" (FCST-06). It says the directory was
there when the store opened, which a full volume and a read-only remount
both leave true. The one thing that answers "can this store persist" is
`_save()`'s return value, and `self._days` NEVER HOLDS A POINT THE FILE DOES
NOT, because `_remember()` undoes a merge that answer refused. That is what
makes `words` and `yesterday()` describe the same store in EVERY degraded
state rather than only in the ones somebody enumerated.
"""
def __init__(self, path: str = "/data/price_history.json"):
@@ -478,14 +519,31 @@ class PriceHistory:
"it", self.path, self.last_error)
return kept
def _save(self) -> None:
def _save(self) -> bool:
"""Write `self._days` out. True only if the file now holds it.
⚠️ THE RETURN VALUE IS THE "CAN THIS STORE PERSIST?" PREDICATE, AND
IT IS THE ONLY ONE (FCST-06). Both ways of not persisting end here and
are the same answer: no durable volume returns False without touching
the disk, an `OSError` out of the write returns False after recording
why. `_remember()` acts on that one answer, so the second place that
used to test the first symptom on its own - and missed the second - has
nothing left to test. See the module docstring's fifth warning.
⚠️ AND IT REPORTS, IT DOES NOT RAISE. §6 and this module's opening: a
broken cache must not be a broken controller. An add-on that will not
start because /data filled up is a worse defect than the one FCST-06
fixed, so a failed write is logged, kept in `last_error` - where `words`
and `yesterday()` both read it - and RETURNED, never thrown.
⚠️ THE TEMP FILE IS REMOVED ON EVERY FAILING PATH. A write that dies
between `mkstemp` and `os.replace` used to leave its temp behind, and
the refresh task runs 48 times a day: a persistent write failure - a
full volume being the likeliest cause - leaked 48 inodes a day onto the
volume that was already the problem.
"""
if not self.ok:
return
# ⚠️ THE TEMP FILE IS REMOVED ON EVERY FAILING PATH. A write that dies
# between `mkstemp` and `os.replace` used to leave its temp behind, and
# the refresh task runs 48 times a day: a persistent write failure -
# a full volume being the likeliest cause - leaked 48 inodes a day onto
# the volume that was already the problem.
return False
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path))
@@ -498,12 +556,14 @@ class PriceHistory:
except OSError as err:
self.last_error = f"could not write ({err})"
_LOG.error("could not persist price history: %s", err)
return False
finally:
if tmp is not None:
try:
os.unlink(tmp)
except OSError: # nothing further to try, and a
pass # failed cleanup is not a fault
return True
# -- the one public entry point -----------------------------------------
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
@@ -540,15 +600,6 @@ class PriceHistory:
nothing outside this file names it, and `test_price_history.py` checks
the tree for that rather than trusting the underscore.
"""
if not self.ok:
# ⚠️ A STORE THAT CANNOT PERSIST RECORDS NOTHING. See the module
# docstring's third warning: filling `self._days` here would give a
# site with no /data an in-memory yesterday that answers a series
# all day and is gone on the next restart - a cache that forgets
# without saying so, which is the one thing that rule refuses. The
# honest degraded state is `words` = "unavailable" and `yesterday()`
# = `no_data`, and those two must agree.
return
points = read.match(series=lambda r: r.points, no_data=lambda _r: ())
if not points:
return
@@ -557,6 +608,25 @@ class PriceHistory:
provider = read.match(series=lambda r: r.provider,
no_data=lambda _r: None)
# ⚠️ THE STATE THIS READ STARTED FROM, KEPT SO IT CAN BE PUT BACK
# (FCST-06). Everything below merges into `self._days` IN PLACE, and
# whether any of it may be KEPT is not knowable until `_save()` has
# tried - so the merge runs, and it is undone if the file did not take
# it. There is no `not self.ok` branch above any more: a store with no
# durable volume is one of the answers `_save()` gives, not a separate
# symptom with a test of its own. Module docstring, fifth warning.
#
# ⚠️ DEEP, NOT `dict(self._days)`. A one-level copy shares the day
# RECORDS, and the loop below rebinds keys on the ones it touches - so
# the "previous" state would hand back records the merge had already
# edited, and a store with a good yesterday on disk would go on
# answering the merge of it that never reached the file. It would also
# be right only for as long as nobody makes this loop mutate a `points`
# list or a row in place. A copy that is right by construction outlives
# that, and the store holds at most KEEP_DAYS + 2 records, 48 times a
# day.
before = copy.deepcopy(self._days)
touched = set()
junked = {}
for p in points:
@@ -605,7 +675,9 @@ class PriceHistory:
# 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.
# 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())))
@@ -613,7 +685,22 @@ class PriceHistory:
"without them and the refresh continues",
self.path, self.last_error)
self._prune(stamp)
self._save()
if not self._save():
# ⚠️ A STORE THAT CANNOT PERSIST RECORDS NOTHING - the rule, asked
# ONCE as a question about the outcome instead of tested twice
# against two symptoms of it. Keeping the merge here is exactly the
# cache the module docstring refuses: `yesterday()` would answer a
# series the file does not contain, all day, until a restart
# dropped it without a word - and `words` and `yesterday()` would
# be describing two different stores. The reason is already in
# `last_error` (from `_save()`, or from `_open()` when there is no
# volume at all), so both answers now describe what is on disk.
#
# It does NOT raise, and §6 is why: a broken cache must not be a
# broken controller. `carry()` still returns the fill, the horizon
# is as short as the feed makes it, and the add-on regulates.
self._days = before
return
_LOG.debug("price history: %d point(s) filed under %s; %d day(s) held",
len(points), ", ".join(sorted(touched)), len(self._days))
+8
View File
@@ -318,6 +318,14 @@ options:
# validate.py warns about exactly that.
static_profile: ""
price_csv_path: "/share/goodwe_prices.csv"
# ⚠️ AN OVERRIDE IS THE EXACT URL (TARIFF-09). The plugin sends it verbatim
# and appends nothing - including the `start`/`end` date range it derives from
# the horizon for its own feed - so an override gets whatever range that URL
# answers, which for a bare day-ahead endpoint is ONE Brussels day. Every hour
# past it is then carried from yesterday and flagged provisional, and §8.3
# keeps provisional hours out of the trade set. The plugin logs a WARNING
# naming that consequence on every fetch that uses an override; clearing this
# option is what restores the full horizon.
price_feed_url: ""
price_horizon_h: 48
price_timeout_s: 10
@@ -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":[1788904800,1788905700,1788906600,1788907500,1788908400,1788909300,1788910200,1788911100,1788912000,1788912900,1788913800,1788914700,1788915600,1788916500,1788917400,1788918300,1788919200,1788920100,1788921000,1788921900,1788922800,1788923700,1788924600,1788925500,1788926400,1788927300,1788928200,1788929100,1788930000,1788930900,1788931800,1788932700,1788933600,1788934500,1788935400,1788936300,1788937200,1788938100,1788939000,1788939900,1788940800,1788941700,1788942600,1788943500,1788944400,1788945300,1788946200,1788947100,1788948000,1788948900,1788949800,1788950700,1788951600,1788952500,1788953400,1788954300,1788955200,1788956100,1788957000,1788957900,1788958800,1788959700,1788960600,1788961500,1788962400,1788963300,1788964200,1788965100,1788966000,1788966900,1788967800,1788968700,1788969600,1788970500,1788971400,1788972300,1788973200,1788974100,1788975000,1788975900,1788976800,1788977700,1788978600,1788979500,1788980400,1788981300,1788982200,1788983100,1788984000,1788984900,1788985800,1788986700,1788987600,1788988500,1788989400,1788990300,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":[150.93,151.41,150.99,147.59,153.25,147.26,144.04,141.53,147.79,146.53,143.55,139.93,145.58,139.56,139.11,138.06,138.92,138.52,138.04,138.65,137.29,139.03,144.59,150.34,154.12,174.5,178.43,186.62,199.35,200.21,200.11,197.43,220.07,202.0,196.01,174.03,198.32,167.49,152.54,142.05,161.5,145.45,128.61,100.85,139.18,115.83,94.41,80.39,111.25,89.61,84.99,74.38,80.53,70.58,62.27,57.1,62.24,64.95,68.99,71.19,66.9,87.32,109.0,123.12,68.48,125.53,155.99,161.9,138.77,160.57,167.35,173.0,142.2,172.83,196.19,211.26,205.85,218.77,217.08,222.0,210.98,208.46,208.88,210.02,209.77,202.61,200.27,191.49,194.39,195.04,197.26,190.92,188.39,198.05,203.05,191.84,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 @@
{"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,82 @@
{
"card": "TARIFF-08, extended by TARIFF-09",
"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. TARIFF-09 adds a body from a request that names a DATE RANGE, for the same reason one layer out: the shipped plugin took `since`/`until` and asked for the bare default URL, so the feed answered ONE Brussels day and every hour past today was carried forward and dropped from the trade set as provisional. A two-day body captured from the range request is what makes \"asking for the range gets the range\" checkable against something the plugin did not shape.",
"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 (TARIFF-08's two), 2026-09-10T07:45:49+00:00 (TARIFF-09's)",
"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 sections 2b (TARIFF-08) and 2c (TARIFF-09)",
"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"
],
"also_returned_verbatim_by": {
"url": "https://api.energy-charts.info/price?bzn=BE&start=2026-09-10&end=2026-09-11",
"retrieved_utc": "2026-09-10T07:46:55+00:00",
"note": "TARIFF-09's PRE-PUBLICATION case, and it needed no second file because the answer was BYTE-IDENTICAL to the bare-URL capture above - the same 1877 bytes and the same sha256. Asked for two days at 09:46 CEST, before the ~13:00-14:00 CET auction publishes 2026-09-11, the feed returned the ONE day that exists. That is the short answer this card requires to stay a `series` of what exists rather than becoming `no_data`, and the replay re-derives the identity from the bytes rather than restating it."
}
},
"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"
]
},
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json": {
"url": "https://api.energy-charts.info/price?bzn=BE&start=2026-09-09&end=2026-09-10",
"retrieved_utc": "2026-09-10T07:45:49+00:00",
"note": "TARIFF-09. A RANGE request, and the counterfactual the ticket is built on: `start`/`end` are INCLUSIVE Brussels calendar dates and this one answers 192 quarter-hours across TWO local days, where the bare `?bzn=BE` of the same endpoint answers 96 across one. The shipped `generic_dayahead` now derives these two dates from the `since`/`until` it is handed.",
"covers_local_days": [
"2026-09-09",
"2026-09-10"
],
"granularity_s": 900,
"intervals": 192,
"bytes": 3579,
"sha256": "4d83f5efb4294a4a682562eac619bf35e45c95edde957bfa94c402e1777cc9b3",
"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"
]
}
}
}
+623 -20
View File
@@ -45,20 +45,23 @@ Read MUTATION.md before you report a survivor.
import ast
import asyncio
import hashlib
import inspect
import io
import json
import logging
import os
import sys
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from textwrap import dedent
from app.price import (
ALL_IN, Absence, BANNED_IN_PLUGIN_CODE, Component, FrameworkOption,
MAX_PLAUSIBLE_EUR_KWH, code_leaks,
PRICE_OPTION_DEFAULTS, PROVIDERS, PluginOptions, PricePoint, PriceProduct,
PriceProvider, PriceRead, ProviderCapabilities, RawPoint, SourceUnavailable,
arbitrage_allowed, assemble, brussels, brussels_offset, check_egress,
arbitrage_allowed, assemble, brussels, brussels_date_range,
brussels_offset, check_egress, dated_feed_url,
price_option,
export_price_eur_kwh, load_prices, plan_is_provisional, register,
volatility_regime,
@@ -148,6 +151,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 +287,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 +319,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 +358,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 +456,528 @@ 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"all three captured bodies are the bytes the provenance file "
f"recorded {sorted(_REAL)}",
len(_REAL) == 3 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 every one is 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("2c. TARIFF-09 - the RANGE the caller asked for, on the Brussels "
"calendar")
#
# ⚠️ WHAT WAS WRONG, AND WHY NO CHECK IN THIS FILE COULD SEE IT.
# `generic_dayahead.fetch()` accepted `since` and `until` and requested the
# bare `https://api.energy-charts.info/price?bzn=BE`, which answers ONE
# Brussels day. Everything past today was then filled from yesterday's profile
# by FCST-04, flagged provisional, and §8.3 dropped all of it from the trade
# set - so the optimiser never saw tomorrow as firm, at any hour of any day.
# Measured on the merged tree at 2026-09-10T09:00+02:00 over a 48 h horizon:
# 15 firm whole hours and 33 carried, both ends of the only permitted cycle
# inside ONE calendar day; and at 23:00, 1 firm hour and 47 carried, where the
# firm spread no longer clears the round-trip cost at all.
#
# The reason section 2b could not catch it is that section 2b fed the parser
# BYTES and never looked at the REQUEST. `canned()` has always recorded the URL
# it was asked for and nothing asserted on it - so the transport knew the
# plugin was ignoring its arguments and no check ever asked. The checks below
# assert on the URL, and the two-day body they replay is a capture of what that
# URL really answers.
_TD = "energy-charts-be-price-2026-09-09_2026-09-10-twoday.json"
# ⚠️ NON-DEGENERACY FIRST (habit 1): the two-day capture must really cover TWO
# Brussels days and the one-day capture ONE, or "the range reached the feed"
# is graded against two fixtures that are the same shape.
_bdays = {n: sorted({brussels(datetime.fromtimestamp(t, UTC)).date().isoformat()
for t in json.loads(b.decode("utf-8"))["unix_seconds"]})
for n, b in _REAL.items()}
check(f"the captures span the local-day counts this section needs - "
f"{ {n: len(v) for n, v in _bdays.items()} } - or the range check has "
f"nothing to distinguish {_bdays[_TD]} from {_bdays[_QH]}",
len(_bdays[_TD]) == 2 and len(_bdays[_QH]) == 1
and len(_bdays[_H1]) == 1)
_npts = {n: len(json.loads(b.decode("utf-8"))["price"])
for n, b in _REAL.items()}
check(f"...and the two-day body carries 192 quarter-hours where the one-day "
f"body carries 96, which is the counterfactual this card is built on "
f"{_npts}",
len(json.loads(_REAL[_TD].decode("utf-8"))["price"]) == 192
and len(json.loads(_REAL[_QH].decode("utf-8"))["price"]) == 96)
# --- the URL the SHIPPED plugin asks for ----------------------------------- #
def asked_url(since, until, extra=None):
"""The URL `generic_dayahead` really requests for a window. Behaviour,
not source text: it comes back off the injected transport."""
t = canned(_REAL[_TD])
o = opts(price_provider="generic_dayahead", **(extra or {}))
run(load_prices(o, since, until, transport=t))
return t.calls[0][0]
# The expectation is built HERE, from the window, by different arithmetic than
# price.py uses: a literal string with the two dates spelled out.
_w0 = datetime.fromisoformat("2026-09-09T09:17:43+02:00")
check(f"a 48 h horizon from 09:17 local asks the feed for the three Brussels "
f"days it touches -> {asked_url(_w0, _w0 + timedelta(hours=48))!r}",
asked_url(_w0, _w0 + timedelta(hours=48))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-11")
check("...and the shipped default no longer asks the bare URL that answers a "
"single day",
asked_url(_w0, _w0 + timedelta(hours=48))
!= "https://api.energy-charts.info/price?bzn=BE")
# ⚠️ THE BRUSSELS/UTC BOUNDARY, WHICH IS THE TRAP. `since`/`until` are
# INSTANTS and the API takes DATES. At 22:30 UTC in September Belgium is
# already on the NEXT calendar day, so a range derived from `since.date()` -
# or from `since.astimezone(utc).date()`, which is the same thing - asks for
# the day the controller has just left and drops the day it is standing in.
# This is `_bdate()`'s lesson one layer up: price_history.py MISFILES an hour
# when it gets this wrong; here the hours are never fetched at all.
_edge = datetime.fromisoformat("2026-09-10T22:30:00+00:00")
check(f"the precondition: {_edge.isoformat()} really is a different day in "
f"Brussels ({brussels(_edge).date().isoformat()}) than in UTC "
f"({_edge.date().isoformat()}), or the boundary check grades nothing",
brussels(_edge).date().isoformat() != _edge.date().isoformat())
check(f"...so the range starts on the BRUSSELS day, 2026-09-11, and not on "
f"the UTC day 2026-09-10 -> "
f"{asked_url(_edge, _edge + timedelta(hours=12))!r}",
asked_url(_edge, _edge + timedelta(hours=12))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-11&end=2026-09-11")
# ...and the SAME trap on the other end. `until` at 22:30 UTC is already
# 00:30 of the next Brussels day, so a UTC-derived END stops one day short and
# the last hours of the horizon are never fetched. Both ends of the range
# derivation are graded, or a mutation only has to get one of them right.
_edge2_a = datetime.fromisoformat("2026-09-09T12:00:00+00:00")
_edge2_b = datetime.fromisoformat("2026-09-10T22:30:00+00:00")
check(f"the precondition: {_edge2_b.isoformat()} is "
f"{brussels(_edge2_b).date().isoformat()} in Brussels and "
f"{_edge2_b.date().isoformat()} in UTC, so the two answers differ",
brussels(_edge2_b).date().isoformat() != _edge2_b.date().isoformat())
check(f"...so the range ENDS on the Brussels day 2026-09-11, not on the UTC "
f"day 2026-09-10 -> {asked_url(_edge2_a, _edge2_b)!r}",
asked_url(_edge2_a, _edge2_b)
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-11")
# ⚠️ `until` IS EXCLUSIVE, so local midnight belongs to the day BEFORE it. A
# 24 h horizon from local midnight is ONE day, not two.
_mid = datetime.fromisoformat("2026-09-09T00:00:00+02:00")
check(f"a horizon ending exactly at local midnight asks for one day, because "
f"`until` is exclusive -> {asked_url(_mid, _mid + timedelta(hours=24))!r}",
asked_url(_mid, _mid + timedelta(hours=24))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-09")
check(f"...while one second past midnight is two days, so the rule is the "
f"exclusive END and not a fixed subtraction -> "
f"{asked_url(_mid, _mid + timedelta(hours=24, seconds=1))!r}",
asked_url(_mid, _mid + timedelta(hours=24, seconds=1))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-10")
check(f"an empty or inverted window collapses to a single day rather than "
f"asking for a backwards range no feed has a defined answer for -> "
f"{asked_url(_mid, _mid - timedelta(hours=5))!r}",
asked_url(_mid, _mid - timedelta(hours=5))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-09")
# The helper on its own, over the DST transitions, where a fixed-offset
# assumption and a correct one disagree.
_mar = datetime.fromisoformat("2026-03-29T00:30:00+00:00") # 02:30 CEST
_bdr = derived(lambda: brussels_date_range(_mar, _mar + timedelta(hours=1)))
check(f"brussels_date_range() is right across the spring transition, where "
f"00:30 UTC is already 02:30 local -> {_bdr}",
_bdr == (date(2026, 3, 29), date(2026, 3, 29)))
# --- the two-day body, end to end through the SHIPPED parser --------------- #
_two = load("generic_dayahead", transport=canned(_REAL[_TD]))
check(f"the shipped generic_dayahead parses the real two-day body "
f"{_two.words}", _two.kind == "series")
def _series_days(read):
return derived(lambda: sorted({brussels(p.start).date().isoformat()
for p in read.points}))
_two_days = _series_days(_two)
check(f"...and the series it produces spans TWO Brussels local days "
f"{_two_days}, where the one-day body's spans one "
f"{_series_days(_REPLAY[_QH])}",
_two_days == ["2026-09-09", "2026-09-10"]
and len(_two_days) == 2)
check(f"...one point per published interval, 192 of them, no decimation and "
f"no day dropped ({derived(lambda: len(_two.points))})",
derived(lambda: len(_two.points)) == 192)
# --- the SHORT ANSWER, which is normal and must stay a series -------------- #
#
# ⚠️ ASKING FOR TOMORROW BEFORE THE ~13:00-14:00 CET AUCTION RETURNS FEWER DAYS
# THAN REQUESTED, AND THAT IS NOT AN ERROR. Measured: at 2026-09-10T07:46:55Z
# the request `?bzn=BE&start=2026-09-10&end=2026-09-11` came back BYTE-IDENTICAL
# to the bare-URL capture - one day, 96 quarter-hours. A `no_data` there would
# switch a working site's arbitrage off every morning, so the requirement is
# that the short body stays a `series` of what EXISTS and FCST-04's fill covers
# the rest with flagged, provisional hours. The identity is re-derived from the
# bytes rather than restated.
_prepub = _CAPTURES["files"][_QH]["also_returned_verbatim_by"]
check(f"the pre-publication range request {_prepub['url']!r} returned the "
f"SAME BYTES as the bare default URL - one day where two were asked for",
hashlib.sha256(_REAL[_QH]).hexdigest()
== _CAPTURES["files"][_QH]["sha256"]
and "start=2026-09-10&end=2026-09-11" in _prepub["url"])
check(f"...and the shipped parser answers that short body with a SERIES of "
f"what exists, never no_data - {_REPLAY[_QH].words}",
_REPLAY[_QH].kind == "series"
and derived(lambda: len(_REPLAY[_QH].points)) == 96)
# ⚠️ THE COUNTS ARE TAKEN THROUGH `derived()` AND COMPARED ONLY WHEN BOTH ARE
# NUMBERS. A mutation that turns the short answer into `no_data` makes
# `.points` raise `Absence` by design, and `_Raised < _Raised` is a TypeError -
# which takes the file down with a traceback instead of reporting a FAIL. A
# check whose failure mode is a crash has no name attached (habit 5's shape,
# with an exception in place of a hang).
_n_short = derived(lambda: len(_REPLAY[_QH].points))
_n_two = derived(lambda: len(_two.points))
check(f"...which is strictly fewer points than the two-day body "
f"({_n_short} < {_n_two}), so 'short' is a real property of this "
f"stimulus and not a name for it",
isinstance(_n_short, int) and isinstance(_n_two, int)
and _n_short < _n_two)
# --- price_feed_url: the exact URL, and the cost said out loud ------------- #
#
# ⚠️ THE DECISION. An override is used VERBATIM and this plugin appends
# nothing to it. Merging our `start`/`end` into an operator's query string
# would mean re-serialising a URL they wrote, and `start`/`end` are
# energy-charts' spelling - and this provider also DECLARES a SECOND host,
# `web-api.tp.entsoe.eu`, whose date parameters are not those and are not
# verified here, so appending our vocabulary there yields two ignored
# parameters and the short horizon anyway. The cost is
# that an overriding site silently gets the horizon this card exists to fix,
# so it is NOT silent: a warning names the consequence at fetch time.
_ovr = "https://api.energy-charts.info/price?bzn=BE&somethingelse=1"
check(f"an override is requested EXACTLY as given, with nothing appended -> "
f"{asked_url(_w0, _w0 + timedelta(hours=48), {'price_feed_url': _ovr})!r}",
asked_url(_w0, _w0 + timedelta(hours=48),
{"price_feed_url": _ovr}) == _ovr)
_obuf = io.StringIO()
_oh = logging.StreamHandler(_obuf)
logging.getLogger("goodwe.price").addHandler(_oh)
asked_url(_w0, _w0 + timedelta(hours=48), {"price_feed_url": _ovr})
_ovr_log = _obuf.getvalue()
_obuf2 = io.StringIO()
_oh2 = logging.StreamHandler(_obuf2)
logging.getLogger("goodwe.price").removeHandler(_oh)
logging.getLogger("goodwe.price").addHandler(_oh2)
asked_url(_w0, _w0 + timedelta(hours=48))
_dflt_log = _obuf2.getvalue()
logging.getLogger("goodwe.price").removeHandler(_oh2)
check(f"...and the operator is TOLD what it costs them, at fetch time, naming "
f"the horizon consequence rather than only the substitution",
"price_feed_url" in _ovr_log and _ovr in _ovr_log
and "provisional" in _ovr_log and "2026-09-11" in _ovr_log)
# ⚠️ THE CONTROL. Without it, "warns about everything" passes the line above.
check("...while the DEFAULT path says nothing, so the warning is about the "
"override and not about fetching",
"price_feed_url is set" not in _dflt_log)
# The builder refuses to append to a base that already has a query string,
# which is the "URL nobody intended" this decision is meant to make
# unreachable. Driven, not read off the source.
check(f"dated_feed_url() refuses a base that already carries a query string, "
f"rather than producing a second '?' - "
f"{_reason(lambda: dated_feed_url('https://h/p?a=1', b='2'))!r}",
raises(lambda: dated_feed_url("https://h/p?a=1", b="2"),
SourceUnavailable))
check("...and a clean base still builds, so the refusal is the existing query "
"string and not the builder",
dated_feed_url("https://h/p", b="2") == "https://h/p?b=2")
# --- the plugin contract: who consumes the range, pinned ------------------- #
#
# ⚠️ THIS IS A SOURCE-SHAPE PIN AND IS NAMED AS ONE (habit 4). The claim is
# about the CODE - which plugins reference the range arguments they are handed
# - which is the same kind of claim `code_leaks()` makes, and it is not
# evidence that any feed parses. `generic_dayahead`'s BEHAVIOUR is graded above
# by the URL it really requests.
#
# It exists because `fetch(self, opts, since, until, transport)` promises every
# plugin the range and the promise is NOT enforced. Enforcing it would oblige
# the three plugins whose endpoints nobody has verified (TARIFF-07) to invent a
# date-parameter vocabulary - TARIFF-08's defect moved from the response to the
# request. So the split is pinned instead, and a sixth supplier has to change
# this line rather than inherit the silence.
def _uses_range(cls):
src = dedent(inspect.getsource(cls.fetch))
tree = ast.parse(src)
names = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
return bool(names & {"since", "until"})
_uses = sorted(pid for pid, cls in PROVIDERS.items() if _uses_range(cls))
check(f"the plugins that CONSUME their `since`/`until` are {_uses}; the rest "
f"take the range and ignore it, which is what TARIFF-09 fixed for "
f"generic_dayahead and what TARIFF-07 has to decide for the others",
_uses == ["generic_dayahead"])
check("...and `manual_csv` is honestly among the ones that do not: it reads a "
"whole file and the fill windows it downstream, so a range parameter "
"would have nowhere to go",
"manual_csv" in sorted(set(PROVIDERS) - set(_uses)))
# --------------------------------------------------------------------------- #
print()
print("3. all-in: energy + markup + taxes, added exactly once (AC1, trap 1)")
@@ -444,12 +1026,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 +1358,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 +1493,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 +1641,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 +1767,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 +1779,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))))
# --------------------------------------------------------------------------- #
+441 -8
View File
@@ -288,9 +288,25 @@ def allin(mwh):
def feed_body(instants, table):
return json.dumps({"data": [
{"start": u.isoformat(), "price_eur_mwh": table[brussels(u).hour]}
for u in instants]}).encode()
"""A `generic_dayahead` body in the COLUMNAR shape the real feed publishes.
⚠️ TARIFF-08. This used to build `{"data": [{"start": ...,
"price_eur_mwh": ...}]}` - a shape `api.energy-charts.info` has never
published and that the shipped parser was nevertheless written to expect.
Every price fixture in the repository fed that invented shape, so the
suite graded the parser against the parser's own assumption while the live
plugin raised `SourceUnavailable` on every fetch. Real captured bodies are
replayed in test_price.py section 2b; the fixtures here are SYNTHETIC
prices in the REAL shape, because this file is grading the fill and the
history, not the parse.
"""
return json.dumps({
"unix_seconds": [int(u.timestamp()) for u in instants],
"price": [table[brussels(u).hour] for u in instants],
"unit": "EUR / MWh",
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
"from Bundesnetzagentur | SMARD.de",
"deprecated": False}).encode()
def transport_for(body):
@@ -299,8 +315,12 @@ def transport_for(body):
return _t
# ⚠️ NO `price_feed_url` (TARIFF-09). An override is used VERBATIM and the
# plugin appends no date range to it, so a CFG that sets one - even to the
# feed's own address - would exercise the override path in every check in this
# file and never the shipped default. The override has its own grading in
# test_price.py section 2c.
CFG = {"price_provider": "generic_dayahead",
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
"price_timeout_s": 1,
"price_markup_eur_kwh": MARKUP,
"price_taxes_eur_kwh": TAXES,
@@ -501,6 +521,220 @@ check("...and the plan is still a provider plan, not a §11.4 fallback: the "
_after.origin == "provider" and _after.product is PriceProduct.DAY_AHEAD)
# --------------------------------------------------------------------------- #
print()
print("1c. TARIFF-09 - the feed is now ASKED for the horizon, and tomorrow "
"comes back FIRM")
#
# ⚠️ WHAT SECTION 1 COULD NOT SEE, AND WHY. Everything above grades the FILL
# against a feed that published one day, and it was right to: FCST-04 covers
# the window between midnight and the ~13:00-14:00 CET auction. But
# `generic_dayahead.fetch()` accepted `since`/`until` and requested the bare
# `?bzn=BE`, which answers one Brussels day AT EVERY HOUR OF EVERY DAY - so the
# one-day feed was not the pre-publication state, it was the ONLY state, and
# §8.3 dropped every hour past today from the trade set as provisional. The
# optimiser never saw tomorrow as firm.
#
# Measured on the merged tree (4257e38) at 2026-09-10T09:00+02:00 over a 48 h
# horizon, live feed, yesterday seeded: 15 firm whole hours and 33 carried,
# and BOTH ends of the permitted cycle inside 2026-09-10. At 23:00 it was 1
# firm and 47 carried, and the firm spread no longer cleared the round-trip
# cost at all. The ceiling was structural - at hour H a one-day window gives at
# most 24-H firm hours - which is why no amount of carrying could fix it.
#
# The two plans below differ in ONE thing: what the feed answered. The code
# path, the window, the store and the instant are identical, so the difference
# is the horizon and nothing else (habit 7).
TOMORROW_D = "2026-01-16"
TOMORROW_H = bday_hours(TOMORROW_D)
# ⚠️ TOMORROW'S PRICES MUST NOT OVERLAP TODAY'S OR YESTERDAY'S, or a point's
# day of origin is not recoverable from its number and every claim below is
# about a coincidence. Asserted, not assumed.
TOMORROW_MWH = {h: 300.0 + h for h in range(24)}
TOMORROW_MWH[18] = 900.0 # the dear evening TOMORROW: the global peak of
# the 48 h window, and it is on the second day
PRICE_AT = {u: TODAY_MWH[brussels(u).hour] for u in TODAY_H}
PRICE_AT.update({u: TOMORROW_MWH[brussels(u).hour] for u in TOMORROW_H})
def body_at(instants):
"""A columnar body priced PER INSTANT, so two days can differ at the same
local hour. `feed_body()` keys on the local hour alone and cannot."""
return json.dumps({
"unix_seconds": [int(u.timestamp()) for u in instants],
"price": [PRICE_AT[u] for u in instants],
"unit": "EUR / MWh",
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
"from Bundesnetzagentur | SMARD.de",
"deprecated": False}).encode()
def recording(body):
"""A transport that serves bytes and remembers the URL it was asked for."""
seen = []
async def _t(url, timeout_s):
seen.append(url)
return body
_t.seen = seen
return _t
_vals_today = {TODAY_MWH[h] for h in range(24)}
_vals_tmrw = {TOMORROW_MWH[h] for h in range(24)}
_vals_yday = {YESTERDAY_MWH[h] for h in range(24)}
check(f"the three days' price tables share no value at all "
f"({len(_vals_today)}/{len(_vals_tmrw)}/{len(_vals_yday)} distinct), so "
f"which day a point came from is recoverable from the point",
not (_vals_today & _vals_tmrw) and not (_vals_tmrw & _vals_yday)
and not (_vals_today & _vals_yday))
check(f"...and both fixture days are 24 Brussels hours long "
f"({len(TODAY_H)}/{len(TOMORROW_H)}), found by search rather than by "
f"adding 24 UTC hours",
len(TODAY_H) == 24 and len(TOMORROW_H) == 24)
# THE TWO FEED STATES, one code path.
_t_two = recording(body_at(TODAY_H + TOMORROW_H))
_h_two = store()
_ph.PriceHistory._remember(_h_two, series_of(YDAY_H, YESTERDAY_MWH), NOW)
_pub = run(resolve_tariff(CFG, NOW, _until, transport=_t_two, now=NOW,
history=_h_two))
_t_one = recording(body_at(TODAY_H))
_h_one = store()
_ph.PriceHistory._remember(_h_one, series_of(YDAY_H, YESTERDAY_MWH), NOW)
_pre = run(resolve_tariff(CFG, NOW, _until, transport=_t_one, now=NOW,
history=_h_one))
# ⚠️ THE REQUEST, WHICH IS THE THING THIS CARD CHANGED. Both calls ask for the
# same three Brussels days; only the answer differs. The expected dates are
# spelled out here rather than derived from the module that builds them.
check(f"the shipped plugin asks the feed for the Brussels days the horizon "
f"touches, on both calls -> {_t_two.seen}",
_t_two.seen == _t_one.seen
== ["https://api.energy-charts.info/price?bzn=BE"
"&start=2026-01-15&end=2026-01-17"])
check("...and it is no longer the bare URL that answers a single day, which "
"is what made the one-day state permanent rather than pre-publication",
"start=" in _t_two.seen[0] and "end=" in _t_two.seen[0])
def split(plan):
"""(firm, carried), and ([], []) for a plan that observed no series.
⚠️ THE EMPTY ANSWER IS DELIBERATE. A mutation that turns the short
pre-publication answer into `no_data` makes `plan.read.points` raise
`Absence` by design, and an unguarded read here would take this file down
with a traceback rather than reporting a named FAIL - "the suite is red"
and "the check that watches this is red" are different claims
(MUTATION.md, reporting a mutation, point 3). With this guard the counts
below go red BY NAME, which is what a mutation report can cite.
"""
if not plan.has_prices:
return [], []
firm = [p for p in plan.read.points if not p.provisional]
carried = [p for p in plan.read.points if p.provisional]
return firm, carried
def local_days(points):
return sorted({brussels(p.start).strftime("%Y-%m-%d") for p in points})
_f2, _c2 = split(_pub)
_f1, _c1 = split(_pre)
# The expected counts are re-derived from the fixture by set arithmetic: the
# horizon hours the feed printed, versus the horizon hours it did not.
_window = [SINCE_HOUR + timedelta(hours=n) for n in range(HORIZON_H)]
_two_pub_utc = {u.astimezone(UTC) for u in TODAY_H + TOMORROW_H}
_one_pub_utc = {u.astimezone(UTC) for u in TODAY_H}
_want_f2 = len([u for u in _window if u.astimezone(UTC) in _two_pub_utc])
_want_f1 = len([u for u in _window if u.astimezone(UTC) in _one_pub_utc])
check(f"WITH TOMORROW PUBLISHED the 48 h plan is {len(_f2)} firm hours and "
f"{len(_c2)} carried; with only today, {len(_f1)} firm and {len(_c1)} "
f"carried. Re-derived from the fixture: {_want_f2} and {_want_f1}",
len(_f2) == _want_f2 and len(_f1) == _want_f1
and len(_f2) + len(_c2) == HORIZON_H
and len(_f1) + len(_c1) == HORIZON_H
and len(_f2) > len(_f1))
check(f"...and the firm hours now span TWO Brussels local days "
f"{local_days(_f2)}, where the one-day answer spans exactly one "
f"{local_days(_f1)}. This is the acceptance criterion, and the ONE thing "
f"no amount of carry-forward could ever produce: a carried hour is "
f"provisional by construction",
len(local_days(_f2)) == 2 and len(local_days(_f1)) == 1
and local_days(_f2) == [TODAY_D, TOMORROW_D])
# ⚠️ THE TAIL IS STILL CARRIED, WHICH IS FCST-04 STILL DOING ITS JOB. A 48 h
# horizon asked for at 09:17 runs 9 hours into a THIRD Brussels day, and no
# day-ahead market publishes that far. The fix widens what is firm; it does not
# claim the market publishes more than it does.
check(f"FCST-04 still fills the remainder: the {len(_c2)} hours past the "
f"market's own horizon are carried from yesterday and flagged, on a "
f"third Brussels day {local_days(_c2)} - so the plan still covers all "
f"{HORIZON_H} hours",
len(_c2) > 0 and local_days(_c2) == ["2026-01-17"]
and all(p.provisional for p in _c2))
check("...and the plan is still a provider plan reporting itself provisional, "
"not a §11.4 fallback",
_pub.origin == "provider" and _pub.provisional)
# ⚠️ THE PRE-PUBLICATION ANSWER IS A SERIES OF WHAT EXISTS, NOT `no_data`.
# Asking for tomorrow before the auction returns fewer days than requested.
# A `no_data` there would switch a working site's arbitrage off every morning.
check(f"the short answer stays a SERIES of what exists - {_pre.read.kind}, "
f"{len(_pre.read.points)} hours, origin {_pre.origin!r} - and is never "
f"no_data and never a truncated series presented as complete "
f"({len(_c1)} of its hours are flagged provisional)",
_pre.read.kind == "series" and _pre.origin == "provider"
and len(_pre.read.points) == HORIZON_H and len(_c1) > 0)
# --- the trade set, which is where the whole thing lands ------------------- #
_A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset())
_v2 = _pub.arbitrage(**_A9)
_v1 = _pre.arbitrage(**_A9)
check("both plans still ALLOW a trade, so the two verdicts below are compared "
"against each other and not against a refusal",
_v2.kind == "allowed" and _v1.kind == "allowed")
_v2 = _v2 if _v2.kind == "allowed" else None
_v1 = _v1 if _v1.kind == "allowed" else None
# ⚠️ NON-DEGENERACY, DERIVED. The global cheapest firm hour must be on TODAY
# and the global dearest on TOMORROW, or "the trade set spans two days" is a
# property of the arithmetic rather than of the fix.
def _day_of(v, end):
"""The Brussels day of one end of a permitting verdict, or "" for a
refusal - so a mutation that turns the verdict into a refusal is a named
FAIL below rather than an `Absence` traceback."""
return "" if v is None else brussels(getattr(v, end).start).strftime(
"%Y-%m-%d")
_lo2, _hi2 = _day_of(_v2, "cheapest"), _day_of(_v2, "dearest")
_lo1, _hi1 = _day_of(_v1, "cheapest"), _day_of(_v1, "dearest")
check(f"WITH TOMORROW FIRM the trade set contains hours from BOTH days: "
f"buy on {_lo2}, sell on {_hi2}",
sorted({_lo2, _hi2}) == [TODAY_D, TOMORROW_D])
check(f"...where the one-day answer's buy AND sell are both inside {_lo1} - "
f"a trade set that is a subset of ONE calendar day, structurally, which "
f"is the defect this card is about",
_lo1 == _hi1 == TODAY_D)
_s2 = None if _v2 is None else _v2.spread_eur_kwh
_s1 = None if _v1 is None else _v1.spread_eur_kwh
check(f"...and the cross-day spread is strictly larger ({_s2} vs {_s1} "
f"EUR/kWh), so the second day is not merely visible but PRICED",
_s2 is not None and _s1 is not None and _s2 > _s1)
check("...and both ends of the permitted cycle are FIRM, which "
"`_firm_or_die()` asserts at construction and §8.3 requires",
_v2 is not None and not _v2.cheapest.provisional
and not _v2.dearest.provisional)
# --------------------------------------------------------------------------- #
print()
print("2. AC2 - the spread stopped being taken over hours that have gone")
@@ -559,14 +793,24 @@ for _n in range(len(TODAY_H) * (60 // _QSTEP_MIN)):
_qu = TODAY_H[0] + timedelta(minutes=_QSTEP_MIN * _n)
# A spiky within-the-hour profile, so decimation is visible in the SPREAD
# and not only in a count.
_qrows.append({"start": _qu.isoformat(),
"price_eur_mwh": 100.0 + 40.0 * (_n % 4) + brussels(_qu).hour})
_qbody = json.dumps({"data": _qrows}).encode()
_qrows.append((_qu,
100.0 + 40.0 * (_n % 4) + brussels(_qu).hour))
# ⚠️ COLUMNAR, like the real feed - see feed_body(). And 900 s spacing is not a
# hypothetical here: Belgium moved to a 15-minute MTU on 2025-10-01 and
# `api.energy-charts.info` has answered 96 points a day ever since, which is
# exactly what test_price.py section 2b replays from a captured body.
_qbody = json.dumps({
"unix_seconds": [int(_u.timestamp()) for _u, _v in _qrows],
"price": [_v for _u, _v in _qrows],
"unit": "EUR / MWh",
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
"from Bundesnetzagentur | SMARD.de",
"deprecated": False}).encode()
check(f"the fixture really is sub-hourly - {len(_qrows)} points across "
f"{len(TODAY_H)} Brussels hours - or every check below passes for an "
f"hourly feed too and grades nothing",
len(_qrows) == 4 * len(TODAY_H)
and len({_r["start"] for _r in _qrows}) == len(_qrows))
and len({_r[0] for _r in _qrows}) == len(_qrows))
_q_before = run(resolve_tariff(CFG, NOW, _until, transport=transport_for(_qbody),
now=NOW, history=NoHistory()))
@@ -1379,6 +1623,195 @@ check(f"...and a revision of that hour REPLACES it rather than sitting beside "
and abs(_y15.points[0].eur_per_kwh - allin(YESTERDAY_MWH[6])) < 1e-9)
# --------------------------------------------------------------------------- #
print()
print("5d. FCST-06 - the OTHER way to be a store that cannot persist")
# THE DEFECT. Section 5 above grades "a store that cannot persist records
# nothing" against `not ok` - the missing-durable-volume SYMPTOM - and that is
# the only condition the rule was ever enforced on. `ok` stays TRUE on a volume
# that is present and cannot be written: a read-only remount, a full disk. In
# that state `_remember()` had already populated `self._days`, `_save()` logged
# and returned, and the store went on answering a series for the rest of the
# process's life. Measured on 5059e28, twice, with a `/data` bind-mounted
# read-only and again on a full tmpfs:
#
# last_error = 'could not write ([Errno 30] Read-only file system)'
# words = '2026-01-14 (24 points over 24 h); 2026-01-15 (24 points
# over 24 h) [could not write (...)]'
# yesterday()= series, 24 points
# ON DISK = {} <- and gone on the next restart
#
# `words` and `yesterday()` were describing two different stores, which is
# exactly what section 5's own comment refuses - reached by the condition the
# rule did not name.
#
# ⚠️ THE LEVERS ARE REAL FAILURES, NOT PATCHED ONES, AND THERE ARE TWO OF THEM
# ON PURPOSE. The fix is written against the OUTCOME - `_save()` answering "no"
# - and not against an errno, so one cause would grade a coincidence. A
# DIRECTORY where the file goes fails `os.replace`; a volume that moves out
# from under an already-open store fails `mkstemp`. Different syscall,
# different errno, one answer (habit 10: grade the family).
def npts(read):
"""A read's point count, or "no_data". Never raises.
⚠️ NOT `len(read.points)`. On a `no_data` that attribute RAISES `Absence`
by design, so a mutation that empties a store where this section expects
one would take the harness down with a traceback instead of reporting a
FAIL - and MUTATION.md's rule is that "it answered nothing" and "it broke
on the way to answering" must not grade the same. Found by driving M4.
"""
return read.match(series=lambda r: len(r.points),
no_data=lambda _r: "no_data")
_d16 = tmpdir()
_p16 = os.path.join(_d16, "price_history.json")
_h16 = PriceHistory(_p16)
os.mkdir(_p16) # `os.replace` cannot complete onto a directory
check(f"the fixture is an `ok` store on a directory that IS there "
f"(ok={_h16.ok}, last_error={_h16.last_error!r}) - not the "
f"no-durable-volume case section 5 already grades, or this section is "
f"that one again (habit 1)",
_h16.ok and _h16.last_error is None and os.path.isdir(_d16))
_carried16 = _h16.carry(series_of(YDAY_H + TODAY_H, TODAY_MWH),
since=NOW, until=_until, stamp=NOW)
_y16 = _h16.yesterday(NOW)
check(f"a write that could not complete is REPORTED and leaves the store "
f"`ok` (ok={_h16.ok}, {(_h16.last_error or '')[:44]!r}). `ok` is about "
f"the DIRECTORY, and a full volume and a read-only remount both leave "
f"it True - which is why testing `ok` alone could not see this",
_h16.ok and "could not write" in (_h16.last_error or ""))
check(f"...and the file really does not hold the day "
f"(isfile={os.path.isfile(_p16)}), or 'it answers what is not there' is "
f"not a measurement (habit 1)",
not os.path.isfile(_p16))
check(f"A STORE THAT COULD NOT WRITE RECORDS NOTHING, exactly as one with no "
f"durable volume does: it holds {sorted(_h16._days)} and `yesterday()` "
f"is {_y16.kind!r}. Retaining the merge is an in-memory yesterday that "
f"answers all day and is gone on the next restart - the "
f"silently-forgetting cache this module's docstring exists to refuse",
_h16._days == {} and _y16.kind == "no_data")
check(f"...and `words` and `yesterday()` AGREE about that store "
f"{_h16.words[:44]!r}: neither names a day the file does not hold, and "
f"both name the write failure. They disagreed - `words` printed two "
f"full days BESIDE the error while `yesterday()` answered 24 points",
YDAY_D not in _h16.words and TODAY_D not in _h16.words
and "could not write" in _h16.words
and _y16.kind == "no_data" and "could not write" in why(_y16))
check(f"...and it DEGRADES rather than raising: the carry still returned a "
f"real series ({_carried16.kind!r}, {npts(_carried16)} points) with "
f"nothing carried. §6 and this module's opening - an add-on that will "
f"not start because /data filled up is a WORSE defect than the one this "
f"section is about",
_carried16.kind == "series"
and not _carried16.match(
series=lambda r: any(p.provisional for p in r.points),
no_data=lambda _r: True))
_plan16 = derived(lambda: run(resolve_tariff(
CFG, NOW, _until, transport=transport_for(_body_today), now=NOW,
history=_h16)).origin)
check(f"...and the SHIPPED `resolve_tariff()` path is unmoved by it "
f"({_plan16!r}): a provider plan, no §11.4 degradation, on a store that "
f"cannot write a byte",
_plan16 == "provider")
# Habit 9: the same wrong input a SECOND time. A refresh runs 48 times a day,
# and a volume that is full at 14:00 is still full at 14:30.
_h16.carry(series_of(YDAY_H, YESTERDAY_MWH), since=NOW, until=_until,
stamp=NOW)
check(f"...and the SECOND failing refresh behaves the same rather than "
f"accumulating: still {sorted(_h16._days)} held and "
f"{_h16.yesterday(NOW).kind!r}. The write fails 48 times a day, so the "
f"call that matters is the one after the first (habit 9)",
_h16._days == {} and _h16.yesterday(NOW).kind == "no_data")
# ⚠️ AND WHAT IS KEPT IS THE LAST STATE THAT IS ON DISK, NOT AN EMPTY ONE. The
# MERGE is undone, not the store. A site whose volume fills at 14:00 goes on
# answering the yesterday it wrote at 13:30 - that record survives a restart,
# so answering it is not a promise the store cannot keep. What it stops doing
# is adding to it. This is also the fixture that separates a deep snapshot from
# `dict(self._days)`: a one-level copy shares the day RECORD the merge rebinds
# `points` on, so the "restored" store would answer the 24 points the merge
# made and the file never took.
_d17 = os.path.join(tmpdir(), "data")
os.mkdir(_d17)
_p17 = os.path.join(_d17, "price_history.json")
_h17 = PriceHistory(_p17)
_ph.PriceHistory._remember(_h17, series_of(YDAY_H[:12], YESTERDAY_MWH), NOW)
_first17 = _h17.yesterday(NOW)
check(f"a HALF day is persisted first ({npts(_first17)} points, "
f"last_error={_h17.last_error!r}), or 'the merge is undone and the "
f"store is not' grades nothing (habit 1)",
_h17.last_error is None and os.path.isfile(_p17)
and npts(_first17) == 12)
_persisted17 = open(_p17, encoding="utf-8").read()
_moved17 = _d17 + ".moved"
os.rename(_d17, _moved17) # the volume goes out from under an OPEN store:
# `mkstemp` fails, a different call and a
# different errno from the fixture above, and
# the bytes already written stay intact
_ph.PriceHistory._remember(_h17, series_of(YDAY_H[12:], YESTERDAY_MWH), NOW)
_y17 = _h17.yesterday(NOW)
_n17 = npts(_y17)
check(f"the second lever failed the write by a DIFFERENT route "
f"{(_h17.last_error or '')[:44]!r} - `mkstemp`, not `os.replace`, and "
f"ENOENT, not EISDIR. The rule is enforced on the outcome, so it must "
f"be driven by more than one cause (habit 10)",
"could not write" in (_h17.last_error or "")
and "No such file" in (_h17.last_error or ""))
check(f"...and the store keeps the last state that IS ON DISK, not an empty "
f"one: {_n17} points held - the 12 it wrote before the volume went, not "
f"the 24 the unwritten merge made, and not nothing. The MERGE is undone; "
f"the store is not",
npts(_y17) == 12)
_ph.PriceHistory._remember(_h17, series_of(YDAY_H[12:], YESTERDAY_MWH), NOW)
_y17b = _h17.yesterday(NOW)
check(f"...and again on the second failing refresh ({npts(_y17b)} points), "
f"because a volume that has gone is still gone 30 minutes later "
f"(habit 9)",
npts(_y17b) == 12)
os.rename(_moved17, _d17) # the volume comes back: what a RESTART finds
_r17 = PriceHistory(_p17).yesterday(NOW)
check(f"...and that is EXACTLY what a restart finds ({npts(_r17)} points, the "
f"file byte-for-byte as it was left), which is the whole claim: the "
f"store answers what survives a restart and nothing else",
open(_p17, encoding="utf-8").read() == _persisted17
and npts(_r17) == 12
and _r17.match(series=lambda a: _y17.match(
series=lambda b: [p.start for p in a.points]
== [p.start for p in b.points],
no_data=lambda _r: False), no_data=lambda _r: False))
# ⚠️ AND "CANNOT PERSIST" IS ONE PREDICATE, ASKED ONCE. FCST-04's M4 and
# FCST-05's HIGH were both a rule enforced on the conditions somebody thought
# of rather than on the family; this is the third of the shape and the last
# place it can be spelt twice. `_save()` returns whether the file now holds
# what memory holds - no volume is one False, an `OSError` is another - and
# `_remember()` keeps only what that answer says was kept.
check(f"`_save()` answers for BOTH symptoms and is the only thing that does: "
f"no durable volume -> {_ph.PriceHistory._save(_missing)!r}, a write "
f"that fails -> {_ph.PriceHistory._save(_h16)!r}, a store that "
f"persisted -> {_ph.PriceHistory._save(_hok)!r}",
_ph.PriceHistory._save(_missing) is False
and _ph.PriceHistory._save(_h16) is False
and _ph.PriceHistory._save(_hok) is True)
check(f"...and asking it once leaves the two symptoms with ONE behaviour: "
f"no volume holds {sorted(_missing._days)}/"
f"{_missing.yesterday(NOW).kind!r} and a failed write holds "
f"{sorted(_h16._days)}/{_h16.yesterday(NOW).kind!r}. A third cause "
f"nobody has thought of needs no third branch - which is how this defect "
f"arrived, twice",
(_missing._days, _missing.yesterday(NOW).kind)
== (_h16._days, _h16.yesterday(NOW).kind))
check("...and `_remember()` no longer tests a symptom of its own: the source "
"holds no `self.ok` test between the docstring and the merge. This one "
"IS about the code's shape - two places testing two symptoms is the "
"defect, not a behaviour (habit 4's stated exception)",
"if not self.ok:" not in inspect.getsource(_ph.PriceHistory._remember)
and "if not self.ok:" in inspect.getsource(_ph.PriceHistory._save))
# --------------------------------------------------------------------------- #
print()
print("6. rule 4 - hours accumulate within a day; they do not replace it")
+17 -4
View File
@@ -743,8 +743,10 @@ def run(coro):
return asyncio.run(coro)
# ⚠️ NO `price_feed_url` (TARIFF-09): an override is used verbatim and the
# plugin appends no date range to it, so setting one - even to the feed's own
# address - exercises the override path rather than the shipped default.
_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,
@@ -778,9 +780,20 @@ check("a dead feed with NO static_profile has nothing to fall back to, and "
_fb_none.origin == "none" and not _fb_none.has_prices)
# A live feed still wins over the profile.
_body = json.dumps({"data": [
{"start": (THU + timedelta(hours=i)).isoformat(), "price_eur_mwh": 40 + i}
for i in range(24)]}).encode()
# ⚠️ COLUMNAR (TARIFF-08). `api.energy-charts.info` publishes parallel
# `unix_seconds`/`price` arrays and no `data` key; the `{"data": [...]}` body
# this used to build was invented by the parser it was grading, which is how a
# provider that could never parse a live response shipped green. Real captured
# bodies are replayed in test_price.py section 2b - the prices here are
# synthetic, the SHAPE is not.
_body = json.dumps({
"unix_seconds": [int((THU + timedelta(hours=i)).timestamp())
for i in range(24)],
"price": [40 + i for i in range(24)],
"unit": "EUR / MWh",
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
"from Bundesnetzagentur | SMARD.de",
"deprecated": False}).encode()
async def _live(url, timeout_s):
+1 -1
View File
@@ -161,7 +161,7 @@ configuration:
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
price_feed_url:
name: "Tariff · Price feed URL override"
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere."
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere. WHAT YOU TYPE IS SENT EXACTLY AS WRITTEN: the add-on adds nothing to it, not even the date range it normally asks its own feed for, so an override gets whatever horizon that URL returns - often a single day, with the rest of the plan carried over from yesterday and excluded from price arbitrage. A warning in the log says so on every price refresh. Clear this option to get the full horizon back."
price_horizon_h:
name: "Tariff · How far ahead to fetch prices (hours)"
description: "How many hours of prices to ask the source for."
+1 -1
View File
@@ -141,7 +141,7 @@ configuration:
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
price_feed_url:
name: "Tarief · Afwijkende URL voor de prijsfeed"
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen."
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen. WAT U HIER TYPT WORDT LETTERLIJK VERSTUURD: de add-on voegt er niets aan toe, ook niet het datumbereik dat ze normaal aan haar eigen feed vraagt. Een afwijkende URL levert dus de horizon op die die URL zelf teruggeeft - vaak één dag, waarna de rest van het plan uit de prijzen van gisteren wordt overgenomen en buiten de prijsarbitrage valt. Bij elke prijsvernieuwing verschijnt daarover een waarschuwing in het logboek. Maak dit veld leeg om de volledige horizon terug te krijgen."
price_horizon_h:
name: "Tarief · Hoe ver vooruit prijzen ophalen (uren)"
description: "Hoeveel uur aan prijzen er bij de bron opgevraagd wordt."