TARIFF-09: ask the day-ahead feed for the horizon it was handed
checks / unit (pull_request) Successful in 7m10s
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
This commit is contained in:
@@ -315,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,
|
||||
@@ -517,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")
|
||||
|
||||
Reference in New Issue
Block a user