`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
7.2 KiB
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 dates:
- the first is
brussels(since).date(). Notsince.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 isprice_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 whenuntilfalls exactly on local midnight, becauseuntilis 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:
start/endare energy-charts' spelling, and this provider declares a second host.generic_dayahead.capabilities().egress_hostsis("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 notstart/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.- 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.
- 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.