Files
glenn schrooyenandClaude Opus 5.5 770988c902
checks / unit (pull_request) Successful in 8m44s
TEST-26: a missing value in a check is a named FAIL, not a traceback
Checks and prints that indexed an empty list, exhausted next(), or formatted
None killed the rest of their file (FCST-06). Read them through the new
named_absence.py (nth/first/field/given/present) and conjoin a presence term.

- every site in the brief, plus the family the new scan and a sweep found
- test_named_absence.py: scans every test_*.py for unguarded filtered-list
  indexing, next() without a default and numeric formats on maybe-None
  names; proven to bite; prints what it cannot see, counted
- test_price.py 16a: the `_T17_CAPS is _T17_CAPS` tautology now grades the
  two assemble() calls' arguments
- test_control.py: coverage table re-measured on runs graded to the end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:06:58 +02:00

2093 lines
107 KiB
Python

"""Runnable check for FCST-04 - the day the fill carries. `python3 test_price_history.py`
WHAT THIS FILE IS DEFENDING. FCST-03 shipped `fill_from_yesterday()` and the
consumers written for it, and shipped NO CALLER. This file grades the caller:
that a partially-published day-ahead feed now produces a plan covering the whole
requested horizon, that the day it carries survives a restart, and that the
cache cannot decay by being fed its own output.
AC1 THE HOLE IS CLOSED. `resolve_tariff()` on a feed that has published 10
of 48 hours returns a series covering 48, the 38 unpublished hours are
provisional and the 10 published ones are the provider's own objects,
unchanged and unflagged. Graded against the same call with
`NoHistory()`, which is the pre-FCST-04 behaviour, so the difference is
MEASURED rather than described.
AC2 THE WINDOW STOPPED INCLUDING THE PAST, which is the half of this change
that moves energy. `load_prices()` returns whatever the feed sent, so at
09:00 a spread was taken over hours that had already elapsed and the
"cheapest hour" could be one no cycle can buy in - the trade is then
silently never taken. The filled series is windowed to [since, until),
so the firm cheapest hour is a hour still ahead. Graded by finding the
cheapest firm hour both ways and showing it MOVES.
AC3 THE CACHE CANNOT DECAY. What is recorded is the provider's read and
never the filled series; if it were, day 2 would carry day 1's guesses
and day 3 would carry day 2's carry of them, behind a `provisional` flag
that stays true while the NUMBER rots. Graded by driving `carry()` twice
and reading the file back.
AC4 A STORE THAT CANNOT ANSWER SAYS SO. No file, no record for yesterday, an
unreadable file, a schema this build does not know, A DAY RECORD THAT IS
NOT A RECORD, a record whose points are malformed - six different
reasons, none of them a price, none of them an empty series, and the
controller keeps its pre-FCST-04 plan through all of them. ⚠️ THE
CRASHING ONE IS THE POINT: `{"days": {"2026-01-14": "oops"}}` loaded as
healthy and then raised `AttributeError` out of every accessor, and both
`PriceHistory(...)` and `.words` are unguarded in `Controller.__init__`
- so the add-on did not start, and never self-healed.
AC5 (FCST-05) THE CACHE'S OWN CONTRACT HOLDS FOR EVERY BYTE AND EVERY ROW.
A file holding one non-UTF-8 byte no longer stops the add-on from
starting - `_load()` caught `(json.JSONDecodeError, OSError)` and
`UnicodeDecodeError` is neither, so it escaped `PriceHistory.__init__`
and `Controller.__init__` does not guard it. And a malformed `start`
under the day the feed is WRITING to no longer kills the tariff plan
for good: the write path shares `_point_of()` with the read path,
drops the row, says so, and `_save()` rewrites the record clean.
⚠️ THE STATUS ROW IS PART OF THIS. It said `'2026-01-15 (1 h)'` about a
store whose every refresh was raising - health it did not have, in a
unit that was not its own (it counts POINTS).
THE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
* EVERY VERDICT IS DERIVED. The carried/published split is re-derived here
from the set of hours the fixture published, by different arithmetic than
app/ uses. The horizon growth is a subtraction of two measured counts, not
a constant. The cheapest-hour move is found by looking, not asserted.
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE:
- YESTERDAY AND TODAY MUST PRICE EVERY LOCAL HOUR DIFFERENTLY, or a fill
that carried the wrong day is invisible. Asserted first, as a check.
- THE PUBLISHED WINDOW MUST BE A STRICT SUBSET OF THE HORIZON, or "the
fill widened it" and "the fill did nothing" are the same observation.
- THE ELAPSED HOURS MUST CONTAIN THE CHEAPEST HOUR OF THE WHOLE FEED, or
AC2 grades nothing: the windowed and unwindowed cheapest hour would be
the same point either way. This is also the realistic shape of the
danger - a cheap night that has already gone, holding a battery out of
a trade all morning.
- THE MERGE FIXTURE MUST REVISE A PRICE TO A DIFFERENT NUMBER, or merge
and replace are indistinguishable.
- THE PRUNE FIXTURE MUST HOLD MORE DAYS THAN KEEP_DAYS, or pruning is not
exercised at all. ⚠️ THIS FILE BROKE ITS OWN RULE AND IT COST A LIVE
MUTATION: §8's clock-jump check seeded ONE day, `_prune`'s early
return fired, and the body it is named after never ran. The mutant that
pruned by arithmetic against the clock - which empties the store on
exactly the morning the fill is for - survived the whole 29-file suite.
The fixture is now WRITTEN TO A FILE and reopened, because `_load()`
does not prune and that is how a store legitimately holds more days
than the cap.
- NOW MUST NOT BE ON AN HOUR BOUNDARY. `datetime.now()` never is, and a
horizon count taken at exactly :00:00 was green for a fill that emitted
HORIZON + 1 hours at every other instant.
- A SUB-HOURLY FIXTURE MUST REALLY BE SUB-HOURLY, or "every published
print survives" passes for an hourly feed and grades nothing.
* A FAILED READ IS NOT AN OBSERVATION. Every degraded store is graded on the
TYPE it answers with (`no_data` and its reason), never on a falsy value,
and the malformed-point case is checked for the specific defect - a price
defaulted to 0.0 - rather than for a count.
* AND A STRUCTURAL CHECK MUST BE ABLE TO FAIL. §3's ordering check compared
`ast.walk` positions, which grade NESTING DEPTH: `self._remember(...)` is a
statement and `self.yesterday(...)` is an argument of the fill, so
`_remember` came out first whichever statement was written first - measured
by reversing them and watching the check stay green, while DECISIONS §3 and
the module docstring both cited it as making a reversal "red rather than
plausible". It is now line numbers AND the behaviour itself.
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. See MUTATION.md and TEST-15.
"""
import ast
import asyncio
import inspect
import json
import os
import shutil
import sys
from named_absence import first, nth
import tempfile
from datetime import datetime, timedelta, timezone
from app import price_history as _ph
from app.price import (
Absence, PricePoint, PriceProduct, PriceRead, brussels,
)
from app.price_history import KEEP_DAYS, NoHistory, PriceHistory, SCHEMA
from app.static_tariff import resolve_tariff
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
fails = []
_tmpdirs = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def why(read):
"""A read's reason, or "" when it is a series.
⚠️ NOT `read.reason`. On a `_Series` that attribute RAISES `Absence` by
design, so a mutation that makes a refusing accessor answer a series would
take this file down with a traceback instead of reporting a FAIL - and
MUTATION.md's rule is that "it was refused" and "it broke on the way to
being refused" must not grade the same.
"""
return read.match(series=lambda _r: "", no_data=lambda r: r.reason)
def raises(fn, exc):
try:
fn()
except exc:
return True
except Exception:
return False
return False
class _Raised:
"""What a check saw instead of a value. Equals nothing, is falsy, and is
NOT a default."""
def __init__(self, e):
self.e = e
def __eq__(self, other):
return False
def __hash__(self):
return id(self)
def __bool__(self):
return False
def __repr__(self):
return f"<raised {type(self.e).__name__}: {self.e}>"
def derived(fn):
"""A value a check grades. An exception becomes `_Raised`.
⚠️ NOT A DEFAULT, AND NOT A BARE CALL EITHER. MUTATION.md's rule is that
"it was refused" and "it broke on the way to being refused" must not grade
the same - and the accessors below are exactly the ones the unvalidated
day record used to raise from, so a raw call here would take the harness
down on the mutation it is meant to catch and report nothing at all.
Copied from test_price_forecast.py.
"""
try:
return fn()
except Exception as e: # noqa: BLE001
return _Raised(e)
def run(coro):
return asyncio.run(coro)
def tmpdir():
d = tempfile.mkdtemp(prefix="fcst04-")
_tmpdirs.append(d)
return d
def store():
"""A history on a fresh directory that looks durable."""
return PriceHistory(os.path.join(tmpdir(), "price_history.json"))
# --------------------------------------------------------------------------- #
# The fixtures.
#
# ⚠️ A DAY IS A BRUSSELS DAY, BUILT BY SEARCH. The first draft of this file made
# a day 24 UTC hours long and every count in it came out one short: in January
# Belgium is UTC+1, so 23:00 UTC already belongs to tomorrow, and a "24-hour
# day" built by adding hours to midnight UTC is 23 hours of one Brussels day
# plus one hour of the next. That is the same class of error the module is
# keyed to avoid, so the fixture must not contain it - and it is found by
# LOOKING at each instant's Brussels date rather than by adding an offset a
# helper would have to be right about.
#
# ⚠️ THE INSTANT IS 09:00 BRUSSELS ON A WEEKDAY, WHICH IS THE WHOLE POINT. The
# day-ahead auction publishes at 14-16 h CET, so at this instant a healthy feed
# is serving TODAY IN FULL and nothing of tomorrow - the window this ticket
# exists for. A fixture at 20:00 would have both days and would grade nothing.
# --------------------------------------------------------------------------- #
TODAY_D = "2026-01-15" # a Thursday; Belgium on CET
YDAY_D = "2026-01-14"
HORIZON_H = 48
def bday_hours(bdate: str) -> list:
"""The UTC instants whose BRUSSELS date is `bdate`, one per local hour."""
probe = datetime.fromisoformat(bdate + "T00:00:00+00:00") - timedelta(hours=3)
out = []
for n in range(30):
u = probe + timedelta(hours=n)
if brussels(u).strftime("%Y-%m-%d") == bdate:
out.append(u)
return out
TODAY_H = bday_hours(TODAY_D)
YDAY_H = bday_hours(YDAY_D)
def at_local(bdate: str, hh: int) -> datetime:
"""The UTC instant whose Brussels wall clock reads hh:00 on `bdate`."""
return first((u for u in bday_hours(bdate) if brussels(u).hour == hh),
f"{bdate} {hh:02d}:00 Brussels")
# ⚠️ AND IT IS NOT ON THE HOUR. A controller's `now` is
# `datetime.now(timezone.utc)`; it is :17:43 past as often as anything else and
# it is never :00:00. The first draft of this file used the exact hour, and
# §1's `_n_after == HORIZON_H` was green only because of it: `since` was
# floored to its hour while `until` was compared raw, so a 48 h request made at
# any other instant came back 49 hours long. A fixture has to have the shape
# production has, or a count grades an alignment the running system never gets.
NOW = at_local(TODAY_D, 9) + timedelta(minutes=17, seconds=43)
# Where the WINDOW starts: the whole hour that contains NOW. The hour a
# controller is standing in is still a tradeable hour, so the fill emits from
# here and not from NOW - and every count below is derived against this rather
# than against the instant.
SINCE_HOUR = NOW.replace(minute=0, second=0, microsecond=0)
# Today's feed, in EUR/MWh, keyed by BRUSSELS local hour and all distinct.
# ⚠️ THE CHEAPEST HOUR IS 03:00 LOCAL - ALREADY ELAPSED AT 09:00 - and that is
# AC2's stimulus, asserted below rather than assumed.
TODAY_MWH = {h: 120.0 + h for h in range(24)}
TODAY_MWH[3] = 5.0 # the cheap night that has already gone
TODAY_MWH[9] = 250.0 # the hour NOW falls in - dear, so the
# cheapest hour AHEAD is strictly
# later than NOW and "it moved to an
# hour still ahead" is exact
TODAY_MWH[19] = 260.0 # the dear evening still ahead
# Yesterday's published prices, DIFFERENT AT EVERY LOCAL HOUR from today's, so
# a fill that carried the wrong day is visible.
YESTERDAY_MWH = {h: 400.0 + 3 * h for h in range(24)}
MARKUP, TAXES = 0.03, 0.06
# ⚠️ FCST-05's HIGH, AS BYTES. A cache that is structurally valid JSON up to
# the point where one byte is not UTF-8 - which is what a truncated write, a
# half-flushed page or a foreign writer produces. Written with `open(..., "wb")`
# and never through a text handle, or the fixture cannot hold the byte it is
# named for. `0xff` is not a valid UTF-8 start byte in any position.
_UTF8_BOMB = b'{"version": 1, "days": {"2026-01-14": "\xff\xfe"}}'
def allin(mwh):
"""EUR/MWh as published -> the all-in EUR/kWh `assemble()` produces."""
return round(mwh * 0.001 + MARKUP + TAXES, 6)
def feed_body(instants, table):
"""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):
async def _t(url, timeout_s):
return 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_timeout_s": 1,
"price_markup_eur_kwh": MARKUP,
"price_taxes_eur_kwh": TAXES,
"static_profile": ""}
def series_of(instants, table, product=PriceProduct.DAY_AHEAD,
provider="generic_dayahead", stamp=None):
"""A PriceRead.series built directly - the shape `load_prices()` returns."""
return PriceRead.series(
[PricePoint(start=brussels(u),
eur_per_kwh=allin(table[brussels(u).hour]),
provisional=False)
for u in instants],
product, provider, stamp or NOW)
# --------------------------------------------------------------------------- #
print("0. the arithmetic this file grades with must not be degenerate")
check(f"a Brussels day is 24 hours long in the fixture helper, on both fixture "
f"days ({len(TODAY_H)}, {len(YDAY_H)}) - a 23-hour 'day' built by adding "
f"to midnight UTC is what made every count below come out one short",
len(TODAY_H) == 24 and len(YDAY_H) == 24
and {brussels(u).hour for u in TODAY_H} == set(range(24)))
check(f"NOW is {NOW.isoformat()} - deliberately NOT on an hour boundary. A "
f"horizon count taken at exactly :00:00 is green for a fill that emits "
f"HORIZON + 1 hours at every instant a controller actually runs at",
(NOW.minute, NOW.second, NOW.microsecond) != (0, 0, 0))
# The feed serves TODAY IN FULL - which is what a day-ahead feed does - so the
# hours it publishes that are still AHEAD are a strict subset of the horizon.
# ⚠️ AGAINST SINCE_HOUR, NOT NOW: the 09:00 point started before NOW and its
# hour still contains NOW, so it is a published hour of the window.
_ahead = [u for u in TODAY_H if u >= SINCE_HOUR]
check(f"at 09:00 the hours the feed published that have NOT elapsed are a "
f"STRICT SUBSET of the {HORIZON_H} h horizon ({len(_ahead)} of "
f"{HORIZON_H}), so 'the fill widened the plan' and 'the fill did "
f"nothing' are different observations",
0 < len(_ahead) < HORIZON_H)
check("yesterday prices EVERY local hour differently from today, so a fill "
"that carried the wrong day is visible in the numbers",
all(TODAY_MWH[h] != YESTERDAY_MWH[h] for h in range(24)))
check("...and no fixture price is zero, so 'dropped the price' is visible too",
all(TODAY_MWH.values()) and all(YESTERDAY_MWH.values()))
_cheapest_all = min(TODAY_H, key=lambda u: TODAY_MWH[brussels(u).hour])
_cheapest_ahead = min(_ahead, key=lambda u: TODAY_MWH[brussels(u).hour])
check(f"AC2's stimulus is real: the cheapest hour of the whole published day is "
f"{brussels(_cheapest_all).hour:02d}:00 local, which has already elapsed "
f"at 09:00, and the cheapest hour still AHEAD is a different one "
f"({brussels(_cheapest_ahead).hour:02d}:00). Without this the windowed "
f"and unwindowed answers would be the same point and AC2 would grade "
f"nothing",
_cheapest_all != _cheapest_ahead and _cheapest_all < SINCE_HOUR
and _cheapest_ahead > NOW)
check(f"KEEP_DAYS is {KEEP_DAYS}, and the prune fixture below files more days "
f"than that, so pruning is exercised rather than skipped", KEEP_DAYS < 6)
# --------------------------------------------------------------------------- #
print()
print("1. AC1 - the hole: a partially-published feed now covers the horizon")
# ⚠️ THE FEED PUBLISHES TODAY IN FULL, WHICH IS WHAT A DAY-AHEAD FEED DOES.
# The partial publication this ticket is about is that TOMORROW is missing, not
# that this morning is - and the elapsed hours the feed is still serving are
# exactly what AC2 is about, so they have to be in the body.
_body_today = feed_body(TODAY_H, TODAY_MWH)
_until = NOW + timedelta(hours=HORIZON_H)
# The pre-FCST-04 behaviour, by the shipped code path: NoHistory() records
# nothing and carries nothing, so this IS the old plan.
_before = run(resolve_tariff(CFG, NOW, _until,
transport=transport_for(_body_today), now=NOW,
history=NoHistory()))
# And the same call with a history that already holds yesterday.
_h1 = store()
_h1_seed = series_of(YDAY_H, YESTERDAY_MWH)
_ph.PriceHistory._remember(_h1, _h1_seed, NOW) # seeding, not a caller
_after = run(resolve_tariff(CFG, NOW, _until,
transport=transport_for(_body_today), now=NOW,
history=_h1))
_n_before = len(_before.read.points)
_n_after = len(_after.read.points)
check(f"the plan's horizon GREW, measured: {_n_before} h before FCST-04, "
f"{_n_after} h after, against a {HORIZON_H} h request",
_n_after > _n_before and _n_after == HORIZON_H)
check(f"...and before the change it was exactly what the feed sent "
f"({len(TODAY_H)} h of one day), which is the defect from both ends: the "
f"{HORIZON_H - len(_ahead)} hours past the feed's own horizon were not "
f"short-priced but ABSENT, and the {len(TODAY_H) - len(_ahead)} hours "
f"that had already elapsed were in the series anyway",
_n_before == len(TODAY_H))
# The split, re-derived here from the fixture rather than read off the module.
_published_instants = {u.astimezone(UTC) for u in _ahead}
_got_carried = {p.start.astimezone(UTC) for p in _after.read.points
if p.provisional}
_got_firm = {p.start.astimezone(UTC) for p in _after.read.points
if not p.provisional}
check("AC1: exactly the hours the feed published are FIRM, and exactly the "
"hours it did not are PROVISIONAL - the flag is a claim about "
"provenance, re-derived here from the fixture",
_got_firm == _published_instants
and _got_carried == {p.start.astimezone(UTC)
for p in _after.read.points} - _published_instants
and len(_got_carried) == HORIZON_H - len(_ahead))
_before_by_start = {p.start.astimezone(UTC): p for p in _before.read.points}
# ⚠️ THIS IS A VALUE CHECK AND SAYS SO. `_before` and `_after` come from two
# separate `resolve_tariff()` calls, each of which parsed the feed body into
# its OWN objects, so `is` could never hold between them however the fill were
# written - a check claiming identity here would be claiming a property its
# own fixture makes untestable. What it does grade is that the fill moved the
# WINDOW and did not touch a PRICE.
check("...and every published point carries the same NUMBER as the unfilled "
"plan's for that instant: the fill changed which hours are in the "
"series, not what any of them costs",
all(p.eur_per_kwh == _before_by_start[p.start.astimezone(UTC)].eur_per_kwh
for p in _after.read.points if not p.provisional))
# ...and identity itself, graded on a read this file constructed and still
# holds a reference to. FCST-03's whole anti-copy argument is
# `filled.points[i] is fetched.points[j]`, and floats cannot grade it.
_h1b = store()
_ph.PriceHistory._remember(_h1b, series_of(YDAY_H, YESTERDAY_MWH), NOW)
_read_id = series_of(TODAY_H, TODAY_MWH)
_filled_id = _h1b.carry(_read_id, since=NOW, until=_until, stamp=NOW)
_firm_id = [p for p in _filled_id.points if not p.provisional]
check(f"...and IDENTITY, graded as identity: all {len(_firm_id)} firm points "
f"of a filled series ARE the provider's own objects (`is`), not copies "
f"carrying the same numbers. A copy is where the provisional flag gets "
f"lost (FCST-03)",
len(_firm_id) == len(_ahead)
and all(any(p is q for q in _read_id.points) for p in _firm_id))
# The carried numbers are yesterday's, at the same BRUSSELS wall-clock hour.
# ⚠️ THE LOOKUP IS BUILT BY SEARCH over yesterday's own instants, not by
# subtracting 24 h from today's - a fill that subtracted a fixed day is exactly
# what this is meant to catch, and a helper that subtracts one cannot catch it.
_Y_BY_LOCAL = {}
for _u in YDAY_H:
_Y_BY_LOCAL.setdefault(brussels(_u).hour,
allin(YESTERDAY_MWH[brussels(_u).hour]))
_carried_ok = [_Y_BY_LOCAL.get(brussels(p.start).hour) is not None
and abs(p.eur_per_kwh
- _Y_BY_LOCAL[brussels(p.start).hour]) < 1e-9
for p in _after.read.points if p.provisional]
check(f"...and every carried number is YESTERDAY's price for the same BRUSSELS "
f"wall-clock hour, not today's and not a neighbour's "
f"({sum(_carried_ok)}/{len(_carried_ok)})",
_carried_ok and all(_carried_ok))
# ⚠️ "FILLS EVERY UNPUBLISHED HOUR" IS CONDITIONAL, AND THIS IS THE CONDITION.
# A yesterday that is itself partial fills only the local hours it HOLDS; the
# rest are left out, which is §11.4 for those hours and emphatically not a
# zero. The behaviour is right and the wording was not - the CHANGELOG and DOCS
# said "every", unqualified, and nothing here exercised it.
_hp = store()
_PART_LOCAL = list(range(12)) # yesterday published 00:00..11:00 only
_ph.PriceHistory._remember(
_hp, series_of([at_local(YDAY_D, h) for h in _PART_LOCAL], YESTERDAY_MWH),
NOW)
_plan_part = run(resolve_tariff(CFG, NOW, _until,
transport=transport_for(_body_today), now=NOW,
history=_hp))
_window_hours = [SINCE_HOUR + timedelta(hours=n) for n in range(HORIZON_H)]
_ahead_utc = {u.astimezone(UTC) for u in _ahead}
_expect_covered = {u for u in _window_hours
if u in _ahead_utc or brussels(u).hour in _PART_LOCAL}
_got_covered = {p.start.astimezone(UTC) for p in _plan_part.read.points}
check(f"a PARTIAL yesterday fills only the local hours it holds: "
f"{len(_got_covered)} of the {HORIZON_H} horizon hours are covered, "
f"re-derived from the fixture as {len(_expect_covered)}. The hours "
f"yesterday never published stay ABSENT",
_got_covered == _expect_covered and 0 < len(_got_covered) < HORIZON_H)
_gap = sorted(set(_window_hours) - _expect_covered)[0]
check(f"...and an uncovered hour ({_gap.isoformat()}) raises `Absence` rather "
f"than answering a price. §11.4 applies to that hour: a hole in the "
f"series is not a free hour, and this is why the wording had to stop "
f"saying 'every'",
raises(lambda: _plan_part.read.at(_gap), Absence))
check("the plan reports itself PROVISIONAL now, which it did not before - the "
"old plan claimed full firmness over a horizon it covered half of",
_after.provisional and not _before.provisional)
check("...and the plan is still a provider plan, not a §11.4 fallback: the "
"fill does not degrade anything",
_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")
_A = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset())
_v_before = _before.arbitrage(**_A)
_v_after = _after.arbitrage(**_A)
check("both plans still ALLOW a trade, so this section compares two verdicts "
"rather than a verdict and a refusal",
_v_before.kind == "allowed" and _v_after.kind == "allowed")
_cheap_before = _v_before.cheapest.start.astimezone(UTC)
_cheap_after = _v_after.cheapest.start.astimezone(UTC)
check(f"AC2: the cheapest FIRM hour MOVED from {_cheap_before.isoformat()} - "
f"already elapsed at {NOW.isoformat()}, so no cycle could ever buy in it "
f"- to {_cheap_after.isoformat()}, which is still ahead",
_cheap_before < NOW <= _cheap_after and _cheap_before != _cheap_after)
check("...and the new one is the cheapest hour the feed published that has NOT "
"gone, derived from the fixture rather than from the module",
_cheap_after == _cheapest_ahead.astimezone(UTC))
check("...and no point of the filled series starts before the requested "
"`since` hour: the window is [since, until) and the past is not in it",
min(p.start.astimezone(UTC) for p in _after.read.points) >= SINCE_HOUR)
# ⚠️ THE HONEST LIMIT OF THIS TICKET, checked so it cannot be overclaimed
# later: the CARRIED hours buy no trade. §8.3 forbids acting on a provisional
# price and refusal 3 drops them from the trade set, so what widened is what
# the optimiser can SEE, and the trade above came from the WINDOWING, not from
# yesterday's numbers.
_firm_after = [p for p in _after.read.points if not p.provisional]
check("the carried hours are NOT in the trade set - the verdict's cheapest and "
"dearest are both firm, so this ticket widened what the optimiser sees "
"and not what it may act on",
not _v_after.cheapest.provisional and not _v_after.dearest.provisional
and len(_firm_after) == len(_ahead))
# --------------------------------------------------------------------------- #
print()
print("2b. a QUARTER-HOURLY feed keeps every print, and its spread with them")
# ⚠️ THE DEFECT THIS SECTION EXISTS FOR. Belgian DAY-AHEAD settles per QUARTER
# - the 15-minute MTU took effect on 2025-10-01, so `generic_dayahead` reads 96
# points a day - and `_validate_raw()` accepts any strictly-increasing spacing,
# so a published hour may hold four points.
# FCST-04's first draft keyed the published set `{_utc_hour(p.start): p ...}` -
# LAST POINT OF THE HOUR WINS, the other three dropped - and on an all-firm,
# fully-published 96-point feed the plan came out 15 points long with its
# arbitrage spread down from 0.143 to 0.014 EUR/kWh. That is not the windowing
# effect AC2 is about; it is prints being thrown away, and no flag reports it.
_QSTEP_MIN = 15
_qrows = []
for _n in range(len(TODAY_H) * (60 // _QSTEP_MIN)):
_qu = nth(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((_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[0] for _r in _qrows}) == len(_qrows))
_q_before = run(resolve_tariff(CFG, NOW, _until, transport=transport_for(_qbody),
now=NOW, history=NoHistory()))
_hq = store()
_ph.PriceHistory._remember(_hq, series_of(YDAY_H, YESTERDAY_MWH), NOW)
_q_after = run(resolve_tariff(CFG, NOW, _until, transport=transport_for(_qbody),
now=NOW, history=_hq))
# What the feed published INSIDE the window, derived from the unfilled plan.
_q_win = [p for p in _q_before.read.points
if p.start.astimezone(UTC) >= SINCE_HOUR]
_q_firm = [p for p in _q_after.read.points if not p.provisional]
check(f"every print the window contains survives the fill: {len(_q_firm)} firm "
f"points against the {len(_q_win)} the feed published in that window. "
f"One-per-hour keying leaves {len(_q_win) // 4}",
len(_q_win) > 0 and len(_q_firm) == len(_q_win))
check("...and they are the SAME INSTANTS AND PRICES the feed published, so "
"the count is not being made up by four hourly points wearing "
"quarter-hourly starts",
{(p.start.astimezone(UTC), p.eur_per_kwh) for p in _q_firm}
== {(p.start.astimezone(UTC), p.eur_per_kwh) for p in _q_win})
check("...and no two of them share a whole hour by accident: the fixture puts "
"four distinct prices inside every published hour, which is what the "
"decimated version could not represent",
len({p.start.astimezone(UTC).replace(minute=0) for p in _q_firm})
== len(_q_firm) // 4 > 0)
_q_spread_want = round(max(p.eur_per_kwh for p in _q_win)
- min(p.eur_per_kwh for p in _q_win), 6)
_qv = _q_after.arbitrage(**_A)
_q_spread_got = _qv.match(allowed=lambda v: v.spread_eur_kwh,
refused=lambda _v: None)
check(f"...and the spread is the fixture's own max - min over that window "
f"({_q_spread_want} EUR/kWh, measured {_q_spread_got}), not the "
f"flattened figure a decimated series produces",
_qv.kind == "allowed" and _q_spread_got is not None
and abs(_q_spread_got - _q_spread_want) < 1e-9)
check("...and the cheapest firm point is one the FEED published, at its own "
"sub-hourly instant - not a re-derived hourly one",
_qv.kind == "allowed"
and any(_qv.cheapest is q for q in _q_after.read.points)
and any(_qv.cheapest.start.astimezone(UTC) == q.start.astimezone(UTC)
for q in _q_win))
# --------------------------------------------------------------------------- #
print()
print("3. AC3 - the cache records the FEED, never its own output")
_h2 = store()
_read2 = series_of(TODAY_H, TODAY_MWH)
_filled2 = _h2.carry(_read2, since=NOW, until=_until, stamp=NOW)
# Round two, as the refresh task does every 1800 s: the same partial feed.
_filled2b = _h2.carry(_read2, since=NOW, until=_until, stamp=NOW)
_disk = json.load(open(_h2.path, encoding="utf-8"))
_today_key = brussels(NOW).strftime("%Y-%m-%d")
_recorded = {p["start"] for p in _disk["days"][_today_key]["points"]}
_recorded_utc = {datetime.fromisoformat(s).astimezone(UTC) for s in _recorded}
check(f"AC3: after two `carry()` rounds the record for {_today_key} holds "
f"EXACTLY the {len(TODAY_H)} hours the feed published "
f"({len(_recorded_utc)} held) - not one of the "
f"{HORIZON_H - len(_ahead)} hours the fill invented",
_recorded_utc == {u.astimezone(UTC) for u in TODAY_H})
check("...and no recorded point is flagged provisional, because a carried "
"point never reached the file - the flag would still read true while the "
"NUMBER decayed, which is the failure this rule is about",
not any(p["provisional"] for p in _disk["days"][_today_key]["points"]))
def _canon(read):
return [p.canonical() for p in read.match(series=lambda r: r.points,
no_data=lambda _r: ())]
check(f"...and the fill is idempotent across the two rounds, which is §5.4's "
f"late-publication contract - the refresh task runs it again every "
f"1800 s against whatever the feed says by then "
f"({len(_canon(_filled2))} h both times)",
_canon(_filled2) and _canon(_filled2) == _canon(_filled2b))
# The ordering is the mechanism, so the mechanism is checked in the tree.
#
# ⚠️ EVERY SHIPPED PYTHON FILE, AND BY NAME RATHER THAN BY SUBSTRING. The first
# draft scanned `app/*.py` only - not `tools/`, not `scenarios/`, not the
# repo's `estop/` - and matched the bare string, so a module that merely
# MENTIONED `_remember` in a comment failed the check while a real call from
# `tools/` passed it unseen. This parses each file and looks for the name being
# USED: an attribute, a bare name, a def, or a string constant, which is how
# `getattr` reaches a private method.
_APP = os.path.join(HERE, "app")
def _uses_remember(path):
"""Does this module USE the name `_remember`? Prose does not count."""
try:
tree = ast.parse(open(path, encoding="utf-8").read())
except (OSError, SyntaxError):
# ⚠️ NOT False. A file this scan cannot read is a file it has not
# cleared, and "unreadable" must never grade the same as "clean".
return True
for n in ast.walk(tree):
if isinstance(n, ast.Attribute) and n.attr == "_remember":
return True
if isinstance(n, ast.Name) and n.id == "_remember":
return True
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) \
and n.name == "_remember":
return True
if isinstance(n, ast.Constant) and n.value == "_remember":
return True
return False
_SHIPPED = []
for _root in (_APP, os.path.join(HERE, "tools"), os.path.join(HERE, "scenarios"),
os.path.join(os.path.dirname(HERE), "estop")):
for _dirpath, _dirnames, _filenames in os.walk(_root):
for _f in sorted(_filenames):
if _f.endswith(".py") and _f != "price_history.py":
_SHIPPED.append(os.path.join(_dirpath, _f))
check(f"the scan has a roster to walk ({len(_SHIPPED)} shipped modules across "
f"app/, tools/, scenarios/ and estop/), and it reaches beyond app/ - an "
f"empty or app-only glob passes for a leak in any other directory",
len(_SHIPPED) > 10
and any(os.sep + "tools" + os.sep in p for p in _SHIPPED))
_leaks = [os.path.relpath(p, HERE) for p in _SHIPPED if _uses_remember(p)]
check(f"AC3's mechanism is an ORDERING, not a validation - a carried point and "
f"a published-provisional point are the same three fields - so nothing "
f"outside app/price_history.py may name `_remember` {_leaks or 'none do'}",
not _leaks)
_probe = os.path.join(tmpdir(), "probe.py")
open(_probe, "w", encoding="utf-8").write("def f(h):\n return h._remember(1)\n")
_probe_ok = os.path.join(tmpdir(), "probe_ok.py")
open(_probe_ok, "w", encoding="utf-8").write(
'"""A docstring about _remember."""\n# and a comment about _remember\nX = 1\n')
check("...and that scan FAILS on a real call and PASSES on a file that only "
"talks about the rule, so it is a check and not a decoration",
_uses_remember(_probe) and not _uses_remember(_probe_ok))
# ⚠️ `ast.walk` IS BREADTH-FIRST, SO A LIST OF CALL NAMES GRADES NESTING DEPTH
# AND NOT SOURCE ORDER. The first draft of this check was
# `_calls.index("_remember") < _calls.index("yesterday")`: `self._remember(...)`
# is a statement while `self.yesterday(...)` is an ARGUMENT of
# `fill_from_yesterday(...)`, one level deeper, so `_remember` was emitted
# first whichever statement came first. Measured by writing the reversal out
# and grading it: shipped and reversed both scored True, while DECISIONS §3 and
# price_history.py's docstring both cited the check as making a reversal "red
# rather than plausible". Line numbers here, and the behaviour itself below.
_carry_src = inspect.getsource(PriceHistory.carry)
_tree = ast.parse(_carry_src.strip())
_lines = {}
for _n in ast.walk(_tree):
if isinstance(_n, ast.Call) and isinstance(_n.func, ast.Attribute):
_lines[_n.func.attr] = min(_lines.get(_n.func.attr, 10 ** 9), _n.lineno)
check(f"`carry()` names `_remember` on an EARLIER LINE than `yesterday` "
f"{_lines} - source order, which is what 'it records before it fills' "
f"actually means",
"_remember" in _lines and "yesterday" in _lines
and _lines["_remember"] < _lines["yesterday"])
# ...and the BEHAVIOUR the ordering exists for, driven rather than read. A feed
# that hands back yesterday alongside today must refresh yesterday's record
# BEFORE it is consumed as the profile. Into an EMPTY store that is the whole
# difference: record-then-fill covers the horizon, fill-then-record covers
# nothing, because at the moment of the fill there is no yesterday yet.
_h_ord = store() # empty: no record from an earlier day
_two_day = PriceRead.series(
[PricePoint(start=brussels(u),
eur_per_kwh=allin(YESTERDAY_MWH[brussels(u).hour]),
provisional=False) for u in YDAY_H]
+ [PricePoint(start=brussels(u),
eur_per_kwh=allin(TODAY_MWH[brussels(u).hour]),
provisional=False) for u in TODAY_H],
PriceProduct.DAY_AHEAD, "generic_dayahead", NOW)
_ord_filled = _h_ord.carry(_two_day, since=NOW, until=_until, stamp=NOW)
_ord_n = _ord_filled.match(series=lambda r: len(r.points), no_data=lambda _r: 0)
check(f"...and the ordering is DRIVEN, not only read: a feed carrying "
f"YESTERDAY alongside today, into an empty store, fills the whole "
f"{HORIZON_H} h horizon ({_ord_n} h) because the record was written "
f"before the lookup. Reversed, the lookup finds nothing and the plan is "
f"the {len(_ahead)} published hours the window keeps",
_ord_n == HORIZON_H)
check("...and the hours it carried carry YESTERDAY's numbers - the record that "
"same call had just written - not today's",
_ord_filled.kind == "series"
and all(abs(p.eur_per_kwh
- allin(YESTERDAY_MWH[brussels(p.start).hour])) < 1e-9
for p in _ord_filled.points if p.provisional))
# --------------------------------------------------------------------------- #
print()
print("4. rule 2 - 'yesterday' is a DATE, not 'the newest day I have'")
_h3 = store()
_ph.PriceHistory._remember(
_h3, series_of(bday_hours("2026-01-12"), YESTERDAY_MWH), NOW)
_y3 = _h3.yesterday(NOW)
check("a store holding only the day before LAST answers no_data, not those "
"prices - `newest()` is the accessor that decays, and an add-on down "
"over a weekend is when it would",
_y3.kind == "no_data")
check(f"...and the reason names BOTH the date wanted and the dates held, so an "
f"operator can see which it is: {why(_y3)!r}",
"2026-01-14" in why(_y3) and "2026-01-12" in why(_y3))
_h4 = store()
_ph.PriceHistory._remember(
_h4, series_of(YDAY_H, YESTERDAY_MWH), NOW)
_y4 = _h4.yesterday(NOW)
check("...while the day BEFORE this one answers a series, so the refusal above "
"is about the date and not about the store being broken",
_y4.kind == "series" and len(_y4.points) == 24)
check("a naive instant is refused rather than assumed to be UTC - 'which "
"Brussels day was yesterday' is the question a wall clock cannot answer",
raises(lambda: _h4.yesterday(datetime(2026, 1, 15, 9)), ValueError))
# The Brussels-date rule, on the fixture a UTC-date rule gets wrong. A feed
# serving the 24 UTC hours of a July day spans TWO Brussels days - Belgium is
# on CEST, so 22:00 and 23:00 UTC are already 00:00 and 01:00 of tomorrow - and
# a UTC-keyed cache would file all 24 under one date. The evening hours are the
# expensive ones, so filing them under the wrong day is the costly direction.
_h5 = store()
_jul_utc = [datetime(2026, 7, 14, h, tzinfo=UTC) for h in range(24)]
_split = {brussels(u).strftime("%Y-%m-%d") for u in _jul_utc}
check(f"the July fixture really does straddle two Brussels days {sorted(_split)}"
f", or the next check passes for a UTC-keyed cache as well",
len(_split) == 2)
_ph.PriceHistory._remember(
_h5, series_of(_jul_utc, YESTERDAY_MWH), _jul_utc[-1])
_disk5 = json.load(open(_h5.path, encoding="utf-8"))
_counts = {d: len(_disk5["days"][d]["points"]) for d in sorted(_disk5["days"])}
check(f"...and the record is keyed on the BRUSSELS date, so those 24 hours are "
f"filed under both of them {_counts} rather than all under one",
set(_counts) == _split and sum(_counts.values()) == 24
and all(_counts.values()))
# --------------------------------------------------------------------------- #
print()
print("4b. the two mornings a year when 'yesterday' is a DATE and not 24 hours")
# ⚠️ `brussels()` RETURNS A FIXED-OFFSET DATETIME, so `brussels(stamp) -
# timedelta(days=1)` subtracts 24 ABSOLUTE HOURS from a wall clock. The
# Brussels day before a spring-forward is 23 hours long, so between 00:00 and
# 00:59 on the morning after it that lands on the day BEFORE yesterday: the
# fill then carries a two-day-old profile behind a flag whose only vocabulary
# is "this is a guess", or - once pruning has dropped that day - answers
# `no_data` naming a date that is not yesterday. Measured at
# 2026-03-30T00:30+02:00 on the shipped branch: it returned 2026-03-28's
# prices. DST is this module's headline claim and it had NO CHECK.
for _label, (_dm2, _dm1, _d0) in (
("spring forward - yesterday was 23 h long",
("2026-03-28", "2026-03-29", "2026-03-30")),
("fall back - yesterday was 25 h long",
("2026-10-24", "2026-10-25", "2026-10-26"))):
_lens = [len(bday_hours(_d)) for _d in (_dm2, _dm1, _d0)]
check(f"{_label}: the fixture days are {_lens} hours long, so one of them "
f"really is not 24 and a DATE subtraction and an INSTANT subtraction "
f"can be told apart at all",
_lens[1] != 24 and _lens[0] == 24 and _lens[2] == 24)
_hd = store()
_tag = {}
for _i, _d in enumerate((_dm2, _dm1, _d0)):
# One price band per DAY, so the answer names which day it came from.
_tag[_d] = 0.100000 * (_i + 1)
_ph.PriceHistory._remember(
_hd,
PriceRead.series(
[PricePoint(start=brussels(u),
eur_per_kwh=round(_tag[_d] + 0.000100
* brussels(u).hour, 6),
provisional=False)
for u in bday_hours(_d)],
PriceProduct.DAY_AHEAD, "generic_dayahead", at_local(_d, 12)),
at_local(_d, 12))
check(f"{_label}: all three days are held, so the check below is about the "
f"DATE arithmetic and not about a record having been pruned away",
sorted(_hd._days) == sorted((_dm2, _dm1, _d0)))
_stamp_dst = at_local(_d0, 0) + timedelta(minutes=30)
_yd = _hd.yesterday(_stamp_dst)
_band = _yd.match(
series=lambda r: round(min(p.eur_per_kwh for p in r.points), 4),
no_data=lambda _r: None)
check(f"{_label}: at 00:30 local on {_d0}, yesterday() answers {_dm1}'s "
f"prices (band {_band}, wanted {round(_tag[_dm1], 4)}) and not "
f"{_dm2}'s (band {round(_tag[_dm2], 4)}) {why(_yd)!r}",
_yd.kind == "series" and _band == round(_tag[_dm1], 4))
check(f"{_label}: ...and it holds {_lens[1]} points, the length that "
f"Brussels day actually was - a 24-slot answer would be the wrong "
f"day wearing the right date",
_yd.kind == "series" and len(_yd.points) == _lens[1])
# The control, one hour later: an implementation that is right only at
# 01:30 is the one being caught, so both are graded.
_yd2 = _hd.yesterday(at_local(_d0, 1) + timedelta(minutes=30))
check(f"{_label}: ...and at 01:30 local it is still {_dm1} - the defect "
f"shows only in the first local hour, which is why an hourly refresh "
f"could hit it and never be noticed",
_yd2.kind == "series"
and round(min(p.eur_per_kwh for p in _yd2.points), 4)
== round(_tag[_dm1], 4))
# --------------------------------------------------------------------------- #
print()
print("5. AC4 - degraded stores, one reason each, no prices")
_missing = PriceHistory(os.path.join(tmpdir(), "gone", "price_history.json"))
check("no durable volume: the store is NOT ok, and it does not create the "
"directory - a cache on the ephemeral layer reads fine and is thrown "
"away on the next restart (tariff.PeakStore's rule)",
not _missing.ok and "no durable volume" in (_missing.last_error or "")
and not os.path.isdir(os.path.dirname(_missing.path)))
# ⚠️ AND IT DOES NOT FILL IN MEMORY EITHER. FCST-04 shipped this half-done:
# `_save()` short-circuited on `not ok` while `_remember()` populated
# `self._days` anyway, so a site with no /data answered `words` = "unavailable"
# and `yesterday()` = A SERIES - an in-memory profile that works all day and is
# gone on the next restart. price_history.py's own third warning rejects
# exactly that ("a yesterday that silently forgets") as its reason for not
# calling makedirs, so the code and the docstring have to agree.
_carried_nowhere = _missing.carry(series_of(TODAY_H, TODAY_MWH),
since=NOW, until=_until, stamp=NOW)
check("a store with no durable volume records NOTHING, not even in memory: "
"after a full carry it still holds no day, `yesterday()` still answers "
"no_data, and `words` still says unavailable. A cache that forgets on "
"restart without saying so is worse than one that says it cannot answer",
_missing._days == {}
and _missing.yesterday(NOW + timedelta(days=1)).kind == "no_data"
and _missing.words.startswith("unavailable"))
check("...and the carry still returns a real series - the fill windows the "
"feed and carries nothing, which is the pre-FCST-04 horizon and not a "
"degradation",
_carried_nowhere.kind == "series"
and len(_carried_nowhere.points) == len(_ahead))
_d6 = tmpdir()
_p6 = os.path.join(_d6, "price_history.json")
open(_p6, "w", encoding="utf-8").write("{ this is not json")
_h6 = PriceHistory(_p6)
check("an unreadable file: the reason is kept and reported, and the record is "
"NOT silently re-created as an empty one that reads like a first day",
"unreadable" in (_h6.last_error or "")
and _h6.yesterday(NOW).kind == "no_data"
and "unreadable" in why(_h6.yesterday(NOW)))
# ⚠️ AND THE BYTE CLASS THAT FIXTURE CANNOT REACH - FCST-05's HIGH. The row
# above is INVALID JSON but it is PERFECTLY VALID UTF-8, so it is decoded and
# then refused by `json`, which raises `json.JSONDecodeError`. A file holding
# one byte that is not UTF-8 at all fails EARLIER, in the decode `open(...,
# encoding="utf-8")` performs before `json` sees a character, and
# `UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError`:
# NOT a `JSONDecodeError`, and NOT an `OSError` either. `_load()` caught
# `(json.JSONDecodeError, OSError)`, so it escaped `_load()` -> `_open()` ->
# `PriceHistory.__init__` - unguarded in `Controller.__init__` - AND THE ADD-ON
# DID NOT START, on a cache whose own contract says "a corrupt cache must not
# stop the controller".
#
# The precondition is asserted first, because it is the whole reason the check
# above could not see this: if `"{ this is not json"` were undecodable, the two
# fixtures would be one fixture and this would grade nothing (MUTATION.md
# habit 1).
check("the two unreadable-file fixtures are DIFFERENT FAILURES: the one above "
"is valid UTF-8 that is not JSON, and the one below is not UTF-8 at all. "
"A file that is merely bad JSON cannot reach the decode step, which is "
"why the suite had this hole",
open(_p6, "rb").read().decode("utf-8") == "{ this is not json"
and raises(lambda: _UTF8_BOMB.decode("utf-8"), UnicodeDecodeError))
_d6b = tmpdir()
_p6b = os.path.join(_d6b, "price_history.json")
open(_p6b, "wb").write(_UTF8_BOMB)
_h6b = derived(lambda: PriceHistory(_p6b))
check(f"a single non-UTF-8 byte in the cache does NOT stop the store from "
f"being constructed {_h6b!r}. `PriceHistory(...)` is unguarded in "
f"`Controller.__init__`, so an exception here is an add-on that does not "
f"start - which is what shipped",
isinstance(_h6b, PriceHistory))
check(f"...and it degrades the SAME WAY the bad-JSON file does: the reason is "
f"kept, named and reported "
f"{(_h6b.last_error if isinstance(_h6b, PriceHistory) else _h6b)!r}",
isinstance(_h6b, PriceHistory)
and "unreadable" in (_h6b.last_error or "")
and "codec can't decode byte" in (_h6b.last_error or "")
and _h6b.yesterday(NOW).kind == "no_data"
and "unreadable" in why(_h6b.yesterday(NOW)))
_w6b = derived(lambda: _h6b.words)
_plan6b = derived(lambda: run(resolve_tariff(
CFG, NOW, _until, transport=transport_for(_body_today),
now=NOW, history=_h6b)).origin)
check(f"...and NOTHING downstream raises on it either: not `words` ({_w6b!r}), "
f"not the shipped `resolve_tariff()` path ({_plan6b!r}). §6: absent "
f"machinery must not disable the loop, and a JSON file is machinery",
isinstance(_w6b, str) and _plan6b == "provider")
_p7 = os.path.join(tmpdir(), "price_history.json")
json.dump({"version": SCHEMA + 99, "days": {}}, open(_p7, "w", encoding="utf-8"))
_h7 = PriceHistory(_p7)
check("a file from a schema this build does not know is discarded rather than "
"guessed at - its points might mean something else",
"schema" in (_h7.last_error or "")
and _h7.yesterday(NOW).kind == "no_data")
# A record whose points are malformed. THE DEFECT IS A DEFAULTED PRICE.
_p8 = os.path.join(tmpdir(), "price_history.json")
_good = {"start": brussels(at_local(YDAY_D, 5)).isoformat(),
"eur_per_kwh": allin(YESTERDAY_MWH[5]), "provisional": False}
json.dump({"version": SCHEMA, "days": {"2026-01-14": {
"product": "dynamic_dayahead", "provider": "generic_dayahead",
"points": [_good,
{"start": "2026-01-14T06:00:00+01:00"}, # no price
{"start": "2026-01-14 07:00:00", "eur_per_kwh": 0.2,
"provisional": False}, # naive
{"eur_per_kwh": 0.3, "provisional": False}, # no instant
"not even a dict"]}}},
open(_p8, "w", encoding="utf-8"))
_y8 = PriceHistory(_p8).yesterday(NOW)
check("a malformed point is DROPPED, not defaulted: the four broken rows are "
"gone and the one good row survives",
_y8.kind == "series" and len(_y8.points) == 1)
check("...and specifically no point came back priced 0.0 - "
"`float(row.get('eur_per_kwh', 0))` is how a lost field becomes 'that "
"hour was free'",
all(p.eur_per_kwh != 0.0 for p in _y8.points)
and abs(_y8.points[0].eur_per_kwh - allin(YESTERDAY_MWH[5])) < 1e-9)
_p9 = os.path.join(tmpdir(), "price_history.json")
json.dump({"version": SCHEMA, "days": {"2026-01-14": {
"product": "no_such_product", "provider": "x",
"points": [_good]}}}, open(_p9, "w", encoding="utf-8"))
check("a record naming a product that is not a PriceProduct is no_data, not a "
"series with a string in the field the optimiser switches on",
PriceHistory(_p9).yesterday(NOW).kind == "no_data")
# ⚠️ AND THE ONE THAT STOPPED THE ADD-ON FROM STARTING. `_load()` validated
# `version` and that `days` is a dict, and never that each VALUE is one. With
# `{"version": 1, "days": {"2026-01-14": "oops"}}` the store reported ok=True
# and last_error=None, and then `rec.get(...)` raised `AttributeError` out of
# `words`, `yesterday()` and `resolve_tariff()` alike. `PriceHistory(...)` and
# `.words` are both unguarded in `Controller.__init__`, so the controller did
# not come up - and it never self-healed, because `_remember()` raised on the
# same record before it could reach `_save()`. The asymmetry that produced it
# is visible in the file: `_parse_points()` is carefully defensive about every
# field of a POINT, and `_load()` was not defensive about the record at all.
_p14 = os.path.join(tmpdir(), "price_history.json")
json.dump({"version": SCHEMA, "days": {
"2026-01-14": "oops",
"2026-01-13": {"product": "dynamic_dayahead", "provider": "generic_dayahead",
"points": [_good]}}},
open(_p14, "w", encoding="utf-8"))
_h14 = PriceHistory(_p14)
check("a day record that is not a dict is DROPPED and REPORTED, and the rest "
"of the file still loads - one bad record is not a bad file",
_h14.ok and "unusable day record" in (_h14.last_error or "")
and sorted(_h14._days) == ["2026-01-13"])
_w14 = derived(lambda: _h14.words)
_y14 = derived(lambda: _h14.yesterday(NOW).kind)
_plan14 = derived(lambda: run(resolve_tariff(
CFG, NOW, _until, transport=transport_for(_body_today),
now=NOW, history=_h14)).origin)
check(f"...and NOTHING raises on it: not `words` ({_w14!r}), not `yesterday()` "
f"({_y14!r}), not the shipped `resolve_tariff()` path ({_plan14!r}). A "
f"broken cache is a short horizon, not a controller that will not start",
isinstance(_w14, str) and _y14 == "no_data" and _plan14 == "provider")
for _shape in ("oops", [1, 2, 3], 7, None, {"points": "not a list"}):
_pX = os.path.join(tmpdir(), "price_history.json")
json.dump({"version": SCHEMA, "days": {"2026-01-14": _shape}},
open(_pX, "w", encoding="utf-8"))
_hX = PriceHistory(_pX)
_wX = derived(lambda h=_hX: h.words)
_yX = derived(lambda h=_hX: h.yesterday(NOW).kind)
check(f"...and the same holds for a day record of {type(_shape).__name__} "
f"{_shape!r}: dropped, reported, and every accessor answers "
f"({_wX!r}, {_yX!r})",
_hX._days == {} and "unusable day record" in (_hX.last_error or "")
and isinstance(_wX, str) and _yX == "no_data")
_pF = os.path.join(tmpdir(), "price_history.json")
json.dump({"version": float(SCHEMA), "days": {}},
open(_pF, "w", encoding="utf-8"))
check(f"a `version` of {float(SCHEMA)!r} is refused although it EQUALS "
f"{SCHEMA} - a schema check that cannot tell 1 from 1.0 is one that "
f"would not notice a writer this build has never met",
"schema" in (PriceHistory(_pF).last_error or ""))
# --------------------------------------------------------------------------- #
print()
print("5b. `words` - the status row, and one of the two things a bad record "
"used to crash")
def words_of(h):
return derived(lambda: h.words)
_w_missing = words_of(_missing)
check(f"no durable volume: the row says UNAVAILABLE and names the reason "
f"{_w_missing!r}",
isinstance(_w_missing, str) and _w_missing.startswith("unavailable")
and "no durable volume" in _w_missing)
_h_words = store()
check("a healthy but EMPTY store says something different, because it is a "
"different state and a normal one on a site's first day",
isinstance(words_of(_h_words), str)
and words_of(_h_words).startswith("empty")
and "nothing to carry forward" in words_of(_h_words))
_ph.PriceHistory._remember(_h_words, series_of(YDAY_H, YESTERDAY_MWH), NOW)
check(f"...and a store holding a day names the DATE, the POINT COUNT and the "
f"HOUR COUNT {words_of(_h_words)!r} - 'a cache exists' and 'the cache "
f"holds yesterday, all 24 hours of it' are different facts, and only "
f"the second says the horizon will be full tomorrow morning",
words_of(_h_words) == f"{YDAY_D} (24 points over 24 h)")
# ⚠️ AND THE TWO NUMBERS ARE NOT THE SAME NUMBER - FCST-05's LOW. The row
# shipped as `f"{d} ({_n_points(rec)} h)"`, which counts POINTS and prints them
# under the HOUR's name. On an hourly feed the two are equal and nothing shows;
# on the quarter-hourly feed this add-on already supports (`_published_by_hour`
# keeps a TUPLE per hour precisely for it, and §7 below drives one) the row
# read '2026-01-13 (96 h)' - 96 "h" for a 24-hour day. That is the points/hours
# conflation H1 was raised for, on the row H1's own ticket shipped.
#
# MUTATION.md's rule: this check asserts a DIFFERENCE, so the fixture is run
# with the two numbers actually made different. 96 != 24, asserted first.
_h_q = store()
_QUARTERS = [u + timedelta(minutes=15 * q) for u in YDAY_H for q in range(4)]
_ph.PriceHistory._remember(
_h_q, PriceRead.series(
[PricePoint(start=brussels(u),
eur_per_kwh=allin(YESTERDAY_MWH[brussels(u).hour]
+ brussels(u).minute),
provisional=False) for u in _QUARTERS],
PriceProduct.DAY_AHEAD, "generic_dayahead", NOW),
NOW)
_qw = words_of(_h_q)
check(f"the quarter-hourly fixture really is sub-hourly and its two counts "
f"really differ ({len(_QUARTERS)} points, "
f"{len({brussels(u).hour for u in _QUARTERS})} local hours), or 'points' "
f"and 'hours' are the same observation and the row grades nothing",
len(_QUARTERS) == 96
and len({brussels(u).hour for u in _QUARTERS}) == 24
and len({u.minute for u in _QUARTERS}) == 4)
check(f"...and the row reports BOTH, each under its own unit: {_qw!r}. "
f"A day of 96 quarter-hourly prints is 96 points over 24 h, and the "
f"figure that says whether tomorrow's fill has a whole day to carry is "
f"the HOUR one",
_qw == f"{YDAY_D} (96 points over 24 h)")
check(f"...and specifically the row does NOT say '96 h' {_qw!r} - a point "
f"count wearing the hour's name is H1's defect, and this row is where "
f"an operator would have read it",
isinstance(_qw, str) and "(96 h)" not in _qw and " (96 points " in _qw)
check(f"a file that only PARTLY loaded says so in the row {_w14!r}, or the "
f"days it dropped read as days that were simply never written",
isinstance(_w14, str) and (_h14.last_error or "") in _w14
and "2026-01-13" in _w14)
check("NoHistory() has a sentence of its own rather than an empty cell",
NoHistory().words == "not kept")
check("...and `words` never raises, whatever the file holds. It is read once "
"from Controller.__init__ and again on every status render, and neither "
"call site guards it - an exception here is an add-on that does not start",
all(isinstance(words_of(h), str)
for h in (_missing, _h6, _h6b, _h7, _h14, _h_words)))
# ⚠️ AND A FAILED WRITE LEAVES NOTHING BEHIND. `_save()` writes to a `mkstemp`
# and renames - atomic, so a power cut cannot leave a truncated file that
# parses as an empty day - and a failure BETWEEN the two used to leave the temp
# in place. The refresh task writes 48 times a day, so a persistent write
# failure leaked 48 inodes a day onto the volume whose being full is the
# likeliest reason the write failed at all.
_dleak = tmpdir()
_pleak = os.path.join(_dleak, "price_history.json")
_hleak = PriceHistory(_pleak)
check("the leak fixture starts clean, or 'nothing left behind' is not a "
"measurement",
_hleak.ok and os.listdir(_dleak) == [])
os.mkdir(_pleak) # a DIRECTORY where the file goes: the temp is
# written fine and `os.replace` cannot complete
_ph.PriceHistory._remember(_hleak, series_of(YDAY_H, YESTERDAY_MWH), NOW)
check(f"a write that cannot complete SAYS so rather than passing quietly "
f"{(_hleak.last_error or '')[:48]!r}",
"could not write" in (_hleak.last_error or ""))
_left = sorted(n for n in os.listdir(_dleak) if n != "price_history.json")
check(f"...and leaves NOTHING behind {_left} - at 48 writes a day a leaked "
f"temp per write fills the volume that was already the problem",
_left == [])
_dok = tmpdir()
_hok = PriceHistory(os.path.join(_dok, "price_history.json"))
_ph.PriceHistory._remember(_hok, series_of(YDAY_H, YESTERDAY_MWH), NOW)
check(f"...and a SUCCESSFUL write leaves exactly the one file "
f"{sorted(os.listdir(_dok))}, so the cleanup did not start deleting the "
f"cache it just wrote",
sorted(os.listdir(_dok)) == ["price_history.json"]
and _hok.last_error is None)
# ...and every one of those leaves the CONTROLLER regulating.
#
# ⚠️ THE PLAN IS NOT BIT-FOR-BIT THE PRE-FCST-04 ONE, and the first draft of
# this check claimed it was. A broken cache still goes through the fill, which
# WINDOWS to [since, until) whether or not it has anything to carry - so the
# plan is the feed's own hours MINUS the elapsed ones. That is the AC2 half of
# this change, and it is correct with no cache at all: an hour that has gone is
# not a cheap hour. What must survive a broken cache is that the plan is a real
# provider series with no invented points and no §11.4 degradation.
for _label, _hist in (("no durable volume", _missing),
("unreadable file", _h6),
("a non-UTF-8 byte in the file", _h6b),
("wrong schema", _h7)):
_plan = run(resolve_tariff(CFG, NOW, _until,
transport=transport_for(_body_today), now=NOW,
history=_hist))
check(f"...and with {_label} the plan is a real provider series over the "
f"{len(_ahead)} published hours still ahead, with NOTHING carried "
f"and no §11.4 degradation. A broken cache is not a broken "
f"controller - it is a short horizon, which is where this started",
_plan.origin == "provider"
and len(_plan.read.points) == len(_ahead)
and not any(p.provisional for p in _plan.read.points))
# --------------------------------------------------------------------------- #
print()
print("5c. FCST-05 - the WRITE path is as defensive as the read path")
# THE DEFECT. `_remember()` merged a day's held points with
#
# held = {q["start"]: q for q in raw
# if isinstance(q, dict) and "start" in q}
# rec["points"] = sorted(held.values(), key=lambda q: q["start"])
#
# which trusts a value read off /data to be a HASHABLE, SORTABLE STRING. It is
# whatever is in the file. `_usable_days()` required `rec["points"]` to be a
# LIST and never looked at the elements, and `_parse_points()` - the READ path -
# was properly defensive, so `yesterday()` survived and the §5 `_p8` fixture
# passed. Two standards for what a stored point is; the weaker one on the path
# that WRITES.
#
# ⚠️ AND THE PLACEMENT IS THE WHOLE FIXTURE. `_p8` files its malformed rows
# under a day the feed is NOT writing to, so `_remember()` never touches that
# record and the defect is invisible. What reaches it is a malformed row under
# TODAY - the record the running feed merges into on every refresh - so that is
# asserted before anything is graded (MUTATION.md habit 1), and the harmless
# placement is run as the accepted baseline it varies from (habit 7).
#
# ⚠️ AND THE BLAST RADIUS IS WHY IT IS NOT MERELY UNTIDY. `carry()` is reached
# from `resolve_tariff()` from `run_tariff_refresh`, which catches `Exception`:
# the add-on stays UP and EVERY refresh raises, forever. The plan retires after
# TARIFF_MAX_AGE_S and arbitrage and the cheap window are off permanently, with
# no self-heal, because `_remember()` raises before it reaches `_save()`.
_BAD_STARTS = (
("an int", 7), # TypeError: '<' between 'str' and 'int'
("a dict", {"a": 1}), # TypeError: unhashable as a dict key
("null", None), # TypeError: '<' between 'str', 'NoneType'
("a list", [1, 2]), # TypeError: unhashable as a dict key
("a string that is not an instant", "nope"), # sorts fine, never a point
)
def _seeded(day, bad_start):
"""A store on disk holding ONE malformed row under `day`, plus a good one."""
d = tmpdir()
path = os.path.join(d, "price_history.json")
good = {"start": brussels(at_local(day, 5)).isoformat(),
"eur_per_kwh": allin(YESTERDAY_MWH[5]), "provisional": False}
json.dump({"version": SCHEMA, "days": {day: {
"product": "dynamic_dayahead", "provider": "generic_dayahead",
"points": [good, {"start": bad_start, "eur_per_kwh": 0.2,
"provisional": False}]}}},
open(path, "w", encoding="utf-8"))
return path, PriceHistory(path)
_TODAY_FEED = series_of(TODAY_H, TODAY_MWH)
check(f"the fixture files its malformed row under {TODAY_D}, which is exactly "
f"the Brussels day the feed being carried writes to "
f"{sorted({_ph._bdate(p.start) for p in _TODAY_FEED.points})} - under any "
f"other day `_remember()` never opens the record and this whole section "
f"grades nothing, which is why the suite already had this hole",
{_ph._bdate(p.start) for p in _TODAY_FEED.points} == {TODAY_D})
# The accepted baseline: the SAME row, one day over. Shipped and broken alike
# pass this, and that is the point of running it (habit 7).
_pB, _hB = _seeded(YDAY_D, 7)
_cB = derived(lambda: _hB.carry(_TODAY_FEED, since=NOW, until=_until, stamp=NOW))
check("baseline: the same malformed row under a day the feed is NOT writing "
"to is carried without incident - one variable moves between this and "
"every check below, and it is the DAY",
not isinstance(_cB, _Raised) and _cB.kind == "series")
for _shape, _bad in _BAD_STARTS:
_pM, _hM = _seeded(TODAY_D, _bad)
_wload = words_of(_hM)
_c1 = derived(lambda h=_hM: h.carry(_TODAY_FEED, since=NOW, until=_until,
stamp=NOW))
_saw = repr(_c1) if isinstance(_c1, _Raised) else _c1.kind
check(f"a `start` that is {_shape} ({_bad!r}) under TODAY's own record "
f"does not take the refresh down: `carry()` returns a series "
f"({_saw}). It raised "
f"`TypeError` out of the shipped path, and `run_tariff_refresh` "
f"catches `Exception`, so the add-on stayed up while every refresh "
f"failed for good",
not isinstance(_c1, _Raised) and _c1.kind == "series")
# ⚠️ THE VERDICT IS NOT THE CLAIM. "It did not raise" is satisfied by a
# store that silently kept the poison row; what this section promises is
# that the row is GONE FROM THE FILE, so the next start is clean.
_disk = json.load(open(_pM, encoding="utf-8"))
_rows = _disk["days"][TODAY_D]["points"]
# DERIVED, not a constant: the surviving rows are exactly the DISTINCT
# instants the feed published for that day plus the fixture's one good
# seeded row - which is 05:00, an hour the feed also prints, so it merges.
_want = len({p.start.astimezone(UTC) for p in _TODAY_FEED.points}
| {at_local(TODAY_D, 5).astimezone(UTC)})
check(f"...and the row is DROPPED FROM /data, not carried: {len(_rows)} "
f"row(s) on disk against {_want} distinct instants, every one of "
f"them a point. `_remember()` used to raise BEFORE `_save()`, so "
f"there was no self-heal at all - the store came back poisoned on "
f"every restart",
all(_ph._point_of(r) is not None for r in _rows)
and len(_rows) == _want)
# ...and the store stops reporting health, at BOTH moments it can be read:
# when it is opened, and after the refresh that cleaned it.
# ⚠️ THE COUNTS ARE ASSERTED TOO, NOT ONLY THE NOTE. The seeded record holds
# ONE point and one row that is not one, so a row printing "2 points" would
# be reporting the poison as held prices - which is the half of this defect
# that reached the operator: `words` said "2026-01-15 (1 h)" while every
# tariff refresh was dying on that record.
check(f"...and the status row does not call that store healthy - at open "
f"{_wload!r}: ONE point held, and the row that is not a point named "
f"rather than counted",
isinstance(_wload, str)
and _wload.startswith(f"{TODAY_D} (1 point over 1 h) [")
and "1 malformed point row(s) in" in _wload)
# ⚠️ AND THE TWO NOTES ARE GRADED APART, BECAUSE THEY MASK EACH OTHER
# OTHERWISE (MUTATION.md TEST-16). `_usable_days()` records what it FOUND
# at open and `_remember()` records what it DROPPED on the refresh; both
# land in `last_error`, so a check asking only "does the row mention a
# malformed point" is answered by whichever rung is still standing - the
# first draft of this check was exactly that, and deleting `_remember()`'s
# note left the whole 29-file suite green at -1 B. The tense is the
# difference an operator reads: "in" is still there, "dropped from" is
# gone, and after the refresh only the second is true.
_wafter = words_of(_hM)
check(f"...nor after the refresh that dropped it {_wafter!r}. It said "
f"'{TODAY_D} (1 h)' - the status row reporting a full cache while "
f"every tariff refresh was dying on it",
isinstance(_wafter, str)
and "malformed point row(s) dropped from" in _wafter
and TODAY_D in _wafter)
check(f"...and the row MOVED from what was found to what was dropped "
f"{_wload.split('[')[-1]!r} -> {_wafter.split('[')[-1]!r}, so the "
f"two notes are not one note read twice",
isinstance(_wload, str) and isinstance(_wafter, str)
and "malformed point row(s) in" in _wload
and "malformed point row(s) in" not in _wafter
and "malformed point row(s) dropped from" not in _wload)
# ...and a SECOND refresh over the same store is clean, which is the state
# a restarted add-on comes up in.
_hM2 = PriceHistory(_pM)
check(f"...and the store reopened from that file is CLEAN: the whole day "
f"held and nothing to report {words_of(_hM2)!r}, so the note clears "
f"itself rather than becoming permanent furniture",
words_of(_hM2) == f"{TODAY_D} ({_want} points over {_want} h)"
and _hM2.last_error is None)
# ...and the shipped path, end to end, through the caller that catches
# `Exception` and would otherwise have hidden this for good.
_pP, _hP = _seeded(TODAY_D, 7)
_o1 = derived(lambda: run(resolve_tariff(
CFG, NOW, _until, transport=transport_for(_body_today), now=NOW,
history=_hP)).origin)
_o2 = derived(lambda: run(resolve_tariff(
CFG, NOW, _until, transport=transport_for(_body_today), now=NOW,
history=_hP)).origin)
check(f"the SHIPPED `resolve_tariff()` path survives it TWICE ({_o1!r}, "
f"{_o2!r}) - once for the refresh that finds the poison and once for "
f"the one after it. A guard that refuses once and dies on the next call "
f"is a delay, not a guard (MUTATION.md habit 9)",
_o1 == "provider" and _o2 == "provider")
# ⚠️ AND THE MERGE KEY IS THE INSTANT, WHICH IS WHAT RULE 4 SAYS. The old key
# was `q["start"]` - the SPELLING - so the same instant written at two offsets
# was held twice, and the lexicographic sort beside it put the second 02:00 of
# the 25-hour October day (`+01:00`) BEFORE the first (`+02:00`). Aware
# datetimes hash and compare by instant, so the validated `start` is the key.
_d15 = tmpdir()
_p15 = os.path.join(_d15, "price_history.json")
_alt = at_local(YDAY_D, 6)
json.dump({"version": SCHEMA, "days": {YDAY_D: {
"product": "dynamic_dayahead", "provider": "generic_dayahead",
"points": [{"start": _alt.astimezone(UTC).isoformat(),
"eur_per_kwh": 0.999, "provisional": False}]}}},
open(_p15, "w", encoding="utf-8"))
_h15 = PriceHistory(_p15)
check(f"the two spellings really are one instant and really are two strings "
f"({_alt.astimezone(UTC).isoformat()!r} vs "
f"{brussels(_alt).isoformat()!r}), or 'merged by instant' and 'merged by "
f"spelling' are the same observation",
_alt.astimezone(UTC).isoformat() != brussels(_alt).isoformat()
and _alt.astimezone(UTC) == brussels(_alt))
_ph.PriceHistory._remember(
_h15, series_of([_alt], YESTERDAY_MWH), NOW)
_y15 = _h15.yesterday(NOW)
_n15 = len(_y15.points) if _y15.kind == "series" else _y15.kind
_v15 = _y15.points[0].eur_per_kwh if _y15.kind == "series" else None
check(f"...and a revision of that hour REPLACES it rather than sitting beside "
f"it: {_n15} point held, priced {_v15} and not 0.999. Rule 4 says "
f"'MERGE BY INSTANT'; the key was the string",
_y15.kind == "series" and len(_y15.points) == 1
and abs(_y15.points[0].eur_per_kwh - allin(YESTERDAY_MWH[6])) < 1e-9)
# --------------------------------------------------------------------------- #
print()
print("5d. FCST-06 - the OTHER way to be a store that cannot persist")
# THE DEFECT. Section 5 above grades "a store that cannot persist records
# nothing" against `not ok` - the missing-durable-volume SYMPTOM - and that is
# the only condition the rule was ever enforced on. `ok` stays TRUE on a volume
# that is present and cannot be written: a read-only remount, a full disk. In
# that state `_remember()` had already populated `self._days`, `_save()` logged
# and returned, and the store went on answering a series for the rest of the
# process's life. Measured on 5059e28, twice, with a `/data` bind-mounted
# read-only and again on a full tmpfs:
#
# last_error = 'could not write ([Errno 30] Read-only file system)'
# words = '2026-01-14 (24 points over 24 h); 2026-01-15 (24 points
# over 24 h) [could not write (...)]'
# yesterday()= series, 24 points
# ON DISK = {} <- and gone on the next restart
#
# `words` and `yesterday()` were describing two different stores, which is
# exactly what section 5's own comment refuses - reached by the condition the
# rule did not name.
#
# ⚠️ THE LEVERS ARE REAL FAILURES, NOT PATCHED ONES, AND THERE ARE TWO OF THEM
# ON PURPOSE. The fix is written against the OUTCOME - `_save()` answering "no"
# - and not against an errno, so one cause would grade a coincidence. A
# DIRECTORY where the file goes fails `os.replace`; a volume that moves out
# from under an already-open store fails `mkstemp`. Different syscall,
# different errno, one answer (habit 10: grade the family).
def npts(read):
"""A read's point count, or "no_data". Never raises.
⚠️ NOT `len(read.points)`. On a `no_data` that attribute RAISES `Absence`
by design, so a mutation that empties a store where this section expects
one would take the harness down with a traceback instead of reporting a
FAIL - and MUTATION.md's rule is that "it answered nothing" and "it broke
on the way to answering" must not grade the same. Found by driving M4.
"""
return read.match(series=lambda r: len(r.points),
no_data=lambda _r: "no_data")
_d16 = tmpdir()
_p16 = os.path.join(_d16, "price_history.json")
_h16 = PriceHistory(_p16)
os.mkdir(_p16) # `os.replace` cannot complete onto a directory
check(f"the fixture is an `ok` store on a directory that IS there "
f"(ok={_h16.ok}, last_error={_h16.last_error!r}) - not the "
f"no-durable-volume case section 5 already grades, or this section is "
f"that one again (habit 1)",
_h16.ok and _h16.last_error is None and os.path.isdir(_d16))
_carried16 = _h16.carry(series_of(YDAY_H + TODAY_H, TODAY_MWH),
since=NOW, until=_until, stamp=NOW)
_y16 = _h16.yesterday(NOW)
check(f"a write that could not complete is REPORTED and leaves the store "
f"`ok` (ok={_h16.ok}, {(_h16.last_error or '')[:44]!r}). `ok` is about "
f"the DIRECTORY, and a full volume and a read-only remount both leave "
f"it True - which is why testing `ok` alone could not see this",
_h16.ok and "could not write" in (_h16.last_error or ""))
check(f"...and the file really does not hold the day "
f"(isfile={os.path.isfile(_p16)}), or 'it answers what is not there' is "
f"not a measurement (habit 1)",
not os.path.isfile(_p16))
check(f"A STORE THAT COULD NOT WRITE RECORDS NOTHING, exactly as one with no "
f"durable volume does: it holds {sorted(_h16._days)} and `yesterday()` "
f"is {_y16.kind!r}. Retaining the merge is an in-memory yesterday that "
f"answers all day and is gone on the next restart - the "
f"silently-forgetting cache this module's docstring exists to refuse",
_h16._days == {} and _y16.kind == "no_data")
check(f"...and `words` and `yesterday()` AGREE about that store "
f"{_h16.words[:44]!r}: neither names a day the file does not hold, and "
f"both name the write failure. They disagreed - `words` printed two "
f"full days BESIDE the error while `yesterday()` answered 24 points",
YDAY_D not in _h16.words and TODAY_D not in _h16.words
and "could not write" in _h16.words
and _y16.kind == "no_data" and "could not write" in why(_y16))
check(f"...and it DEGRADES rather than raising: the carry still returned a "
f"real series ({_carried16.kind!r}, {npts(_carried16)} points) with "
f"nothing carried. §6 and this module's opening - an add-on that will "
f"not start because /data filled up is a WORSE defect than the one this "
f"section is about",
_carried16.kind == "series"
and not _carried16.match(
series=lambda r: any(p.provisional for p in r.points),
no_data=lambda _r: True))
_plan16 = derived(lambda: run(resolve_tariff(
CFG, NOW, _until, transport=transport_for(_body_today), now=NOW,
history=_h16)).origin)
check(f"...and the SHIPPED `resolve_tariff()` path is unmoved by it "
f"({_plan16!r}): a provider plan, no §11.4 degradation, on a store that "
f"cannot write a byte",
_plan16 == "provider")
# Habit 9: the same wrong input a SECOND time. A refresh runs 48 times a day,
# and a volume that is full at 14:00 is still full at 14:30.
_h16.carry(series_of(YDAY_H, YESTERDAY_MWH), since=NOW, until=_until,
stamp=NOW)
check(f"...and the SECOND failing refresh behaves the same rather than "
f"accumulating: still {sorted(_h16._days)} held and "
f"{_h16.yesterday(NOW).kind!r}. The write fails 48 times a day, so the "
f"call that matters is the one after the first (habit 9)",
_h16._days == {} and _h16.yesterday(NOW).kind == "no_data")
# ⚠️ AND WHAT IS KEPT IS THE LAST STATE THAT IS ON DISK, NOT AN EMPTY ONE. The
# MERGE is undone, not the store. A site whose volume fills at 14:00 goes on
# answering the yesterday it wrote at 13:30 - that record survives a restart,
# so answering it is not a promise the store cannot keep. What it stops doing
# is adding to it. This is also the fixture that separates a deep snapshot from
# `dict(self._days)`: a one-level copy shares the day RECORD the merge rebinds
# `points` on, so the "restored" store would answer the 24 points the merge
# made and the file never took.
_d17 = os.path.join(tmpdir(), "data")
os.mkdir(_d17)
_p17 = os.path.join(_d17, "price_history.json")
_h17 = PriceHistory(_p17)
_ph.PriceHistory._remember(_h17, series_of(YDAY_H[:12], YESTERDAY_MWH), NOW)
_first17 = _h17.yesterday(NOW)
check(f"a HALF day is persisted first ({npts(_first17)} points, "
f"last_error={_h17.last_error!r}), or 'the merge is undone and the "
f"store is not' grades nothing (habit 1)",
_h17.last_error is None and os.path.isfile(_p17)
and npts(_first17) == 12)
_persisted17 = open(_p17, encoding="utf-8").read()
_moved17 = _d17 + ".moved"
os.rename(_d17, _moved17) # the volume goes out from under an OPEN store:
# `mkstemp` fails, a different call and a
# different errno from the fixture above, and
# the bytes already written stay intact
_ph.PriceHistory._remember(_h17, series_of(YDAY_H[12:], YESTERDAY_MWH), NOW)
_y17 = _h17.yesterday(NOW)
_n17 = npts(_y17)
check(f"the second lever failed the write by a DIFFERENT route "
f"{(_h17.last_error or '')[:44]!r} - `mkstemp`, not `os.replace`, and "
f"ENOENT, not EISDIR. The rule is enforced on the outcome, so it must "
f"be driven by more than one cause (habit 10)",
"could not write" in (_h17.last_error or "")
and "No such file" in (_h17.last_error or ""))
check(f"...and the store keeps the last state that IS ON DISK, not an empty "
f"one: {_n17} points held - the 12 it wrote before the volume went, not "
f"the 24 the unwritten merge made, and not nothing. The MERGE is undone; "
f"the store is not",
npts(_y17) == 12)
_ph.PriceHistory._remember(_h17, series_of(YDAY_H[12:], YESTERDAY_MWH), NOW)
_y17b = _h17.yesterday(NOW)
check(f"...and again on the second failing refresh ({npts(_y17b)} points), "
f"because a volume that has gone is still gone 30 minutes later "
f"(habit 9)",
npts(_y17b) == 12)
os.rename(_moved17, _d17) # the volume comes back: what a RESTART finds
_r17 = PriceHistory(_p17).yesterday(NOW)
check(f"...and that is EXACTLY what a restart finds ({npts(_r17)} points, the "
f"file byte-for-byte as it was left), which is the whole claim: the "
f"store answers what survives a restart and nothing else",
open(_p17, encoding="utf-8").read() == _persisted17
and npts(_r17) == 12
and _r17.match(series=lambda a: _y17.match(
series=lambda b: [p.start for p in a.points]
== [p.start for p in b.points],
no_data=lambda _r: False), no_data=lambda _r: False))
# ⚠️ AND "CANNOT PERSIST" IS ONE PREDICATE, ASKED ONCE. FCST-04's M4 and
# FCST-05's HIGH were both a rule enforced on the conditions somebody thought
# of rather than on the family; this is the third of the shape and the last
# place it can be spelt twice. `_save()` returns whether the file now holds
# what memory holds - no volume is one False, an `OSError` is another - and
# `_remember()` keeps only what that answer says was kept.
check(f"`_save()` answers for BOTH symptoms and is the only thing that does: "
f"no durable volume -> {_ph.PriceHistory._save(_missing)!r}, a write "
f"that fails -> {_ph.PriceHistory._save(_h16)!r}, a store that "
f"persisted -> {_ph.PriceHistory._save(_hok)!r}",
_ph.PriceHistory._save(_missing) is False
and _ph.PriceHistory._save(_h16) is False
and _ph.PriceHistory._save(_hok) is True)
check(f"...and asking it once leaves the two symptoms with ONE behaviour: "
f"no volume holds {sorted(_missing._days)}/"
f"{_missing.yesterday(NOW).kind!r} and a failed write holds "
f"{sorted(_h16._days)}/{_h16.yesterday(NOW).kind!r}. A third cause "
f"nobody has thought of needs no third branch - which is how this defect "
f"arrived, twice",
(_missing._days, _missing.yesterday(NOW).kind)
== (_h16._days, _h16.yesterday(NOW).kind))
check("...and `_remember()` no longer tests a symptom of its own: the source "
"holds no `self.ok` test between the docstring and the merge. This one "
"IS about the code's shape - two places testing two symptoms is the "
"defect, not a behaviour (habit 4's stated exception)",
"if not self.ok:" not in inspect.getsource(_ph.PriceHistory._remember)
and "if not self.ok:" in inspect.getsource(_ph.PriceHistory._save))
# --------------------------------------------------------------------------- #
print()
print("6. rule 4 - hours accumulate within a day; they do not replace it")
_h10 = store()
_ph.PriceHistory._remember(_h10, series_of(TODAY_H, TODAY_MWH), NOW)
# A feed that serves only the hours left in the day - a legitimate shape, and
# the one that turns replace-the-record into a two-hour yesterday.
_REVISED = dict(TODAY_MWH)
_REVISED[22], _REVISED[23] = 999.0, 998.0
check("the revision really changes the numbers, or merge and replace are the "
"same observation",
_REVISED[22] != TODAY_MWH[22] and _REVISED[23] != TODAY_MWH[23])
_ph.PriceHistory._remember(
_h10, series_of([at_local(TODAY_D, 22), at_local(TODAY_D, 23)], _REVISED),
NOW)
_back = _h10.yesterday(NOW + timedelta(days=1))
check(f"a two-hour read did not overwrite a 24-hour day: "
f"{len(_back.points) if _back.kind == 'series' else _back.kind} hours "
f"held. Under replace, the next morning's 'yesterday' would have been "
f"two hours long",
_back.kind == "series" and len(_back.points) == 24)
# ⚠️ KEYED BY BRUSSELS HOUR, because that is what the fixture tables are keyed
# by. Keying this by UTC hour reads the wrong row of the table in winter and
# grades the module against a price nobody put there.
_by_hour = {brussels(p.start).hour: p.eur_per_kwh for p in _back.points}
def _priced(h, want):
"""True only if hour `h` is present AND carries `want`. A MISSING hour is
False, never a KeyError: under `replace` the hours simply are not there, and
a harness that dies on the mutation it is grading reports nothing at all."""
return h in _by_hour and abs(_by_hour[h] - want) < 1e-9
check("...and the two revised hours carry the NEW price, because a later read "
"is the newer statement about the same hour",
_priced(22, allin(999.0)) and _priced(23, allin(998.0)))
check("...while every untouched hour kept its original price",
all(_priced(h, allin(TODAY_MWH[h])) for h in range(22)))
# --------------------------------------------------------------------------- #
print()
print("7. the restart this whole module exists for")
_d11 = tmpdir()
_p11 = os.path.join(_d11, "price_history.json")
_first = PriceHistory(_p11)
_ph.PriceHistory._remember(
_first, series_of(YDAY_H, YESTERDAY_MWH), NOW)
del _first
_second = PriceHistory(_p11) # a brand-new add-on process
_y11 = _second.yesterday(NOW)
check("yesterday survives a restart, which is the ONLY reason this is a file "
"and not a variable on the refresh task: the window the fill exists for "
"is midnight -> publication, and an add-on restarted at 08:00 would "
"otherwise spend all of it on the truncated series",
_y11.kind == "series" and len(_y11.points) == 24)
check("...with the prices intact through the JSON round trip, to six decimals",
all(abs(p.eur_per_kwh
- allin(YESTERDAY_MWH[brussels(p.start).hour])) < 1e-9
for p in _y11.points))
_plan11 = run(resolve_tariff(CFG, NOW, _until,
transport=transport_for(_body_today), now=NOW,
history=_second))
check("...and the restarted controller's very first refresh already covers the "
"whole horizon",
len(_plan11.read.points) == HORIZON_H)
# --------------------------------------------------------------------------- #
print()
print("8. pruning keeps yesterday, and a jumped clock does not empty the store")
_h12 = store()
for back in range(6, 0, -1):
_ph.PriceHistory._remember(
_h12,
series_of(bday_hours(
(datetime.fromisoformat(TODAY_D) - timedelta(days=back)
).strftime("%Y-%m-%d")), YESTERDAY_MWH),
NOW)
_held = sorted(json.load(open(_h12.path, encoding="utf-8"))["days"])
check(f"six days filed, {KEEP_DAYS} held {_held} - a bigger cache would only "
f"hold days rule 2 refuses to look at",
len(_held) == KEEP_DAYS)
check("...and yesterday is one of them, which is the only day that matters",
_h12.yesterday(NOW).kind == "series")
# ⚠️ THE STIMULUS HAS TO REACH `_prune`'s BODY, AND THE FIRST DRAFT DID NOT.
# It seeded ONE day and then pruned with a jumped stamp: `if len(self._days) <=
# KEEP_DAYS: return` fired, the body never ran, and the check named after that
# body was green for an implementation that pruned by clock arithmetic.
# Measured - the body was replaced with "delete every day older than stamp
# minus KEEP_DAYS days", the guard left in place, and the whole 29-file suite
# stayed green while the mutant emptied the store on a jumped clock.
#
# A store holding more days than the cap is a REAL state, not a contrivance:
# `_load()` does not prune, so this is exactly what a restart sees after a file
# written by a build with a larger cap, or after a feed that spanned several
# days. So the fixture is written, opened, and only then pruned.
_FILED = KEEP_DAYS + 2
_p13 = os.path.join(tmpdir(), "price_history.json")
_days13 = {}
for _back in range(_FILED - 1, -1, -1):
_d13 = (datetime.fromisoformat(TODAY_D)
- timedelta(days=_back)).strftime("%Y-%m-%d")
_days13[_d13] = {
"product": PriceProduct.DAY_AHEAD.value,
"provider": "generic_dayahead",
"points": [PricePoint(start=brussels(u),
eur_per_kwh=allin(YESTERDAY_MWH[brussels(u).hour]),
provisional=False).canonical()
for u in bday_hours(_d13)]}
json.dump({"version": SCHEMA, "days": _days13},
open(_p13, "w", encoding="utf-8"))
_h13 = PriceHistory(_p13)
check(f"the prune fixture holds {len(_h13._days)} days, MORE than KEEP_DAYS "
f"({KEEP_DAYS}), so the early return cannot swallow the stimulus - this "
f"file's own rule, and the one its first draft broke",
len(_h13._days) == _FILED > KEEP_DAYS)
_ph.PriceHistory._prune(_h13, NOW + timedelta(days=4000))
check(f"pruning does not delete by ARITHMETIC AGAINST THE CLOCK: a clock that "
f"jumped four thousand days keeps the newest {KEEP_DAYS} of the "
f"{_FILED} records {sorted(_h13._days)} instead of deleting all of them, "
f"which would empty the store on exactly the morning the fill is for",
len(_h13._days) == KEEP_DAYS and _h13.yesterday(NOW).kind == "series")
# --------------------------------------------------------------------------- #
print()
print("8b. the two days a lookup can ask for are PINNED, so a far-off record "
"cannot evict yesterday")
# ⚠️ KEEP_DAYS = 3 IS THE STEADY STATE EXACTLY - {yesterday, today, tomorrow} -
# so a newest-N cap has NO SLACK. A feed that publishes to D+2 makes four days
# and the cap drops the oldest, which is yesterday: the only day the fill can
# use. One stray point in the wrong year does the same thing permanently,
# because a lexicographic sort puts it LAST and nothing can ever age it out.
_h15 = store()
_SPAN_D = (YDAY_D, TODAY_D, "2026-01-16", "2026-01-17")
_span = [u for _d in _SPAN_D for u in bday_hours(_d)]
check(f"the spanning fixture really covers {len(_SPAN_D)} Brussels days, more "
f"than KEEP_DAYS ({KEEP_DAYS}), or the cap is not under pressure and "
f"this grades nothing",
len(_SPAN_D) > KEEP_DAYS
and len({brussels(u).strftime("%Y-%m-%d") for u in _span}) == len(_SPAN_D))
_ph.PriceHistory._remember(_h15, series_of(_span, YESTERDAY_MWH), NOW)
check(f"a feed spanning {len(_SPAN_D)} days does NOT evict yesterday: "
f"{sorted(_h15._days)} held, and {YDAY_D} is one of them. Under the "
f"newest-{KEEP_DAYS} cap alone it is the first record dropped, and the "
f"fill loses its only usable day for good",
YDAY_D in _h15._days and _h15.yesterday(NOW).kind == "series")
_h16 = store()
for _d16 in (YDAY_D, TODAY_D, "2026-01-16"):
_ph.PriceHistory._remember(_h16, series_of(bday_hours(_d16), TODAY_MWH), NOW)
_STRAY_D = "2099-07-01"
_ph.PriceHistory._remember(
_h16, series_of(bday_hours(_STRAY_D)[:1], TODAY_MWH), NOW)
check(f"the stray fixture is really far future and really sorts last "
f"{sorted(_h16._days)[-1]!r}, or 'it can never age out' is not what is "
f"being graded",
sorted(_h16._days)[-1] == _STRAY_D)
check(f"...and ONE stray point in {_STRAY_D} does not push yesterday out "
f"either. It is not deleted - nothing is deleted by arithmetic here - "
f"it simply cannot take yesterday's slot",
YDAY_D in _h16._days and _h16.yesterday(NOW).kind == "series")
check(f"...while the store stays BOUNDED at KEEP_DAYS + 2 = {KEEP_DAYS + 2} "
f"records ({len(_h16._days)} held), so the pin is a pin and not a leak",
len(_h16._days) <= KEEP_DAYS + 2)
# --------------------------------------------------------------------------- #
print()
print("9. the parameter cannot be forgotten, and main.py does not forget it")
_sig = inspect.signature(resolve_tariff)
_hp = _sig.parameters.get("history")
check("`resolve_tariff(history=...)` is KEYWORD-ONLY and has NO DEFAULT - a "
"default of None meaning 'skip the fill' would hand a caller who forgot "
"it the truncated horizon this ticket removes, silently",
_hp is not None and _hp.kind is inspect.Parameter.KEYWORD_ONLY
and _hp.default is inspect.Parameter.empty)
check("...so calling it without one is a TypeError, not a quiet regression",
raises(lambda: run(resolve_tariff(CFG, NOW, _until,
transport=transport_for(_body_today),
now=NOW)), TypeError))
_MAIN = open(os.path.join(_APP, "main.py"), encoding="utf-8").read()
_main_tree = ast.parse(_MAIN)
_refresh = first((n for n in ast.walk(_main_tree)
if isinstance(n, ast.AsyncFunctionDef)
and n.name == "run_tariff_refresh"), "run_tariff_refresh in main.py")
_kw = [k.arg for n in ast.walk(_refresh) if isinstance(n, ast.Call)
for k in n.keywords]
check(f"the shipped refresh task passes a history {_kw} - the wiring is what "
f"this ticket IS, so it is checked in the tree rather than described",
"history" in _kw)
check("the FCST-04 note in main.py carries the WINDOWING caveat beside the "
"'widens what the optimiser can SEE, never what it may act on' "
"guarantee. That note is the one place a maintainer reads that sentence "
"unqualified, and the CHANGELOG, DOCS and resolve_tariff()'s docstring "
"all carry the pair - so it may not appear here on its own",
"never what it may act on" in _MAIN and "ALREADY ELAPSED" in _MAIN)
check("...and it is the CONTROLLER'S store, not a NoHistory() smuggled in to "
"make the call typecheck",
"history=self.price_history" in _MAIN
and "NoHistory" not in _MAIN)
check("...and `words` reaches the STATUS PAGE as its own row, so what the fill "
"has to carry from is a fact an operator can read rather than one that "
"only exists in a log line",
'("Price history", self.price_history.words)' in _MAIN)
_rows_fn = first((n for n in ast.walk(_main_tree)
if isinstance(n, ast.FunctionDef)
and n.name == "status_payload"), "status_payload in main.py")
_row_labels = [e.elts[0].value for e in ast.walk(_rows_fn)
if isinstance(e, ast.Tuple) and len(e.elts) == 2
and isinstance(e.elts[0], ast.Constant)
and isinstance(e.elts[0].value, str)]
check(f"...and it is in the ROW LIST rather than in a comment about one "
f"{[r for r in _row_labels if 'rice' in r or 'ariff' in r]}",
"Price history" in _row_labels)
# ⚠️ AND WHAT THE STATUS PAGE DOES **NOT** SAY, PINNED AS A PAIR. DOCS.md
# claimed "*Tariff* on the status page reads `provisional` through the
# morning". It does not and never did: `self.tariff_words` is assigned only
# from the Home Assistant price-entity branch and from `static_status()`, both
# on the 30 s tick, and the provider plan's own sentence reaches `_LOG.info`
# and nothing else. This grades the PAIRING rather than the current state - so
# wiring the plan into that row is fine and correcting the doc is fine, and
# doing one without the other is red.
_MAIN_LINES = _MAIN.splitlines()
_tw_lines = sorted({n.lineno for n in ast.walk(_main_tree)
if isinstance(n, ast.Assign)
for t in n.targets
for a in ast.walk(t)
if isinstance(a, ast.Attribute) and a.attr == "tariff_words"})
_row_from_plan = any("plan" in _MAIN_LINES[i - 1] for i in _tw_lines)
_DOCS = open(os.path.join(HERE, "DOCS.md"), encoding="utf-8").read()
_docs_claims = "**Tariff** on the status page reads `provisional`" in _DOCS
check(f"the *Tariff* row and what DOCS says about it AGREE: fed from the "
f"provider plan = {_row_from_plan} (assigned on lines {_tw_lines}), DOCS "
f"claims it reads `provisional` through the morning = {_docs_claims}. It "
f"shipped as False against True - an overclaim in a doc is a defect "
f"here, and this is the pair that has to move together",
len(_tw_lines) >= 2 and _row_from_plan == _docs_claims)
# NoHistory is inert, and says so by identity rather than by producing a series.
_nh_read = series_of(TODAY_H, TODAY_MWH)
check("NoHistory() carries nothing and returns the read BY IDENTITY, so the "
"explicit 'I keep no history' is exactly the old behaviour and not a "
"second implementation of it",
NoHistory().carry(_nh_read, since=NOW, until=_until, stamp=NOW)
is _nh_read
and NoHistory().yesterday(NOW).kind == "no_data")
# --------------------------------------------------------------------------- #
for d in _tmpdirs:
shutil.rmtree(d, ignore_errors=True)
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")