Files
goodwe-addon/goodwe_controller/test_price.py
T
adminandClaude Opus 5 f620f7cf80
checks / unit (pull_request) Successful in 7m12s
TARIFF-07: three plugins that never fetched a price are withdrawn, and a site configured on one is told so
engie_dynamic, totalenergies_dynamic and eneco_eddy are removed from PROVIDERS
and from every surface that listed them. They did not stop working - they never
worked. Re-verified by hand on 2026-09-13 before deleting anything: 404 with
Kong's "no Route matched with those values"; a TLS handshake that fails because
api.totalenergies.be is absent from the certificate's SAN list; 404 with an
empty body. All three are also structurally impossible - the coefficients are
per-contract and behind a customer login, there is no regulator index, and
"Eddy" is not an Eneco product at all.

⚠️ BACKWARD-INCOMPATIBLE. A config legal on the current version is refused on
the next. That is the point of the change: §11.4 turned every failed fetch into
a silent fall back, so a site on one of these ids has been running on
static_profile - or on nothing - since the day it was set, with nothing on its
status page ever differing from a working provider that never found a cheap
hour. validate.py now refuses a retired id with a message naming the id, the
endpoint, what that endpoint answers, which tariff has really been in force,
and what to set instead. No version number is picked here; that depends on a
fact about the live box.

⚠️ config.yaml's price_provider enum deliberately KEEPS all three ids. The
Supervisor validates the options file against that enum before the add-on
starts, so removing them would make an existing config schema-invalid and the
refusal above would never be reached on exactly the sites that need it.
PROVIDERS is two; the enum stays six; test_price.py asserts the schema offers
exactly PROVIDERS | RETIRED_PROVIDERS | {"off"}, derived rather than typed, and
register() refuses to re-register a retired id.

PriceProduct.REALTIME, _WIDE, Uncertainty.REALTIME_PRODUCT and WIDEN_FACTOR's
2.5 are KEPT with no shipped declarer - it is the AC2 seam, and a reviewer
finding a mechanism with zero callers would reasonably delete it. Recorded in
DECISIONS/TARIFF-07-withdrawing-three-plugins.md and pinned by a check.

False claims corrected: every "Belgian imbalance product" claim about
eneco_eddy (DOCS.md, price_forecast.py, price_history.py, static_tariff.py,
FCST-04, CHANGELOG - the two released entries annotated in place rather than
rewritten); the quarter-hourly examples now cite Belgian day-ahead, which
really does settle per quarter since the 15-minute MTU; and
export_price_eur_kwh's "no shipped supplier publishes a dynamic injection
curve", which is false - Eneco publishes 0,1 x BELPEX-H - 1,188 and ENGIE
(B x eSpot_15) - A, same two-coefficient shape with its own coefficients and
its own VAT treatment, so the scalar is a simplification and not the market.

DOCS.md's provider table is two rows and now says honestly what a Belgian
dynamic customer should do today, including that generic_dayahead cannot
express B x spot + A and that price_markup_eur_kwh: float(0,1) could not hold a
negative offset. The formula provider is referenced as planned, not built.

_points_from_json() went with the plugins it served: it read a shape no feed
anybody here has ever reached publishes. The one place it survives is a
negative stimulus, named row_shape_body().

Nine mutations driven, all red; table in the decision record. 29/29 suites
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 02:46:15 +02:00

3158 lines
156 KiB
Python

"""Runnable check for the price provider framework. `python3 test_price.py`
WHAT THIS FILE IS DEFENDING, in the order the ticket names it:
1. Every shipped plugin emits the EXACT canonical schema (§9.3), all-in.
2. Adding a plugin needs no optimiser change - so the framework must contain
no plugin name, and a plugin invented HERE, in this file, must reach the
right volatility regime without price.py being touched.
3. `manual_csv` works with no network at all.
4. The injection price is a separate scalar (§9.3).
5. "No price data" is a DIFFERENT OUTCOME from "prices, all of them zero".
TWO RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
* EVERY VERDICT IS DERIVED. There is no `check("the schema is right", True)`
anywhere below. Where a check needs an expected number, the number is
computed here from the same inputs by different arithmetic than price.py
uses, and where it needs a set, the set is built from the objects rather
than typed out.
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE. That
is not decoration here, it is the main hazard in a markup test:
- markup and taxes must DIFFER and both be NON-ZERO, or "added markup
twice" and "added markup and taxes once each" produce the same number
and the double-counting check grades nothing. The shipped defaults are
0.03 and 0.06 and 0.06 IS EXACTLY TWICE 0.03, so this file deliberately
uses 0.031 / 0.057 for the arithmetic and asserts the non-degeneracy
conditions as checks in their own right rather than assuming them.
- the raw price must be non-zero, or an all-in figure equals markup+taxes
and a plugin that dropped the energy component entirely still passes.
- a fixture must carry BOTH a provisional and a non-provisional point, or
`plan_is_provisional` returns the same answer under any bug.
- the DST checks run on the 23-hour and 25-hour days, not on a normal
24-hour day where a fixed-24-slot implementation and a correct one
agree exactly.
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
edit reverted inside one second is invisible to CPython's staleness test. That
is TEST-15, and it has already turned one live mutation into a false survivor.
Read MUTATION.md before you report a survivor.
"""
import ast
import asyncio
import hashlib
import inspect
import io
import json
import logging
import os
import shutil
import sys
import tempfile
from datetime import date, datetime, timedelta, timezone
from textwrap import dedent
from app.price import (
ALL_IN, Absence, BANNED_IN_PLUGIN_CODE, Component, FrameworkOption,
RETIRED_PROVIDERS,
MAX_PLAUSIBLE_EUR_KWH, code_leaks,
PRICE_OPTION_DEFAULTS, PROVIDERS, PluginOptions, PricePoint, PriceProduct,
PriceProvider, PriceRead, ProviderCapabilities, RawPoint, SourceUnavailable,
arbitrage_allowed, assemble, brussels, brussels_date_range,
brussels_offset, check_egress, dated_feed_url,
price_option,
export_price_eur_kwh, load_prices, plan_is_provisional, register,
volatility_regime, uncertainty_of, widen_factor, Uncertainty,
)
from app import price as _price
from app import arbitrage as _arb
from app import main as _main
from app.p1 import make_sample, split_signed
from app.static_tariff import TariffPlan
from app.validate import validate_startup_config
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def run(coro):
return asyncio.run(coro)
def _reason(fn):
"""The message fn() raised, so a check can assert WHICH rule refused.
`raises(...)` only says that something refused. M7 survived because two
different rules could refuse the same URL and the check could not tell them
apart - so where a check is named for a specific rule, it asserts on the
reason instead.
"""
try:
fn()
except Exception as e: # noqa: BLE001
return str(e)
return ""
def raises(fn, exc):
"""True if fn() raises exactly `exc`. A DIFFERENT exception is False, not
True - otherwise "it was refused" and "it broke on the way to being
refused" would grade the same, which is how M7 hid."""
try:
fn()
except exc:
return True
except Exception:
return False
return False
class _Raised:
"""A value a check wanted, whose computation raised.
Compares unequal to everything, is falsy, and prints what went wrong.
"""
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):
"""Compute a value a check grades. An exception becomes `_Raised`.
⚠️ THIS IS NOT A DEFAULT, and the distinction is the one this whole ticket
is about. `_Raised` is not `[]`, not `None`, not 0; it equals nothing, it is
falsy, and it renders as the exception that produced it. Its only job is
that a mutation which makes a derivation IMPOSSIBLE - the reviewer removing
`_NoData`'s missing `.points`, say - reports as a failed check naming the
exception, instead of aborting this file before the sections below it run.
A silent `except: return []` here would be the defect under test, in the
harness that tests it.
"""
try:
return fn()
except Exception as e: # noqa: BLE001
return _Raised(e)
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
# ⚠️ NOT the shipped 0.03 / 0.06. See this module's docstring: 0.06 is exactly
# 2 x 0.03, so under the shipped pair "the framework added the markup twice and
# no taxes" and "the framework added one markup and one taxes" differ by
# 0.03 - but several OTHER confusions collapse. These two are co-prime-ish in
# the only sense that matters here, and the three degeneracy conditions are
# asserted below rather than assumed.
MARKUP = 0.031
TAXES = 0.057
def opts(**over):
"""A price configuration. Everything below is this, spoilt."""
o = {"price_provider": "off",
"price_markup_eur_kwh": MARKUP,
"price_taxes_eur_kwh": TAXES,
"price_timeout_s": 5}
o.update(over)
return o
def hours(n, start, step_h=1):
return [start + timedelta(hours=i * step_h) for i in range(n)]
# --------------------------------------------------------------------------- #
print("0. the arithmetic this file grades with must not be degenerate")
# ⚠️ These four ARE the guard against a check that passes for the wrong reason.
# If any of them goes red, every markup assertion below is measuring nothing.
check("markup and taxes are both non-zero", MARKUP != 0 and TAXES != 0)
check("...and differ, so 'added the wrong one' is visible",
MARKUP != TAXES)
check("...and markup twice != markup plus taxes, so double-counting the markup "
"is distinguishable from correctly adding both",
abs(2 * MARKUP - (MARKUP + TAXES)) > 1e-9)
check("...and the shipped defaults are NOT what this file grades with, because "
f"config.yaml's {PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']} is exactly "
f"twice its {PRICE_OPTION_DEFAULTS['price_markup_eur_kwh']}",
abs(PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
- 2 * PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"]) < 1e-9
and (MARKUP, TAXES) != (PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"],
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]))
# --------------------------------------------------------------------------- #
print()
print("1. the shipped defaults live in config.yaml, and here only as a mirror")
# ⚠️ CFG-03. The add-on already carries option defaults in three places. This
# ticket adds ONE in-code mirror (PRICE_OPTION_DEFAULTS) and pins it to
# config.yaml by reading the file rather than by transcribing it - the same
# move test_validate.py makes for max_w and meter_freeze_s. If they drift, this
# goes red and somebody has to decide which is right.
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
with open(_CFG, "r", encoding="utf-8") as fh:
_cfg_lines = fh.read().splitlines()
def shipped(key):
"""The value config.yaml ships, from the `options:` block, parsed by hand.
No yaml module in the shipped image, and this is two lines of parsing
against a file whose shape is known - the alternative is a transcription,
which is the thing being guarded against.
"""
in_schema = False
for line in _cfg_lines:
if line.startswith("schema:"):
in_schema = True
if in_schema or not line.startswith(f" {key}:"):
continue
raw = line.split(":", 1)[1].split("#", 1)[0].strip().strip('"').strip("'")
# OPT-04 added the first BOOLEAN price option, and without this the
# mirror check would compare the string "false" against `False` and go
# red for a file that is perfectly in step. Parsing a bool as a bool is
# what the yaml module this image does not carry would have done; it
# widens nothing, because a key whose shipped value disagrees with the
# mirror still drifts.
if raw in ("true", "false"):
return raw == "true"
try:
return int(raw)
except ValueError:
pass
try:
return float(raw)
except ValueError:
return raw
return KeyError
_found = {k: shipped(k) for k in PRICE_OPTION_DEFAULTS}
_missing = sorted(k for k, v in _found.items() if v is KeyError)
check(f"config.yaml ships every price option ({len(PRICE_OPTION_DEFAULTS)} keys)",
_missing == [])
_drift = sorted(k for k, v in _found.items()
if v is not KeyError and v != PRICE_OPTION_DEFAULTS[k])
check(f"...and each shipped default equals the in-code mirror {_found}",
_drift == [])
# The schema block must list them too, or the Supervisor drops the option and
# the add-on silently runs on the mirror's value instead of the operator's.
_schema_block = _cfg_lines[_cfg_lines.index("schema:"):]
_unschemad = sorted(k for k in PRICE_OPTION_DEFAULTS
if not any(ln.startswith(f" {k}:") for ln in _schema_block))
check("...and every one of them is in the schema block too", _unschemad == [])
# ⚠️ Derived from the registry, not typed: the schema's provider list must offer
# exactly the plugins that exist PLUS the retired ids, or the UI and the code
# disagree about what is installable.
#
# ⚠️ THE RETIRED IDS ARE IN THE SCHEMA ON PURPOSE AND THIS IS THE CHECK THAT
# KEEPS THEM THERE (TARIFF-07). The Supervisor validates the options file
# against this line BEFORE the add-on runs. Drop a retired id from it and an
# EXISTING config naming that id becomes schema-invalid: the operator gets an
# opaque "expected one of ..." and validate.py's retired-id refusal - the whole
# point of the withdrawal - is never reached, on exactly the sites that need
# it. So the schema is deliberately WIDER than the registry, by exactly
# RETIRED_PROVIDERS and by nothing else. Both halves are graded: an id that is
# in neither set, and a retired id that has gone missing.
_line = next(ln for ln in _schema_block if ln.startswith(" price_provider:"))
_schema_ids = set(_line.split("list(", 1)[1].rstrip(")").split("|"))
_wanted = set(PROVIDERS) | set(RETIRED_PROVIDERS) | {"off"}
check(f"the schema offers exactly the registered plugins, the retired ids and "
f"'off' {sorted(_schema_ids)}",
_schema_ids == _wanted)
check(f"...and every retired id is STILL offered, so a config already naming "
f"one reaches validate.py rather than the Supervisor's schema error "
f"{sorted(set(RETIRED_PROVIDERS) - _schema_ids) or 'none missing'}",
set(RETIRED_PROVIDERS) <= _schema_ids and set(RETIRED_PROVIDERS) != set())
check("...and no retired id is ALSO a live plugin, which would silently take "
"that refusal away",
set(RETIRED_PROVIDERS) & set(PROVIDERS) == set())
# --------------------------------------------------------------------------- #
print()
print("2. every shipped plugin emits the exact canonical schema (AC1)")
# One recorded fixture per plugin, none of them a real fetch. Prices are
# deliberately distinct, non-zero, and one of them NEGATIVE - §17 keeps a
# negative-price regression case, and a windy Sunday is the best charging hour
# of the year, so a framework that clamped it away would cost real money.
T0 = datetime(2026, 1, 15, 0, 0, tzinfo=UTC)
# ⚠️ THE NEGATIVE HOUR IS -95 EUR/MWh, NOT -12.5, AND THE MAGNITUDE IS THE
# POINT. All-in adds 0.088 EUR/kWh here, so a -12.5 EUR/MWh hour comes out at
# +0.0755 and the "a negative price survives" check below would be grading a
# positive number - passing whether or not the framework clamped anything.
# -95 EUR/MWh is both realistic for a windy Belgian Sunday and still negative
# after markup and taxes, which is the only version of the stimulus that grades
# what it claims to.
EPEX_EUR_MWH = [61.4, -95.0, 183.0] # EUR/MWh, one deeply negative
RETAIL_EUR_KWH = [0.1341, 0.0872, 0.2610] # EUR/kWh, markup already inside
# ⚠️ THE INVENTED ROW-PER-POINT FIXTURE IS GONE, AND SO IS ITS PARSER
# (TARIFF-07). Until this card every price fixture here was a
# `{"data": [{"start": ..., "price_eur_mwh": ...}]}` document that
# `_points_from_json()` was written to read and that NO FEED ANYBODY REACHED
# HAS EVER PUBLISHED. The three plugins fed from it - `engie_dynamic`,
# `totalenergies_dynamic`, `eneco_eddy` - had no captured body because their
# endpoints do not exist, so the loop was closed twice over: an invented parser
# graded against an invented body, on behalf of plugins that could never fetch.
# Both plugins that remain are replayed from something real - `generic_dayahead`
# from captured bytes (section 2b), `manual_csv` from the CSV a human types.
#
# The shape itself survives in ONE place and in one direction only: as the body
# section 9 feeds `generic_dayahead` to prove that a row-per-point document is a
# FAILURE for it rather than a second dialect it might sniff. It is named for
# what it is.
def row_shape_body(key, values):
"""A `{"data": [...]}` document. ⚠️ EXISTS ONLY TO BE REFUSED.
No feed this repository has ever reached publishes this, and the parser
that read it was withdrawn with the three plugins it served (TARIFF-07).
It is kept as a NEGATIVE stimulus: if `_points_from_columnar_json()` ever
grows a sniff for `data`, section 9's "the body is the OTHER shape" row
turns from `no_data` into a series and a check goes red, rather than
TARIFF-08's scope rule being widened by a review nobody remembers.
"""
return json.dumps({"data": [
{"start": (T0 + timedelta(hours=i)).isoformat(), key: v,
"provisional": False}
for i, v in enumerate(values)]}).encode()
def columnar_feed(values, provisional_at=None, unit="EUR / MWh",
step_min=60, start=None, prices=None):
"""A body in the shape `api.energy-charts.info` really publishes.
Parallel arrays and no per-point object; `unix_seconds` are UTC epoch
seconds. `prices` overrides the price column outright, which is how the
unequal-length case below is built without hand-writing JSON.
⚠️ THERE IS NO PER-POINT `provisional` IN THIS SHAPE, so `provisional_at`
exists only to be REFUSED - the columnar document cannot carry the flag and
the plugin supplies it. It is a keyword the callers do not pass; the
parameter is here so that a future caller who reaches for it gets a
TypeError rather than a silently ignored argument.
"""
if provisional_at is not None:
raise TypeError(
"the columnar document has no per-point provisional flag; "
"`generic_dayahead` declares it in fetch(). See "
"_points_from_columnar_json().")
t0 = start if start is not None else T0
secs = [int((t0 + timedelta(minutes=step_min * i)).timestamp())
for i in range(len(values))]
doc = {"unix_seconds": secs,
"price": list(values) if prices is None else list(prices),
"unit": unit,
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
"from Bundesnetzagentur | SMARD.de",
"deprecated": False}
return json.dumps(doc).encode()
CSV_BODY = ("start,eur_per_kwh,provisional\n"
"2026-01-15T00:00:00+01:00,0.1834,false\n"
"2026-01-15T01:00:00+01:00,-0.0210,true\n"
"2026-01-15T02:00:00+01:00,0.3115,false\n")
# The fixture, the transport that serves it, and - crucially - the ENERGY
# component of each point, computed here so the all-in expectation below is
# arithmetic this file did itself.
# ⚠️ `generic_dayahead` IS FED THE COLUMNAR SHAPE AND THE OTHER THREE ARE NOT,
# and the asymmetry is the honest one rather than an oversight: the columnar
# document is what the one endpoint anybody has ever reached actually returns.
FIXTURES = {
"generic_dayahead": (columnar_feed(EPEX_EUR_MWH),
[v / 1000 for v in EPEX_EUR_MWH]),
"manual_csv": (None, [0.1834, -0.0210, 0.3115]),
}
check(f"the two plugins that survive TARIFF-07 are registered "
f"{sorted(PROVIDERS)}",
set(PROVIDERS) == {"generic_dayahead", "manual_csv"})
check(f"...and the three withdrawn ids resolve to NOTHING "
f"{sorted(RETIRED_PROVIDERS)}",
set(RETIRED_PROVIDERS) == {"engie_dynamic", "totalenergies_dynamic",
"eneco_eddy"}
and not (set(RETIRED_PROVIDERS) & set(PROVIDERS)))
def _readd_retired():
return register(type("_ReAdd", (PriceProvider,), {
"id": "eneco_eddy",
"capabilities": classmethod(lambda c: ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY}))),
"fetch": lambda s, o, a, b, t: None}))
# ⚠️ GRADED ON THE REASON, NOT THE VERDICT. `register()` already refuses a
# DUPLICATE id, so "it raised ValueError" would pass on a tree where the
# retired guard is deleted and the id happens to be live again - the masking
# shape MUTATION.md's TEST-16 section is about.
_readd_why = _reason(_readd_retired)
check(f"re-registering a retired id is refused, and refused for the RETIRED "
f"reason rather than the duplicate one ({_readd_why[:60]!r})",
"RETIRED_PROVIDERS" in _readd_why and "eneco_eddy" in _readd_why)
check("...and the refusal did not take effect anyway",
"eneco_eddy" not in PROVIDERS)
check("...and the fixture set covers every one of them",
set(FIXTURES) == set(PROVIDERS))
def canned(body):
"""A transport that serves recorded bytes and records that it was used."""
calls = []
async def t(url, timeout_s):
calls.append((url, timeout_s))
return body
t.calls = calls
return t
def load(pid, extra=None, transport=None):
o = opts(price_provider=pid, **(extra or {}))
return run(load_prices(o, T0, T0 + timedelta(hours=48),
transport=transport))
import tempfile # noqa: E402
_csv_dir = tempfile.mkdtemp(prefix="tariff02-")
CSV_PATH = os.path.join(_csv_dir, "prices.csv")
with open(CSV_PATH, "w", encoding="utf-8") as fh:
fh.write(CSV_BODY)
def load_any(pid):
body, _ = FIXTURES[pid]
if pid == "manual_csv":
return load(pid, {"price_csv_path": CSV_PATH}, transport=refuse_transport)
return load(pid, transport=canned(body))
async def _refuse(url, timeout_s):
raise AssertionError(f"the offline plugin reached the network: {url}")
refuse_transport = _refuse
READS = {pid: load_any(pid) for pid in PROVIDERS}
_wrong_kind = sorted(p for p, r in READS.items() if r.kind != "series")
check(f"every plugin returned a series from its fixture", _wrong_kind == [])
# THE SCHEMA. Exactly three keys, in every point of every plugin, and the check
# derives the key set from the objects rather than asserting a typed literal.
_keys = derived(lambda: {frozenset(p.keys())
for r in READS.values()
for p in r.canonical()["prices"]})
check(f"every canonical point has exactly the three §9.3 keys, and only those "
f"-> {_keys}",
_keys == {frozenset({"start", "eur_per_kwh", "provisional"})})
check("the envelope is exactly {'prices': [...]}",
derived(lambda: all(set(r.canonical()) == {"prices"}
for r in READS.values())) is True)
_badtype = derived(lambda: sorted(
p for p, r in READS.items()
if not all(isinstance(pt["eur_per_kwh"], float)
and isinstance(pt["provisional"], bool)
and isinstance(pt["start"], str)
for pt in r.canonical()["prices"])))
check(f"...with a float price, a bool flag and an ISO string start {_badtype}",
_badtype == [])
_naive = derived(lambda: sorted(p for p, r in READS.items()
if any(pt.start.utcoffset() is None
for pt in r.points)))
check(f"every start is timezone-aware (§9.3 shows +01:00) {_naive}",
_naive == [])
_offsets = derived(lambda: {pt.start.utcoffset()
for r in READS.values() for pt in r.points})
check(f"...and at a Belgian winter offset for a January fixture {_offsets}",
_offsets == {timedelta(hours=1)})
check("the whole envelope round-trips through json.dumps",
derived(lambda: all(json.loads(json.dumps(r.canonical())) == r.canonical()
for r in READS.values())) is True)
# --------------------------------------------------------------------------- #
print()
print("2b. TARIFF-08 - the SHIPPED parser, replayed against REAL captured "
"bodies")
#
# ⚠️ WHY THIS SECTION EXISTS, WHICH IS THE MORE IMPORTANT HALF OF THE TICKET.
# Until 2026-09-10 every price fixture in this repository - here, in
# test_price_history.py and in test_static_tariff.py - was a
# `{"data": [{"start": ..., "price_eur_mwh": ...}]}` document invented by
# whoever wrote `_points_from_json()`. `api.energy-charts.info` has never
# published that shape. So the suite validated the parser against the parser's
# own assumption: a closed loop that cannot notice the assumption is wrong, and
# it stayed green while `generic_dayahead` raised `SourceUnavailable` on every
# live fetch it ever made. §11.4 then made that silent - a parse error becomes
# `no_data` becomes the static profile with arbitrage off, which is the CORRECT
# degradation and says nothing at all about the cause.
#
# The only thing that breaks the loop is bytes nobody here wrote. Both fixtures
# below are unmodified response bodies, captured by hand with curl on
# 2026-09-10; `scenarios/energy-charts-be-price-captures.json` records the URL,
# the retrieval time, the licence as returned and a sha256 of each, and the
# checksums are RE-DERIVED here so a fixture edited to make a check pass stops
# being a capture and starts being a failure.
#
# ⚠️ THERE IS NOW EXACTLY ONE NETWORKED PLUGIN, WHICH IS TARIFF-07's DOING.
# `engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy` had no capture
# because their endpoints do not exist - verified by hand on 2026-09-13: a 404
# from a gateway with no route, a TLS hostname mismatch, and a 404 with an empty
# body. They were withdrawn rather than left fed by an invented fixture. The
# check below is derived from the shipped `PROVIDERS` registry, so a plugin
# added with no capture forces the question rather than inheriting silence.
_CAPTURES = json.load(open(
os.path.join(HERE, "scenarios", "energy-charts-be-price-captures.json"),
encoding="utf-8"))
def _capture(name):
with open(os.path.join(HERE, "scenarios", name), "rb") as fh:
return fh.read()
_REAL = {n: _capture(n) for n in _CAPTURES["files"]}
_bad_sha = sorted(n for n, b in _REAL.items()
if hashlib.sha256(b).hexdigest()
!= _CAPTURES["files"][n]["sha256"])
check(f"all three captured bodies are the bytes the provenance file "
f"recorded {sorted(_REAL)}",
len(_REAL) == 3 and _bad_sha == [])
# The document really is columnar, derived from the bytes rather than asserted:
# if it ever grows a `data` key this check says so before any parser does.
_shapes = {n: sorted(json.loads(b.decode("utf-8"))) for n, b in _REAL.items()}
check(f"...and every one is COLUMNAR - parallel arrays, no 'data' key "
f"{sorted({tuple(v) for v in _shapes.values()})}",
all(v == ["deprecated", "license_info", "price", "unit", "unix_seconds"]
for v in _shapes.values()))
check("...which is precisely the shape the shipped parser used to demand a "
"'data' list from",
all("data" not in v for v in _shapes.values()))
# ⚠️ CC BY 4.0 IS WHY THESE BYTES MAY BE IN THE REPOSITORY AT ALL. DATA-02
# established that the BE zone is redistributable with attribution; the feed
# restates the licence per response, so a capture whose own licence line has
# changed is a capture that must not stay committed.
_lic = {n: json.loads(b.decode("utf-8"))["license_info"] for n, b in _REAL.items()}
check(f"...and each carries the CC BY 4.0 line the provenance block cites",
all("CC BY 4.0" in v for v in _lic.values())
and all(_lic[n] == _CAPTURES["files"][n]["licence_as_returned"]
for n in _REAL))
_QH = "energy-charts-be-price-2026-09-10-quarterhourly.json"
_H1 = "energy-charts-be-price-2025-09-10-hourly.json"
# ⚠️ THE TWO GRANULARITIES ARE NOT DECORATION. Belgium moved to a 15-minute MTU
# on 2025-10-01 and the same endpoint answers 96 points at 900 s for a day after
# it and 24 at 3600 s for a day before it. A fixture at one spacing cannot grade
# whether anything assumes the other, so both are replayed and the spacings are
# DERIVED from the bodies rather than typed.
_spacing = {}
for _n, _b in _REAL.items():
_u = json.loads(_b.decode("utf-8"))["unix_seconds"]
_spacing[_n] = sorted({_u[i + 1] - _u[i] for i in range(len(_u) - 1)})
check(f"the two captures really differ in granularity - {_spacing[_QH]} s over "
f"{len(json.loads(_REAL[_QH])['price'])} points and {_spacing[_H1]} s "
f"over {len(json.loads(_REAL[_H1])['price'])} points - or replaying both "
f"grades nothing more than replaying one",
_spacing[_QH] == [900] and _spacing[_H1] == [3600])
_REPLAY = {n: load("generic_dayahead", transport=canned(b))
for n, b in _REAL.items()}
_not_series = sorted(n for n, r in _REPLAY.items() if r.kind != "series")
check(f"the SHIPPED generic_dayahead parses both real bodies "
f"{[r.words for r in _REPLAY.values()]}",
_not_series == [])
# The point count is the source's own interval count, taken from the document.
_counts = derived(lambda: {n: len(r.points) for n, r in _REPLAY.items()})
_want_counts = {n: len(json.loads(b.decode("utf-8"))["price"])
for n, b in _REAL.items()}
check(f"...emitting one point per published interval, no decimation "
f"{_counts}", _counts == _want_counts)
check(f"...which for the quarter-hourly body is 96 points and not 24 - the "
f"15-minute MTU is the shape of the real feed, not a hypothetical",
_want_counts[_QH] == 96 and _want_counts[_H1] == 24)
# §9.3: unix_seconds are UTC instants, RENDERED at the Brussels offset. Both
# captured days are inside CEST, so the correct first start is local midnight at
# +02:00. ⚠️ A NAIVE READING - `datetime.fromtimestamp(t)` with no tzinfo - is
# what this grades: it would produce the host's local wall clock, and on a UTC
# host that is 22:00 of the previous day. The expectation is computed here from
# the raw epoch second by different arithmetic than price.py uses.
_first_raw = {n: json.loads(b.decode("utf-8"))["unix_seconds"][0]
for n, b in _REAL.items()}
_first_utc = {n: datetime(1970, 1, 1, tzinfo=UTC) + timedelta(seconds=t)
for n, t in _first_raw.items()}
_first_got = derived(lambda: {n: r.points[0].start
for n, r in _REPLAY.items()})
check(f"every start is the UTC instant the feed published "
f"{ {n: v.isoformat() for n, v in _first_utc.items()} }",
isinstance(_first_got, dict)
and all(_first_got[n] == _first_utc[n] for n in _REAL))
check(f"...rendered at the Brussels offset, so a September day starts at "
f"00:00+02:00 and NOT at the 22:00 a naive local reading gives "
f"{ {n: v.isoformat() for n, v in (_first_got if isinstance(_first_got, dict) else {}).items()} }",
isinstance(_first_got, dict)
and all(v.utcoffset() == timedelta(hours=2) for v in _first_got.values())
and all((v.hour, v.minute) == (0, 0) for v in _first_got.values()))
# EUR/MWh -> all-in EUR/kWh, recomputed here from the raw column.
_allin_want = {n: [round(v / 1000 + MARKUP + TAXES, 6)
for v in json.loads(b.decode("utf-8"))["price"]]
for n, b in _REAL.items()}
_allin_got = derived(lambda: {n: [p.eur_per_kwh for p in r.points]
for n, r in _REPLAY.items()})
check(f"...and every price is the published EUR/MWh figure over 1000 plus "
f"markup and taxes (first: {[v[0] for v in _allin_want.values()]})",
_allin_got == _allin_want)
# ⚠️ NON-DEGENERACY. If the captured prices happened to be all equal, the
# arithmetic check above would pass under a parser that emitted the same number
# for every interval.
_raw_spread = {n: round(max(v) - min(v), 6)
for n, v in ((k, json.loads(b.decode("utf-8"))["price"])
for k, b in _REAL.items())}
check(f"...on bodies whose prices actually vary, or that check grades a "
f"constant {_raw_spread}",
all(v > 1.0 for v in _raw_spread.values()))
# --- the refusals, driven from the real body by breaking one thing ---------- #
def _edited(name, **fields):
"""The captured body with one field replaced. Everything else is real."""
doc = json.loads(_REAL[name].decode("utf-8"))
doc.update(fields)
return json.dumps(doc).encode()
_full = json.loads(_REAL[_QH].decode("utf-8"))
_short = load("generic_dayahead",
transport=canned(_edited(_QH, price=_full["price"][:-4])))
check(f"a `price` array 4 entries short of `unix_seconds` is no_data, NOT a "
f"series truncated to {len(_full['price']) - 4} points - the missing "
f"tail of a Belgian day is the evening peak, and a short series is "
f"traded on where no data at all switches arbitrage off (§11.4)",
_short.kind == "no_data")
check("...and the reason names the two lengths, so an operator can see it was "
"a length mismatch and not an outage",
_short.kind == "no_data"
and str(len(_full["unix_seconds"])) in _short.reason
and str(len(_full["price"]) - 4) in _short.reason)
# The other direction, so the check is not a one-sided `len(price) < len(time)`.
_long = load("generic_dayahead",
transport=canned(_edited(_QH, price=_full["price"] + [1.0])))
check("...and a `price` array one entry LONGER is refused too",
_long.kind == "no_data")
# ⚠️ THE CONTROL. Without it, "refuses everything" passes both checks above.
check("...while the untouched body is still a series, so the refusal is the "
"length and not the editing",
load("generic_dayahead",
transport=canned(_edited(_QH))).kind == "series")
_unit = load("generic_dayahead", transport=canned(_edited(_QH,
unit="EUR / kWh")))
check("a feed that changes its unit to EUR/kWh is no_data, not every price "
"silently divided by 1000 - MAX_PLAUSIBLE_EUR_KWH only bounds prices "
"that are too LARGE, so nothing downstream could catch it",
_unit.kind == "no_data")
_nulls = _full["price"][:]
for _i in (3, 17):
_nulls[_i] = None
_nulled = load("generic_dayahead", transport=canned(_edited(_QH,
price=_nulls)))
check(f"a published null is DROPPED, leaving a hole rather than a zero - a "
f"missing quarter is not a free quarter",
_nulled.kind == "series"
and derived(lambda: len(_nulled.points)) == len(_full["price"]) - 2)
check("...and the dropped instants are the ones the feed left null, not the "
"first two",
derived(lambda: sorted(
int(p.start.timestamp()) for p in _nulled.points))
== sorted(t for i, t in enumerate(_full["unix_seconds"])
if i not in (3, 17)))
_allnull = load("generic_dayahead",
transport=canned(_edited(_QH,
price=[None] * len(_full["price"]))))
check("...but a body with a price for nothing at all is no_data, not an empty "
"series", _allnull.kind == "no_data")
# ⚠️ WHICH RULE REFUSED IT, not merely that something did. `_validate_raw()`
# would also refuse an empty list, one layer further out and with a sentence
# that says nothing about the feed - so asserting only `no_data` here would
# grade the same under a parser that had lost this refusal entirely.
check(f"...refused by the COLUMNAR parser, naming what arrived, rather than "
f"reaching the boundary validator as a generic empty list - "
f"{_allnull.reason!r}",
_allnull.kind == "no_data"
and f"{len(_full['price'])} instants" in _allnull.reason
and "a price for none of them" in _allnull.reason)
# ⚠️ THE TWO WARNINGS ARE GRADED, NOT ASSUMED. §11.4 is exactly what makes a
# price defect quiet - a parse failure becomes no_data becomes the static
# profile, correctly and silently - so an unverified diagnostic is the first
# thing here to rot. Both are captured off the shipped logger.
_wbuf = io.StringIO()
_wh = logging.StreamHandler(_wbuf)
logging.getLogger("goodwe.price").addHandler(_wh)
load("generic_dayahead", transport=canned(_edited(_QH, price=_nulls)))
_dep = load("generic_dayahead", transport=canned(_edited(_QH,
deprecated=True)))
logging.getLogger("goodwe.price").removeHandler(_wh)
_warn = _wbuf.getvalue()
_n_null = len([v for v in _nulls if v is None])
check(f"the dropped nulls are reported with a count ({_n_null} of "
f"{len(_nulls)}), so a series with holes in it is visible to an operator "
f"rather than merely short",
f"{_n_null} of {len(_nulls)}" in _warn)
check("...and a feed announcing deprecated=true says so WHILE IT STILL WORKS - "
"the day it stops, §11.4 makes its death look exactly like a quiet one",
"deprecated" in _warn and _dep.kind == "series")
# Which plugins are replayed from real bytes, derived from the registry so a
# sixth supplier cannot be added without this line noticing.
_networked = sorted(pid for pid, cls in PROVIDERS.items()
if cls.capabilities().egress_hosts)
_replayed = ["generic_dayahead"]
check(f"the networked plugins are {_networked}, and EVERY one of them is "
f"replayed from a captured real body ({_replayed}) - no shipped plugin "
f"reaches a network this file has never seen answer (TARIFF-07)",
_networked == _replayed)
# --------------------------------------------------------------------------- #
print()
print("2c. TARIFF-09 - the RANGE the caller asked for, on the Brussels "
"calendar")
#
# ⚠️ WHAT WAS WRONG, AND WHY NO CHECK IN THIS FILE COULD SEE IT.
# `generic_dayahead.fetch()` accepted `since` and `until` and requested the
# bare `https://api.energy-charts.info/price?bzn=BE`, which answers ONE
# Brussels day. Everything past today was then filled from yesterday's profile
# by FCST-04, flagged provisional, and §8.3 dropped all of it from the trade
# set - so the optimiser never saw tomorrow as firm, at any hour of any day.
# Measured on the merged tree at 2026-09-10T09:00+02:00 over a 48 h horizon:
# 15 firm whole hours and 33 carried, both ends of the only permitted cycle
# inside ONE calendar day; and at 23:00, 1 firm hour and 47 carried, where the
# firm spread no longer clears the round-trip cost at all.
#
# The reason section 2b could not catch it is that section 2b fed the parser
# BYTES and never looked at the REQUEST. `canned()` has always recorded the URL
# it was asked for and nothing asserted on it - so the transport knew the
# plugin was ignoring its arguments and no check ever asked. The checks below
# assert on the URL, and the two-day body they replay is a capture of what that
# URL really answers.
_TD = "energy-charts-be-price-2026-09-09_2026-09-10-twoday.json"
# ⚠️ NON-DEGENERACY FIRST (habit 1): the two-day capture must really cover TWO
# Brussels days and the one-day capture ONE, or "the range reached the feed"
# is graded against two fixtures that are the same shape.
_bdays = {n: sorted({brussels(datetime.fromtimestamp(t, UTC)).date().isoformat()
for t in json.loads(b.decode("utf-8"))["unix_seconds"]})
for n, b in _REAL.items()}
check(f"the captures span the local-day counts this section needs - "
f"{ {n: len(v) for n, v in _bdays.items()} } - or the range check has "
f"nothing to distinguish {_bdays[_TD]} from {_bdays[_QH]}",
len(_bdays[_TD]) == 2 and len(_bdays[_QH]) == 1
and len(_bdays[_H1]) == 1)
_npts = {n: len(json.loads(b.decode("utf-8"))["price"])
for n, b in _REAL.items()}
check(f"...and the two-day body carries 192 quarter-hours where the one-day "
f"body carries 96, which is the counterfactual this card is built on "
f"{_npts}",
len(json.loads(_REAL[_TD].decode("utf-8"))["price"]) == 192
and len(json.loads(_REAL[_QH].decode("utf-8"))["price"]) == 96)
# --- the URL the SHIPPED plugin asks for ----------------------------------- #
def asked_url(since, until, extra=None):
"""The URL `generic_dayahead` really requests for a window. Behaviour,
not source text: it comes back off the injected transport."""
t = canned(_REAL[_TD])
o = opts(price_provider="generic_dayahead", **(extra or {}))
run(load_prices(o, since, until, transport=t))
return t.calls[0][0]
# The expectation is built HERE, from the window, by different arithmetic than
# price.py uses: a literal string with the two dates spelled out.
_w0 = datetime.fromisoformat("2026-09-09T09:17:43+02:00")
check(f"a 48 h horizon from 09:17 local asks the feed for the three Brussels "
f"days it touches -> {asked_url(_w0, _w0 + timedelta(hours=48))!r}",
asked_url(_w0, _w0 + timedelta(hours=48))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-11")
check("...and the shipped default no longer asks the bare URL that answers a "
"single day",
asked_url(_w0, _w0 + timedelta(hours=48))
!= "https://api.energy-charts.info/price?bzn=BE")
# ⚠️ THE BRUSSELS/UTC BOUNDARY, WHICH IS THE TRAP. `since`/`until` are
# INSTANTS and the API takes DATES. At 22:30 UTC in September Belgium is
# already on the NEXT calendar day, so a range derived from `since.date()` -
# or from `since.astimezone(utc).date()`, which is the same thing - asks for
# the day the controller has just left and drops the day it is standing in.
# This is `_bdate()`'s lesson one layer up: price_history.py MISFILES an hour
# when it gets this wrong; here the hours are never fetched at all.
_edge = datetime.fromisoformat("2026-09-10T22:30:00+00:00")
check(f"the precondition: {_edge.isoformat()} really is a different day in "
f"Brussels ({brussels(_edge).date().isoformat()}) than in UTC "
f"({_edge.date().isoformat()}), or the boundary check grades nothing",
brussels(_edge).date().isoformat() != _edge.date().isoformat())
check(f"...so the range starts on the BRUSSELS day, 2026-09-11, and not on "
f"the UTC day 2026-09-10 -> "
f"{asked_url(_edge, _edge + timedelta(hours=12))!r}",
asked_url(_edge, _edge + timedelta(hours=12))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-11&end=2026-09-11")
# ...and the SAME trap on the other end. `until` at 22:30 UTC is already
# 00:30 of the next Brussels day, so a UTC-derived END stops one day short and
# the last hours of the horizon are never fetched. Both ends of the range
# derivation are graded, or a mutation only has to get one of them right.
_edge2_a = datetime.fromisoformat("2026-09-09T12:00:00+00:00")
_edge2_b = datetime.fromisoformat("2026-09-10T22:30:00+00:00")
check(f"the precondition: {_edge2_b.isoformat()} is "
f"{brussels(_edge2_b).date().isoformat()} in Brussels and "
f"{_edge2_b.date().isoformat()} in UTC, so the two answers differ",
brussels(_edge2_b).date().isoformat() != _edge2_b.date().isoformat())
check(f"...so the range ENDS on the Brussels day 2026-09-11, not on the UTC "
f"day 2026-09-10 -> {asked_url(_edge2_a, _edge2_b)!r}",
asked_url(_edge2_a, _edge2_b)
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-11")
# ⚠️ `until` IS EXCLUSIVE, so local midnight belongs to the day BEFORE it. A
# 24 h horizon from local midnight is ONE day, not two.
_mid = datetime.fromisoformat("2026-09-09T00:00:00+02:00")
check(f"a horizon ending exactly at local midnight asks for one day, because "
f"`until` is exclusive -> {asked_url(_mid, _mid + timedelta(hours=24))!r}",
asked_url(_mid, _mid + timedelta(hours=24))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-09")
check(f"...while one second past midnight is two days, so the rule is the "
f"exclusive END and not a fixed subtraction -> "
f"{asked_url(_mid, _mid + timedelta(hours=24, seconds=1))!r}",
asked_url(_mid, _mid + timedelta(hours=24, seconds=1))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-10")
check(f"an empty or inverted window collapses to a single day rather than "
f"asking for a backwards range no feed has a defined answer for -> "
f"{asked_url(_mid, _mid - timedelta(hours=5))!r}",
asked_url(_mid, _mid - timedelta(hours=5))
== "https://api.energy-charts.info/price?bzn=BE"
"&start=2026-09-09&end=2026-09-09")
# The helper on its own, over the DST transitions, where a fixed-offset
# assumption and a correct one disagree.
_mar = datetime.fromisoformat("2026-03-29T00:30:00+00:00") # 02:30 CEST
_bdr = derived(lambda: brussels_date_range(_mar, _mar + timedelta(hours=1)))
check(f"brussels_date_range() is right across the spring transition, where "
f"00:30 UTC is already 02:30 local -> {_bdr}",
_bdr == (date(2026, 3, 29), date(2026, 3, 29)))
# --- the two-day body, end to end through the SHIPPED parser --------------- #
_two = load("generic_dayahead", transport=canned(_REAL[_TD]))
check(f"the shipped generic_dayahead parses the real two-day body "
f"{_two.words}", _two.kind == "series")
def _series_days(read):
return derived(lambda: sorted({brussels(p.start).date().isoformat()
for p in read.points}))
_two_days = _series_days(_two)
check(f"...and the series it produces spans TWO Brussels local days "
f"{_two_days}, where the one-day body's spans one "
f"{_series_days(_REPLAY[_QH])}",
_two_days == ["2026-09-09", "2026-09-10"]
and len(_two_days) == 2)
check(f"...one point per published interval, 192 of them, no decimation and "
f"no day dropped ({derived(lambda: len(_two.points))})",
derived(lambda: len(_two.points)) == 192)
# --- the SHORT ANSWER, which is normal and must stay a series -------------- #
#
# ⚠️ ASKING FOR TOMORROW BEFORE THE ~13:00-14:00 CET AUCTION RETURNS FEWER DAYS
# THAN REQUESTED, AND THAT IS NOT AN ERROR. Measured: at 2026-09-10T07:46:55Z
# the request `?bzn=BE&start=2026-09-10&end=2026-09-11` came back BYTE-IDENTICAL
# to the bare-URL capture - one day, 96 quarter-hours. A `no_data` there would
# switch a working site's arbitrage off every morning, so the requirement is
# that the short body stays a `series` of what EXISTS and FCST-04's fill covers
# the rest with flagged, provisional hours. The identity is re-derived from the
# bytes rather than restated.
_prepub = _CAPTURES["files"][_QH]["also_returned_verbatim_by"]
check(f"the pre-publication range request {_prepub['url']!r} returned the "
f"SAME BYTES as the bare default URL - one day where two were asked for",
hashlib.sha256(_REAL[_QH]).hexdigest()
== _CAPTURES["files"][_QH]["sha256"]
and "start=2026-09-10&end=2026-09-11" in _prepub["url"])
check(f"...and the shipped parser answers that short body with a SERIES of "
f"what exists, never no_data - {_REPLAY[_QH].words}",
_REPLAY[_QH].kind == "series"
and derived(lambda: len(_REPLAY[_QH].points)) == 96)
# ⚠️ THE COUNTS ARE TAKEN THROUGH `derived()` AND COMPARED ONLY WHEN BOTH ARE
# NUMBERS. A mutation that turns the short answer into `no_data` makes
# `.points` raise `Absence` by design, and `_Raised < _Raised` is a TypeError -
# which takes the file down with a traceback instead of reporting a FAIL. A
# check whose failure mode is a crash has no name attached (habit 5's shape,
# with an exception in place of a hang).
_n_short = derived(lambda: len(_REPLAY[_QH].points))
_n_two = derived(lambda: len(_two.points))
check(f"...which is strictly fewer points than the two-day body "
f"({_n_short} < {_n_two}), so 'short' is a real property of this "
f"stimulus and not a name for it",
isinstance(_n_short, int) and isinstance(_n_two, int)
and _n_short < _n_two)
# --- price_feed_url: the exact URL, and the cost said out loud ------------- #
#
# ⚠️ THE DECISION. An override is used VERBATIM and this plugin appends
# nothing to it. Merging our `start`/`end` into an operator's query string
# would mean re-serialising a URL they wrote, and `start`/`end` are
# energy-charts' spelling - and this provider also DECLARES a SECOND host,
# `web-api.tp.entsoe.eu`, whose date parameters are not those and are not
# verified here, so appending our vocabulary there yields two ignored
# parameters and the short horizon anyway. The cost is
# that an overriding site silently gets the horizon this card exists to fix,
# so it is NOT silent: a warning names the consequence at fetch time.
_ovr = "https://api.energy-charts.info/price?bzn=BE&somethingelse=1"
check(f"an override is requested EXACTLY as given, with nothing appended -> "
f"{asked_url(_w0, _w0 + timedelta(hours=48), {'price_feed_url': _ovr})!r}",
asked_url(_w0, _w0 + timedelta(hours=48),
{"price_feed_url": _ovr}) == _ovr)
_obuf = io.StringIO()
_oh = logging.StreamHandler(_obuf)
logging.getLogger("goodwe.price").addHandler(_oh)
asked_url(_w0, _w0 + timedelta(hours=48), {"price_feed_url": _ovr})
_ovr_log = _obuf.getvalue()
_obuf2 = io.StringIO()
_oh2 = logging.StreamHandler(_obuf2)
logging.getLogger("goodwe.price").removeHandler(_oh)
logging.getLogger("goodwe.price").addHandler(_oh2)
asked_url(_w0, _w0 + timedelta(hours=48))
_dflt_log = _obuf2.getvalue()
logging.getLogger("goodwe.price").removeHandler(_oh2)
check(f"...and the operator is TOLD what it costs them, at fetch time, naming "
f"the horizon consequence rather than only the substitution",
"price_feed_url" in _ovr_log and _ovr in _ovr_log
and "provisional" in _ovr_log and "2026-09-11" in _ovr_log)
# ⚠️ THE CONTROL. Without it, "warns about everything" passes the line above.
check("...while the DEFAULT path says nothing, so the warning is about the "
"override and not about fetching",
"price_feed_url is set" not in _dflt_log)
# The builder refuses to append to a base that already has a query string,
# which is the "URL nobody intended" this decision is meant to make
# unreachable. Driven, not read off the source.
check(f"dated_feed_url() refuses a base that already carries a query string, "
f"rather than producing a second '?' - "
f"{_reason(lambda: dated_feed_url('https://h/p?a=1', b='2'))!r}",
raises(lambda: dated_feed_url("https://h/p?a=1", b="2"),
SourceUnavailable))
check("...and a clean base still builds, so the refusal is the existing query "
"string and not the builder",
dated_feed_url("https://h/p", b="2") == "https://h/p?b=2")
# --- the plugin contract: who consumes the range, pinned ------------------- #
#
# ⚠️ THIS IS A SOURCE-SHAPE PIN AND IS NAMED AS ONE (habit 4). The claim is
# about the CODE - which plugins reference the range arguments they are handed
# - which is the same kind of claim `code_leaks()` makes, and it is not
# evidence that any feed parses. `generic_dayahead`'s BEHAVIOUR is graded above
# by the URL it really requests.
#
# It exists because `fetch(self, opts, since, until, transport)` promises every
# plugin the range and the promise is NOT enforced. Enforcing it would oblige
# the three plugins whose endpoints nobody has verified (TARIFF-07) to invent a
# date-parameter vocabulary - TARIFF-08's defect moved from the response to the
# request. So the split is pinned instead, and a sixth supplier has to change
# this line rather than inherit the silence.
def _uses_range(cls):
src = dedent(inspect.getsource(cls.fetch))
tree = ast.parse(src)
names = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
return bool(names & {"since", "until"})
_uses = sorted(pid for pid, cls in PROVIDERS.items() if _uses_range(cls))
check(f"the plugins that CONSUME their `since`/`until` are {_uses}; the rest "
f"take the range and ignore it, which is what TARIFF-09 fixed for "
f"generic_dayahead - and after TARIFF-07 the only one left that does not "
f"is the offline plugin, for the honest reason below",
_uses == ["generic_dayahead"])
check("...and `manual_csv` is honestly among the ones that do not: it reads a "
"whole file and the fill windows it downstream, so a range parameter "
"would have nowhere to go",
"manual_csv" in sorted(set(PROVIDERS) - set(_uses)))
# --------------------------------------------------------------------------- #
print()
print("3. all-in: energy + markup + taxes, added exactly once (AC1, trap 1)")
# The expected figure is computed HERE, from the fixture's energy component and
# the plugin's own declaration, by different arithmetic than assemble() uses.
for pid, read in sorted(READS.items()):
caps = PROVIDERS[pid].capabilities()
_, energy = FIXTURES[pid]
add = (MARKUP if Component.MARKUP in caps.adds else 0.0) + \
(TAXES if Component.TAXES in caps.adds else 0.0)
want = [round(e + add, 6) for e in energy]
got = derived(lambda r=read: [p.eur_per_kwh for p in r.points])
check(f"{pid}: all-in == published {sorted(c.value for c in caps.sources)} "
f"+ framework {sorted(c.value for c in caps.adds) or 'nothing'} "
f"-> {got}",
got == want)
# ⚠️ THE ONE THAT MATTERS. generic_dayahead is the only shipped plugin whose
# feed is wholesale, so it is the only one that gets both components - and a
# feed declaring more must get strictly less. Derived by comparing output on the
# SAME energy figure rather than by restating numbers.
#
# ⚠️ THE MIDDLE RUNG - a feed sourcing {ENERGY, MARKUP}, which gets TAXES only -
# HAS NO SHIPPED DECLARER SINCE TARIFF-07, and is graded through `assemble()`
# with a hand-built declaration instead of being dropped. It is the only rung
# that distinguishes "adds the complement" from "adds everything or nothing",
# so deleting it with the three retail plugins would have left the partial case
# ungraded on the exact function that must not get it wrong.
def added(pid):
return derived(lambda: round(READS[pid].points[0].eur_per_kwh
- FIXTURES[pid][1][0], 6))
def added_for(sources):
"""What assemble() adds to ONE point declared as sourcing `sources`."""
caps = ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset(sources))
e = RETAIL_EUR_KWH[0]
return derived(lambda: round(
assemble(caps, [RawPoint(T0, e, False)], MARKUP, TAXES)[0].eur_per_kwh
- e, 6))
_generic_add = added("generic_dayahead")
_csv_add = added("manual_csv")
_retail_add = added_for({Component.ENERGY, Component.MARKUP})
check(f"the wholesale feed gets markup+taxes ({_generic_add})",
isinstance(_generic_add, float)
and abs(_generic_add - (MARKUP + TAXES)) < 1e-9)
check(f"a feed that already carries the markup gets taxes ONLY "
f"({_retail_add}), exactly one markup less than the wholesale one",
isinstance(_retail_add, float) and isinstance(_generic_add, float)
and abs(_retail_add - TAXES) < 1e-9
and abs((_generic_add - _retail_add) - MARKUP) < 1e-9)
check(f"an all-in feed gets nothing added ({_csv_add})",
isinstance(_csv_add, float) and abs(_csv_add) < 1e-9)
# Negative prices survive. A framework that clamped them would turn the best
# charging hour of the year into an average one, silently.
_neg = derived(lambda: [p.eur_per_kwh for p in READS["generic_dayahead"].points
if p.eur_per_kwh < 0])
check(f"a negative day-ahead price passes through, all-in {_neg}",
_neg == [round(EPEX_EUR_MWH[1] / 1000 + MARKUP + TAXES, 6)])
# provisional is per point and comes from the feed, not from the series.
#
# ⚠️ `generic_dayahead` IS EXCLUDED FROM THE "MIXED" REQUIREMENT, AND THE
# EXCLUSION IS THE POINT RATHER THAN A GAP. The columnar document the real feed
# publishes has no per-point provisional field - it is a settled auction result
# - so the plugin declares `False` for all of them. Demanding a mixed series
# from it would mean feeding it an invented body carrying an invented flag,
# which is exactly the closed loop TARIFF-08 exists to remove. It is graded on
# what its real body can grade instead, immediately below.
for pid in sorted(set(READS) - {"generic_dayahead"}):
read = READS[pid]
flags = derived(lambda r=read: [p.provisional for p in r.points])
check(f"{pid}: provisional is per point and mixed {flags}",
isinstance(flags, list) and any(flags) and not all(flags))
check(f"...so plan_is_provisional is derived from those points",
derived(lambda r=read: plan_is_provisional(r)) is True)
_gen_flags = derived(lambda: [p.provisional
for p in READS["generic_dayahead"].points])
check(f"generic_dayahead's settled auction comes out settled at every point "
f"{_gen_flags}",
isinstance(_gen_flags, list) and len(_gen_flags) == len(EPEX_EUR_MWH)
and not any(_gen_flags))
check("...so plan_is_provisional is False for it, derived from the same points",
derived(lambda: plan_is_provisional(READS["generic_dayahead"])) is False)
# ...and the same function says False when nothing is provisional. Without this
# arm, "always True" would pass every check above.
_settled = PriceRead.series(
[PricePoint(brussels(T0), 0.2, False)], PriceProduct.DAY_AHEAD, "x", T0)
check("a fully settled series is NOT provisional",
plan_is_provisional(_settled) is False)
# --------------------------------------------------------------------------- #
print()
print("4. double-counting the markup is detectable, and unreachable (trap 1)")
class _Rogue(PriceProvider):
"""A plugin that adds the markup itself while declaring it did not.
NOT registered - it exists only here, as the mutation the design is meant to
survive. It is what a plugin author writes when they copy the wholesale
plugin, hardcode their supplier's margin, and forget to update `sources`.
"""
id = "_rogue_not_shipped"
@classmethod
def capabilities(cls):
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY}))
async def fetch(self, o, since, until, transport):
# The margin the author hardcoded, because they could not read the
# configured one - see the FrameworkOption check below.
return [RawPoint(T0, RETAIL_EUR_KWH[0], False)]
# The honest counterpart to _Rogue: the SAME declaration _Rogue makes, on a
# feed that really does publish only energy. No shipped plugin is needed for it
# and none is used - the comparison is between two declarations, not two
# suppliers.
_honest_caps = ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY, Component.MARKUP}))
_honest = assemble(_honest_caps, [RawPoint(T0, RETAIL_EUR_KWH[0], False)],
MARKUP, TAXES)[0].eur_per_kwh
_rogue_raw = run(_Rogue().fetch(PluginOptions({}), T0, T0, None))
_rogue = assemble(_Rogue.capabilities(), _rogue_raw, MARKUP, TAXES)[0].eur_per_kwh
_delta = derived(lambda: round(_rogue - _honest, 6))
check(f"a plugin that pre-adds the markup and declares it did not comes out "
f"{_delta} high - exactly one markup, so the defect is DETECTABLE and "
f"attributable rather than merely wrong",
isinstance(_delta, float) and abs(_delta - MARKUP) < 1e-9)
check("...and it is not confusable with 'the taxes were added twice'",
isinstance(_delta, float) and abs(_delta - TAXES) > 1e-9)
# ...and now the structural half: the number is not reachable from a plugin, so
# the honest author cannot write that line even by accident.
_po = PluginOptions({"price_markup_eur_kwh": MARKUP,
"price_taxes_eur_kwh": TAXES,
"export_price_eur_kwh": 0.04,
"price_csv_path": "/x"})
_refused = []
for k in ("price_markup_eur_kwh", "price_taxes_eur_kwh", "export_price_eur_kwh"):
try:
_po.get(k)
except FrameworkOption:
_refused.append(k)
check(f"a plugin cannot read the framework's own options {sorted(_refused)}",
sorted(_refused) == sorted(["price_markup_eur_kwh", "price_taxes_eur_kwh",
"export_price_eur_kwh"]))
check("...and `k in opts` is false for them too, so a probe cannot find them",
not any(k in _po for k in ("price_markup_eur_kwh", "price_taxes_eur_kwh")))
check("...while the options a plugin legitimately needs still come through",
_po.get("price_csv_path") == "/x"
and _po.get("price_timeout_s") == PRICE_OPTION_DEFAULTS["price_timeout_s"])
# The prose and the declaration must agree. A docstring that says "the framework
# adds taxes" over a class declaring it sources them is a 3 c/kWh error with a
# reassuring comment on top.
_doc_drift = []
for pid, cls in sorted(PROVIDERS.items()):
line = next((ln for ln in (cls.__doc__ or "").splitlines()
if ln.strip().startswith("SOURCES:")), "")
named = {w.strip() for w in line.split(":", 1)[-1].split(",") if w.strip()}
declared = {c.value for c in cls.capabilities().sources}
if named != declared:
_doc_drift.append((pid, sorted(named), sorted(declared)))
check(f"every plugin's docstring names exactly the components it declares "
f"({len(PROVIDERS)} checked)", _doc_drift == [])
# The ignored-markup warning: a plugin whose feed is already all-in must SAY it
# is ignoring the operator's numbers rather than dropping them silently.
_buf = io.StringIO()
_h = logging.StreamHandler(_buf)
logging.getLogger("goodwe.price").addHandler(_h)
load_any("manual_csv")
load_any("generic_dayahead")
logging.getLogger("goodwe.price").removeHandler(_h)
_warned = _buf.getvalue()
check("an all-in plugin logs that the configured markup/taxes are ignored",
"double-count" in _warned and "manual_csv" in _warned)
check("...and the wholesale plugin, which really does use them, does not",
"generic_dayahead" not in _warned)
# --------------------------------------------------------------------------- #
print()
print("4b. a plugin cannot reach the markup BY ANY ROUTE (review of PR #16)")
# WHY THIS SECTION EXISTS. The first version of this ticket claimed the markup
# was "not in scope" for a plugin because `PluginOptions` refused it. Review
# wrote a double-counting plugin three more ways, one of them touching nothing
# but `PRICE_OPTION_DEFAULTS` - a public dict in the module every plugin must
# already import for `register`. One closed door in a room with three is not a
# guarantee. Honest wholesale baseline 0.188; each attack lands 0.219.
_banned = set(BANNED_IN_PLUGIN_CODE)
check(f"the banned set covers the framework keys AND the three public routes "
f"review actually used {sorted(_banned)}",
_banned == set(_price.FRAMEWORK_KEYS) | {"PRICE_OPTION_DEFAULTS",
"price_option", "_o"})
# code_leaks() fails OPEN when inspect.getsource() cannot find the .py - the
# right trade for a battery controller, and a silently disarmed guard if nobody
# checks. THIS is the check that makes CI prove the guard is armed.
_srclen = derived(lambda: {pid: len(inspect.getsource(cls))
for pid, cls in PROVIDERS.items()})
check(f"the scan really read every shipped plugin's source, so its fail-open "
f"path is not silently in use {_srclen}",
isinstance(_srclen, dict) and set(_srclen) == set(PROVIDERS)
and _srclen != {} and all(n > 200 for n in _srclen.values()))
_dirty = derived(lambda: {pid: code_leaks(cls)
for pid, cls in PROVIDERS.items()
if code_leaks(cls)})
check(f"...and every shipped plugin is clean {_dirty}", _dirty == {})
# THE DOCSTRING STRIP IS LOAD-BEARING, AND THIS PROVES IT RATHER THAN ASSERTING
# IT. Every plugin is REQUIRED to name its sourced components in prose, and a
# plugin whose feed already carries the markup naturally writes the option key
# out to say the framework must not add it again - which a RAW text scan reads
# as a leak. A check that fires on the documentation the same file mandates is
# a check somebody deletes.
#
# ⚠️ THE STIMULUS IS BUILT HERE NOW, AND SAYING SO IS THE POINT. Until
# TARIFF-07 this asserted on the SHIPPED set, because `engie_dynamic`'s
# docstring happened to spell `price_markup_eur_kwh` in full. It was withdrawn,
# no remaining shipped docstring spells a full key, and grading the shipped set
# would therefore now pass on a tree with the strip DELETED - a check that had
# quietly stopped being one. So the false-positive is constructed, and the
# shipped set is asserted to be clean separately, above.
class _DocumentedPlugin(PriceProvider):
"""A plugin documenting, correctly, that price_markup_eur_kwh is not added.
SOURCES: energy, markup
The framework ADDS: taxes
"""
id = "_documented_not_shipped"
@classmethod
def capabilities(cls):
return ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY, Component.MARKUP}))
async def fetch(self, o, since, until, transport):
return [RawPoint(T0, RETAIL_EUR_KWH[0], False)]
_raw_hit = derived(lambda: sorted(
b for b in _banned if b in inspect.getsource(_DocumentedPlugin)))
check(f"a RAW text scan false-positives on a mandated docstring {_raw_hit}, "
f"which is why code_leaks() strips docstrings before searching - and on "
f"the same class code_leaks() reports {code_leaks(_DocumentedPlugin)}",
_raw_hit == ["price_markup_eur_kwh"]
and code_leaks(_DocumentedPlugin) == []
and _dirty == {})
class _AttackPrivateDict(PriceProvider):
"""A1: reaches into PluginOptions' private dict.
SOURCES: energy
The framework ADDS: markup, taxes
"""
id = "_attack_private_dict"
@classmethod
def capabilities(cls):
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY}))
async def fetch(self, o, since, until, transport):
return [RawPoint(T0, 0.10 + o._o["price_markup_eur_kwh"], False)]
class _AttackHelper(PriceProvider):
"""A2: calls a public framework helper with the raw options dict.
SOURCES: energy
The framework ADDS: markup, taxes
"""
id = "_attack_helper"
@classmethod
def capabilities(cls):
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY}))
async def fetch(self, o, since, until, transport):
return [RawPoint(T0, 0.10 + price_option(o._o, "price_markup_eur_kwh"),
False)]
class _AttackPublicConstant(PriceProvider):
"""A2b: touches nothing but a public module constant. THE DECIDING ONE.
SOURCES: energy
The framework ADDS: markup, taxes
"""
id = "_attack_public_constant"
@classmethod
def capabilities(cls):
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY}))
async def fetch(self, o, since, until, transport):
return [RawPoint(T0,
0.10 + PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"],
False)]
_ATTACKS = {"A1 private dict": _AttackPrivateDict,
"A2 public helper": _AttackHelper,
"A2b public constant": _AttackPublicConstant}
for _name, _cls in _ATTACKS.items():
_found = derived(lambda c=_cls: code_leaks(c))
check(f"{_name}: the scan names what it reached for {_found}",
isinstance(_found, list) and _found != [])
check(f"...and register() REFUSES it at import, not at fetch time",
raises(lambda c=_cls: register(c), FrameworkOption))
check(f"...so it never enters the registry", _cls.id not in PROVIDERS)
# THE COST OF THE DEFECT, MEASURED, so the refusal is not grading itself.
_honest_wholesale = derived(lambda: assemble(
PROVIDERS["generic_dayahead"].capabilities(),
[RawPoint(T0, 0.10, False)], MARKUP, TAXES)[0].eur_per_kwh)
_attacked = derived(lambda: assemble(
_AttackPublicConstant.capabilities(),
[RawPoint(T0, 0.10 + MARKUP, False)], MARKUP, TAXES)[0].eur_per_kwh)
check(f"an unrefused attack would bill {_attacked} against an honest "
f"{_honest_wholesale} - one markup high, every hour, silently",
isinstance(_attacked, float) and isinstance(_honest_wholesale, float)
and abs((_attacked - _honest_wholesale) - MARKUP) < 1e-9)
# WHAT THIS DOES *NOT* CLOSE, AND CANNOT. A plugin that declares {ENERGY} while
# its feed is really all-in comes out one markup high while touching no
# framework name at all. No code can check that: the declaration is the
# plugin's unverifiable claim about somebody else's tariff. Stated here so the
# guarantee is read at its true size - the complement is added exactly once,
# GIVEN A CORRECT DECLARATION - and so a reviewer who trusts an absolute
# guarantee does not stop checking. Measured, not asserted:
_misdeclared = derived(lambda: assemble(
ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY})),
[RawPoint(T0, 0.10 + MARKUP + TAXES, False)], MARKUP, TAXES)[0].eur_per_kwh)
check(f"a MIS-DECLARED all-in feed still doubles ({_misdeclared} vs "
f"{_honest_wholesale}) with no framework name touched - the limit of the "
f"guarantee, stated rather than hidden",
isinstance(_misdeclared, float)
and abs((_misdeclared - _honest_wholesale) - (MARKUP + TAXES)) < 1e-9
and code_leaks(_AttackPrivateDict) != [])
# --------------------------------------------------------------------------- #
print()
print("5. product type is declared, not inferred from the name (AC2, trap 2)")
# ⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME`, AND THAT IS RECORDED
# HERE RATHER THAN LEFT TO BE REDISCOVERED (TARIFF-07). `eneco_eddy` was its
# sole declarer and it is withdrawn, so `_WIDE`, `Uncertainty.REALTIME_PRODUCT`
# and `WIDEN_FACTOR`'s 2.5 have no shipped caller. They are KEPT - a reviewer
# finding a mechanism with zero callers would reasonably delete it, and the
# first real imbalance product would then have to rebuild the AC2 seam - and
# this pin is what tells that reviewer the emptiness is intentional. See
# DECISIONS/TARIFF-07-withdrawing-three-plugins.md.
#
# ⚠️ IT IS A PIN, NOT A PROHIBITION. If a plugin that really is real-time ever
# ships, this check goes red and the right fix is to EDIT it - and the decision
# record with it - not to delete the seam.
_products = {pid: cls.capabilities().product for pid, cls in PROVIDERS.items()}
_realtime = sorted(p for p, v in _products.items()
if v is PriceProduct.REALTIME)
check(f"no SHIPPED plugin declares real-time/imbalance {_realtime or '[]'} - "
f"§9.2's row 2 is unoccupied since TARIFF-07 withdrew eneco_eddy, and "
f"the widening path below is kept anyway, on purpose",
_realtime == [])
_regimes = {pid: volatility_regime(v) for pid, v in _products.items()}
check(f"...so every shipped plugin is in the NORMAL regime {_regimes}",
set(_regimes.values()) == {"normal"})
# ...and the machinery is still LIVE, graded from a declaration this file makes
# rather than from a plugin. This is the same stimulus AC2 promises a sixth
# supplier: declare REALTIME, get the wide regime, edit nothing in price.py.
_rt_caps = ProviderCapabilities(product=PriceProduct.REALTIME,
sources=frozenset({Component.ENERGY}))
check(f"the widening path still works from a declaration alone "
f"({volatility_regime(_rt_caps.product)}), which is why §9.2 row 2 "
f"having no occupant is not the same as it having no mechanism",
volatility_regime(_rt_caps.product) == "wide"
and volatility_regime(PriceProduct.DAY_AHEAD) == "normal")
check(f"...and it is the PRODUCT that carries it: uncertainty_of() answers "
f"{sorted(u.value for u in uncertainty_of(PriceProduct.REALTIME))} for "
f"real-time and nothing for day-ahead, and widen_factor() turns that "
f"into {widen_factor(uncertainty_of(PriceProduct.REALTIME))}",
uncertainty_of(PriceProduct.REALTIME)
== frozenset({Uncertainty.REALTIME_PRODUCT})
and uncertainty_of(PriceProduct.DAY_AHEAD) == frozenset()
and widen_factor(uncertainty_of(PriceProduct.REALTIME)) == 2.5
and widen_factor(uncertainty_of(PriceProduct.DAY_AHEAD)) == 1.0)
def _accepts_name():
"""Would volatility_regime() take a plugin id? If it would, somebody can
start passing one and AC2 rots without a single check going red."""
try:
volatility_regime("eneco_eddy")
return True
except TypeError:
return False
check("volatility_regime refuses a plugin NAME, which is the AC2 violation "
"written out",
not _accepts_name())
# ⚠️ THE MECHANICAL FORM OF AC2. The framework's own code must contain no
# plugin id at all - if it does, the sixth supplier is an edit to price.py's
# shared machinery rather than a new class. Derived: build the set of ids that
# actually occur, and assert it is empty.
_framework = [_price.assemble, _price.load_prices, _price.volatility_regime,
_price.arbitrage_allowed, _price.plan_is_provisional,
_price.check_egress, _price._validate_raw, _price.PriceRead,
_price._Series, _price._NoData, _price.PluginOptions,
_price.register, _price.price_option]
_src = "\n".join(inspect.getsource(o) for o in _framework)
_leaked = sorted(pid for pid in PROVIDERS if pid in _src)
check(f"no plugin id appears anywhere in the framework's code {_leaked or '[]'}",
_leaked == [])
# ...and the positive half: a plugin defined HERE, in a file price.py has never
# heard of, gets the right regime with no edit anywhere.
@register
class _NewSupplier(PriceProvider):
"""A sixth supplier, invented in the test file. AC2 in one class.
SOURCES: energy
The framework ADDS: markup, taxes
"""
id = "_test_sixth_supplier"
@classmethod
def capabilities(cls):
return ProviderCapabilities(product=PriceProduct.REALTIME,
sources=frozenset({Component.ENERGY}),
egress_hosts=("api.example.invalid",))
async def fetch(self, o, since, until, transport):
body = await transport("https://api.example.invalid/p",
float(o.get("price_timeout_s")))
return _price._points_from_columnar_json(body, expect_unit="EUR / MWh",
scale=0.001,
provisional_default=False)
# ⚠️ THIS CLASS IS ALSO THE ONLY LIVE DECLARER OF `PriceProduct.REALTIME` IN
# THE TREE (TARIFF-07), which makes the widened-threshold path a thing this
# suite exercises end to end - registry, series, regime - and not merely a
# constant section 5 reads. Deleting it would strand the seam twice over.
_sixth = load("_test_sixth_supplier",
transport=canned(columnar_feed(EPEX_EUR_MWH)))
check("a plugin invented outside price.py resolves through the registry",
_sixth.kind == "series" and _sixth.provider == "_test_sixth_supplier")
_sixth_regime = derived(lambda: _sixth.regime)
check(f"...and lands in the wide-threshold regime purely from its declaration "
f"({_sixth_regime})", _sixth_regime == "wide")
check("...and the framework applied its markup and taxes correctly too",
derived(lambda: [p.eur_per_kwh for p in _sixth.points])
== [round(v / 1000 + MARKUP + TAXES, 6) for v in EPEX_EUR_MWH])
del PROVIDERS["_test_sixth_supplier"] # do not leak into later sections
# --------------------------------------------------------------------------- #
print()
print("6. no price data is its own outcome, not an empty list (trap 3, §11.4)")
_none = PriceRead.no_data("the feed timed out")
# ⚠️ THE DISTINCTION THE WHOLE TYPE EXISTS FOR. A day of free electricity and a
# day with no price data are different instructions: arbitrage is POINTLESS
# under the first and FORBIDDEN under the second.
_zero = PriceRead.series([PricePoint(brussels(T0 + timedelta(hours=i)), 0.0,
False) for i in range(3)],
PriceProduct.DAY_AHEAD, "x", T0)
check("a zero-price series and no-price-data are different kinds",
_zero.kind != _none.kind)
check("...arbitrage is allowed under zero prices and forbidden under no data",
arbitrage_allowed(_zero) is True and arbitrage_allowed(_none) is False)
check("...the plan is provisional under no data and settled under zero prices",
plan_is_provisional(_none) is True and plan_is_provisional(_zero) is False)
check("...and the cheapest hour is computable for the zero series",
derived(lambda: min(p.eur_per_kwh for p in _zero.points)) == 0.0)
check("no_data has no .points, so `min(read.points)` cannot invent a free hour",
raises(lambda: _none.points, Absence))
check("...no .product to switch a threshold regime on",
raises(lambda: _none.product, Absence))
check("...no .at(), so an hourly lookup cannot silently succeed",
raises(lambda: _none.at(T0), Absence))
check("...and .get('points', []) - the idiom the whole rework exists to kill - "
"raises rather than returning the default",
raises(lambda: _none.get("points", []), Absence))
check("...and it is not iterable, so `for p in read:` cannot be an empty loop",
raises(lambda: list(_none), Absence))
check("...while its one real field, the reason, is there for an operator",
_none.reason == "the feed timed out")
# M3, INHERITED FROM RIG-01 AND CLOSED HERE. `_NoData.__init__(self,
# reason="no price data")` used to go green: a required field with a DEFAULT is
# the exact idiom this whole type exists to forbid, and it survived because
# every check passed a reason explicitly. It is the identical survivor left
# open on sim/riglock.py's `_Unread.cause` - the shape was borrowed including
# its blind spot, which is what borrowing a shape does if nobody re-runs the
# mutations against the copy.
#
# Checked by SIGNATURE rather than by one call, so any default added to any of
# these constructors later is red without somebody remembering to write a new
# case.
def _defaulted(fn):
"""Parameters of `fn` that carry a default. `self` is skipped."""
try:
ps = list(inspect.signature(fn).parameters.values())
except (TypeError, ValueError) as e: # pragma: no cover
return _Raised(e)
return [q.name for q in ps
if q.name != "self" and q.default is not inspect.Parameter.empty]
_ctors = {"PriceRead.no_data": PriceRead.no_data,
"PriceRead.series": PriceRead.series,
"_NoData.__init__": _price._NoData.__init__,
"_Series.__init__": _price._Series.__init__,
"PriceRead.match": PriceRead.match}
_with_defaults = derived(lambda: {n: d for n, d in
((n, _defaulted(f)) for n, f in _ctors.items())
if d})
check(f"no constructor or handler of an outcome type carries a DEFAULT, so a "
f"reading can never be built out of nothing {_with_defaults}",
_with_defaults == {})
check("...so no_data() with no reason at all is a TypeError at the call site",
raises(lambda: PriceRead.no_data(), TypeError))
check("...and the variant class itself cannot be built bare either",
raises(lambda: _price._NoData(), TypeError))
check("...nor can a series be built without its product or provenance",
raises(lambda: _price._Series([PricePoint(brussels(T0), 0.2, False)]),
TypeError))
# The positive arm, so the four above are not passing because everything raises.
check("...while a fully specified no_data is still perfectly constructible",
PriceRead.no_data("the feed timed out").reason == "the feed timed out")
# `match` must also refuse POSITIONAL handlers - keyword-only is what makes a
# forgotten arm a TypeError rather than a silently swapped one.
check("match() refuses positional handlers, so the two arms cannot be swapped",
raises(lambda: _none.match(lambda r: 1, lambda r: 2), TypeError))
check("an EMPTY series cannot be constructed at all, so 'no prices' can never "
"wear the shape of an answer",
raises(lambda: PriceRead.series([], PriceProduct.DAY_AHEAD, "x", T0),
ValueError))
check("a series built with a plugin NAME instead of a product enum is refused",
raises(lambda: PriceRead.series([PricePoint(brussels(T0), 0.2, False)],
"eneco_eddy", "x", T0), ValueError))
check("no_data with a blank reason is refused - the reason is its only field",
raises(lambda: PriceRead.no_data(""), ValueError))
def _forgot():
return _none.match(series=lambda r: r.points) # no_data omitted
check("a caller who forgets the no_data arm gets a TypeError at the call site, "
"not a fall-through", raises(_forgot, TypeError))
# .at() outside the series raises rather than returning a neighbour or None.
_s = READS["generic_dayahead"]
check("series.at() inside the series returns the covering point",
derived(lambda: _s.at(_s.points[0].start + timedelta(minutes=59))
is _s.points[0]) is True)
check("...and raises outside it rather than returning None to be `or`-ed away",
derived(lambda: (
raises(lambda: _s.at(_s.points[-1].start + timedelta(days=2)),
Absence)
and _s.covers(_s.points[0].start)
and not _s.covers(_s.points[-1].start + timedelta(days=2)))) is True)
# Every failure a site can have must come back as no_data, never as a traceback
# into the tick. Derived: drive each failure and collect the kinds.
async def _boom(url, timeout_s):
raise OSError("connection reset by peer")
_failures = {
"the source is unreachable": load("generic_dayahead", transport=_boom),
"the body is not JSON": load("generic_dayahead", transport=canned(b"<html>")),
"the feed is empty": load("generic_dayahead",
transport=canned(columnar_feed([]))),
# ⚠️ THE ROW SHAPE IS A FAILURE FOR THIS PLUGIN, NOT A SECOND DIALECT. If
# `_points_from_columnar_json()` ever grows a sniff for `data`, this case
# goes from no_data to a series and TARIFF-08's scope rule is broken by a
# check rather than by a review.
"the body is the OTHER shape": load(
"generic_dayahead",
transport=canned(row_shape_body("price_eur_mwh", EPEX_EUR_MWH))),
"the provider id is unknown": load("no_such_supplier"),
"the provider id is RETIRED": load("eneco_eddy"),
"no provider is configured": load("off"),
"the csv is missing": load("manual_csv",
{"price_csv_path": "/no/such/file.csv"},
transport=refuse_transport),
}
_not_nodata = sorted(k for k, r in _failures.items() if r.kind != "no_data")
check(f"every site-level failure returns no_data instead of raising "
f"({len(_failures)} cases)", _not_nodata == [])
_no_reason = derived(lambda: sorted(k for k, r in _failures.items()
if len(r.reason) < 10))
check(f"...each with a reason an operator can act on {_no_reason}",
_no_reason == [])
check("...and none of them is mistakable for a series",
derived(lambda: all(not arbitrage_allowed(r)
for r in _failures.values())) is True)
# ⚠️ The one thing that must NOT degrade to no_data: a plugin reaching for the
# markup. That is a code defect, and swallowing it would hide exactly what
# trap 1 exists to catch.
class _Thief(PriceProvider):
id = "_thief"
@classmethod
def capabilities(cls):
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY}))
async def fetch(self, o, since, until, transport):
return [RawPoint(T0, 0.1 + o.get("price_markup_eur_kwh"), False)]
PROVIDERS["_thief"] = _Thief
check("a plugin that reaches for the markup raises rather than degrading to "
"no_data - that is a bug, not a site condition",
raises(lambda: load("_thief"), FrameworkOption))
del PROVIDERS["_thief"]
# --------------------------------------------------------------------------- #
print()
print("7. manual_csv works fully offline (AC3)")
_had_aiohttp = "aiohttp" in sys.modules
_csv_read = load("manual_csv", {"price_csv_path": CSV_PATH},
transport=refuse_transport)
_csv_prices = derived(lambda: [p.eur_per_kwh for p in _csv_read.points])
check("the offline plugin produced a series without touching the transport - "
"the transport raises if called at all",
_csv_read.kind == "series" and _csv_prices == [0.1834, -0.021, 0.3115])
check(f"...it is all-in already, so the framework added nothing {_csv_prices}",
_csv_prices == [round(v, 6) for v in
[float(ln.split(",")[1]) for ln in
CSV_BODY.strip().splitlines()[1:]]])
check("...including the negative hour",
isinstance(_csv_prices, list) and any(v < 0 for v in _csv_prices))
check("...and the per-point provisional flag came from the file's third column",
derived(lambda: [p.provisional for p in _csv_read.points])
== [ln.split(",")[2] == "true"
for ln in CSV_BODY.strip().splitlines()[1:]])
check("...and no HTTP stack was imported to do it"
if not _had_aiohttp else
"...(aiohttp was already imported before this file ran; not a finding)",
_had_aiohttp or "aiohttp" not in sys.modules)
check("manual_csv declares no egress hosts at all",
PROVIDERS["manual_csv"].capabilities().egress_hosts == ())
_ManualCsv = PROVIDERS["manual_csv"]
check("a CSV with no header is refused rather than parsed as data",
raises(lambda: _ManualCsv.parse("2026-01-15T00:00:00+01:00,0.18\n"),
SourceUnavailable))
check("a CSV start with no offset is refused, naming the fix",
raises(lambda: _ManualCsv.parse("start,eur_per_kwh\n2026-01-15 00:00,0.18\n"),
SourceUnavailable))
check("...and the two-column form (no provisional) is accepted, defaulting to "
"settled",
[p.provisional for p in
_ManualCsv.parse("start,eur_per_kwh\n2026-01-15T00:00:00+01:00,0.18\n")]
== [False])
# --------------------------------------------------------------------------- #
print()
print("8. the two 2026 DST transitions, where a 24-slot array is wrong")
# ⚠️ RUN ON THE 23- AND 25-HOUR DAYS ON PURPOSE. On any normal day a fixed
# 24-slot implementation, a naive-local-hour key and a correct one all agree
# exactly, so a check on 15 January grades nothing about DST.
check("the 2026 transitions are where this repo expects them (last Sundays)",
_price._last_sunday(2026, 3) == 29 and _price._last_sunday(2026, 10) == 25)
def local_day(day_start_utc, n):
return [brussels(day_start_utc + timedelta(hours=i)) for i in range(n)]
# Spring forward: local day 2026-03-29 runs 2026-03-28T23:00Z .. 2026-03-29T22:00Z
_spring = local_day(datetime(2026, 3, 28, 23, tzinfo=UTC), 23)
_spring_end = brussels(datetime(2026, 3, 29, 22, tzinfo=UTC))
check(f"the spring day has 23 hours, ending exactly at the next midnight "
f"({_spring[0].isoformat()} .. {_spring_end.isoformat()})",
_spring[0].hour == 0 and _spring[0].day == 29
and _spring_end.hour == 0 and _spring_end.day == 30)
check(f"...crossing exactly one offset change "
f"{sorted(str(o) for o in {p.utcoffset() for p in _spring})}",
{p.utcoffset() for p in _spring} == {timedelta(hours=1),
timedelta(hours=2)})
check("...and 02:00 local never happens that day, which a 24-slot array would "
"have invented", 2 not in {p.hour for p in _spring})
# Fall back: local day 2026-10-25 runs 2026-10-24T22:00Z .. 2026-10-25T22:00Z
_fall = local_day(datetime(2026, 10, 24, 22, tzinfo=UTC), 25)
_fall_end = brussels(datetime(2026, 10, 25, 23, tzinfo=UTC))
check(f"the autumn day has 25 hours ({_fall[0].isoformat()} .. "
f"{_fall_end.isoformat()})",
_fall[0].hour == 0 and _fall[0].day == 25
and _fall_end.hour == 0 and _fall_end.day == 26)
_wall = [p.replace(tzinfo=None) for p in _fall]
_dups = len(_wall) - len(set(_wall))
check(f"...and exactly one local wall-clock hour occurs TWICE ({_dups}), which "
f"a naive-local-hour key would collide into one mispriced hour",
_dups == 1)
check("...distinguished by their offsets, which is why the canonical format "
"carries one",
len({p.isoformat() for p in _fall}) == 25)
# A normal day, stated as the control: without it, "23" and "25" prove nothing
# about whether the code counts at all.
_normal = local_day(datetime(2026, 1, 14, 23, tzinfo=UTC), 24)
check("...while an ordinary January day has 24 hours and one offset",
len({p.replace(tzinfo=None) for p in _normal}) == 24
and len({p.utcoffset() for p in _normal}) == 1)
check("brussels() refuses a naive datetime rather than assuming an offset",
raises(lambda: brussels(datetime(2026, 1, 15, 0, 0)), ValueError))
check("the offset boundary is 01:00 UTC, not local midnight",
brussels_offset(datetime(2026, 3, 29, 0, 59, tzinfo=UTC)) ==
timezone(timedelta(hours=1))
and brussels_offset(datetime(2026, 3, 29, 1, 0, tzinfo=UTC)) ==
timezone(timedelta(hours=2)))
# A feed that publishes the duplicated hour must survive it. The 25-hour day is
# 25 strictly increasing instants; a plugin keyed on local hours would emit 24
# and be rejected by the boundary validator instead of silently mispricing.
_dst_feed = columnar_feed([50.0 + i for i in range(25)],
start=datetime(2026, 10, 24, 22, tzinfo=UTC))
_dst_read = load("generic_dayahead", transport=canned(_dst_feed))
_dst_n = derived(lambda: len(_dst_read.points))
check(f"a 25-point autumn feed is accepted whole ({_dst_n} points)",
_dst_read.kind == "series" and _dst_n == 25)
check("...and its canonical starts are 25 distinct strings",
derived(lambda: len({p["start"]
for p in _dst_read.canonical()["prices"]})) == 25)
# --------------------------------------------------------------------------- #
print()
print("9. the boundary rejects what cannot be a price, and only that")
# M5. Deleting the `Component.ENERGY not in sources` guard used to go green,
# and the consequence is not cosmetic: `adds` becomes {ENERGY, TAXES},
# `assemble()` never adds an energy component it thinks it is adding, and the
# emitted all-in price silently omits the energy it is supposed to be pricing.
check("capabilities() refuses a feed that declares no ENERGY component",
raises(lambda: ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.MARKUP})), ValueError))
check("...including a completely empty sources set",
raises(lambda: ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset()), ValueError))
check("...and an unknown component is refused rather than ignored",
raises(lambda: ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY, "grid_fees"})), ValueError))
check("...and a product given as a string is refused at the declaration too",
raises(lambda: ProviderCapabilities(product="dynamic_dayahead",
sources=frozenset({Component.ENERGY})),
ValueError))
# THE CONSEQUENCE, derived across every shipped plugin rather than asserted on
# one: the framework must never be in a position to "add" the energy component,
# because there is nothing to add it from.
_adds_energy = derived(lambda: sorted(
pid for pid, cls in PROVIDERS.items()
if Component.ENERGY in cls.capabilities().adds))
check(f"so no shipped plugin ever asks the framework to invent an energy "
f"price {_adds_energy}", _adds_energy == [])
_caps = PROVIDERS["generic_dayahead"].capabilities()
def boundary(points):
return raises(lambda: _price._validate_raw(points, _caps), SourceUnavailable)
check("NaN is refused", boundary([RawPoint(T0, float("nan"), False)]))
check("infinity is refused", boundary([RawPoint(T0, float("inf"), False)]))
check(f"a EUR/MWh figure read as EUR/kWh is refused (>{MAX_PLAUSIBLE_EUR_KWH})",
boundary([RawPoint(T0, 61.4, False)]))
check("a naive start is refused",
boundary([RawPoint(datetime(2026, 1, 15), 0.1, False)]))
check("duplicate starts are refused - the DST collision, caught",
boundary([RawPoint(T0, 0.1, False), RawPoint(T0, 0.2, False)]))
check("out-of-order starts are refused",
boundary([RawPoint(T0 + timedelta(hours=1), 0.1, False),
RawPoint(T0, 0.2, False)]))
check("a plugin returning something that is not a RawPoint is refused",
boundary([PricePoint(brussels(T0), 0.1, False)]))
check("an empty list is refused", boundary([]))
# ...and the negative-price control, so the rules above are not just "reject
# everything". A validator that refused this would cost money on a windy Sunday.
check("a NEGATIVE price at a plausible magnitude is accepted",
not boundary([RawPoint(T0, -0.35, False)]))
check("...and so is a large-but-plausible one at the limit",
not boundary([RawPoint(T0, MAX_PLAUSIBLE_EUR_KWH - 0.01, False)]))
# --------------------------------------------------------------------------- #
print()
print("10. egress is declared by the plugin and enforced (§16)")
_g = PROVIDERS["generic_dayahead"].capabilities()
check("a declared https host is allowed",
check_egress("https://api.energy-charts.info/price?bzn=BE", _g)
== "api.energy-charts.info")
check("an undeclared host is refused even over TLS",
raises(lambda: check_egress("https://evil.example/p", _g),
SourceUnavailable))
# M7, AND THE ASSERTION IS THE FIX, NOT ONLY THE PARSER. Deleting the TLS rule
# used to leave this check GREEN: the old parser stripped a fixed
# len("https://") from the front, so `http://api.energy-charts.info/p` became
# the host `pi.energy-charts.info`, which failed the ALLOW-LIST instead. The
# feed was refused, the check named "plain http is refused" passed, and the
# rule it was named for could be deleted with no consequence - an assertion
# that does not test what it names, inside a security check.
#
# Three checks now, and the first two are what make the third able to fail:
# prove the host is declared over https, prove it parses identically over
# http, and only then assert that http is refused AND that the reason is TLS.
_tls_url = "http://api.energy-charts.info/p"
check("the same host over https is allowed, so it IS on the allow-list",
derived(lambda: check_egress("https://api.energy-charts.info/p", _g))
== "api.energy-charts.info")
check("...and the http form parses to that same declared host, so nothing but "
"the TLS rule can refuse it",
derived(lambda: _tls_url.partition("://")[2].split("/", 1)[0])
== "api.energy-charts.info")
_tls_reason = derived(lambda: _reason(lambda: check_egress(_tls_url, _g)))
check(f"plain http is refused, and refused BY THE TLS RULE - {_tls_reason!r}",
isinstance(_tls_reason, str) and "non-TLS" in _tls_reason)
check("a scheme that is neither is refused the same way, by TLS not by host",
"non-TLS" in str(derived(lambda: _reason(
lambda: check_egress("ftp://api.energy-charts.info/p", _g)))))
# ⚠️ M7b, WHICH I FOUND IN MY OWN FIX FOR M7 AND WHICH IS THE SAME DEFECT ONE
# LAYER DOWN. This check used to read `raises(..., SourceUnavailable)` and
# survived deleting the `if not sep` guard: with no guard,
# "api.energy-charts.info/p".partition("://") leaves scheme = the WHOLE string,
# so the TLS rule refuses it and the check named for the scheme guard passes.
# Refused either way - but the diagnostic an operator gets degrades from "not an
# absolute URL" to "uses 'api.energy-charts.info/p'" as a scheme, which is
# gibberish. Assert the reason, like M7 above.
_noscheme = derived(lambda: _reason(
lambda: check_egress("api.energy-charts.info/p", _g)))
check(f"a URL with no scheme is refused BY THE SCHEME GUARD, with a diagnostic "
f"that names the real problem - {_noscheme!r}",
isinstance(_noscheme, str) and "not an absolute URL" in _noscheme)
check("a userinfo prefix cannot smuggle a declared host past the check",
raises(lambda: check_egress(
"https://api.energy-charts.info@evil.example/p", _g),
SourceUnavailable))
check("an operator-supplied URL to another supplier's host is refused",
load("generic_dayahead",
{"price_feed_url": "https://api.eneco.be/eddy/prices"},
transport=canned(columnar_feed(EPEX_EUR_MWH))
).kind == "no_data")
_declared = {pid: cls.capabilities().egress_hosts
for pid, cls in PROVIDERS.items()}
check(f"every networked plugin declares at least one host, and manual_csv "
f"declares none {_declared}",
all(bool(h) for p, h in _declared.items() if p != "manual_csv")
and _declared["manual_csv"] == ())
check("the timeout the plugin passes to the transport is the configured one, "
"not a hardcoded default",
(lambda t: (load("generic_dayahead", {"price_timeout_s": 7},
transport=t), t.calls[0][1] == 7.0)[1])(
canned(columnar_feed(EPEX_EUR_MWH))))
# --------------------------------------------------------------------------- #
print()
print("11. injection price is a separate scalar (AC4, §9.3)")
check(f"the shipped default is config.yaml's "
f"{PRICE_OPTION_DEFAULTS['export_price_eur_kwh']}",
export_price_eur_kwh({}) == PRICE_OPTION_DEFAULTS["export_price_eur_kwh"])
check("an operator's value wins over it",
export_price_eur_kwh({"export_price_eur_kwh": 0.0}) == 0.0)
check("...including a negative one - Belgian injection has gone below zero",
export_price_eur_kwh({"export_price_eur_kwh": -0.02}) == -0.02)
check("it is NOT folded into the consumption series any plugin emits",
derived(lambda: all(export_price_eur_kwh({}) not in
[p.eur_per_kwh for p in r.points]
for r in READS.values())) is True)
check("...and no plugin can reach it to fold it in",
raises(lambda: PluginOptions({}).get("export_price_eur_kwh"),
FrameworkOption))
# --------------------------------------------------------------------------- #
print()
print("12. the startup gate (validate.py rule 7)")
def vbase(**over):
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off",
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
o.update(over)
return o
def why(problems, needle):
return any(needle in p for p in problems)
check("the shipped price defaults start (price_provider is 'off')",
validate_startup_config(vbase(**{k: v for k, v in
PRICE_OPTION_DEFAULTS.items()}),
-5000, 5000) == [])
check("...and so does a configured provider on its own",
validate_startup_config(vbase(price_provider="generic_dayahead"),
-5000, 5000) == [])
check("an unknown provider id is refused rather than defaulted to 'off'",
why(validate_startup_config(vbase(price_provider="nordpool"), -5000, 5000),
"not a shipped plugin"))
# ⚠️ TARIFF-07: A RETIRED ID IS REFUSED BY A DIFFERENT MESSAGE, AND EVERY CHECK
# HERE GRADES THE REASON RATHER THAN THE VERDICT. The failure this section
# exists to catch is NOT "a retired id starts" - it never will, because it is
# absent from PROVIDERS and the unknown-id branch would catch it. It is the
# retired branch COLLAPSING INTO the unknown-id one: same refusal, same exit
# code, and an operator told they mistyped a name they chose from a dropdown
# this add-on offered them. The two messages are asserted to DIFFER and each
# needle is asserted to be absent from the other, which is MUTATION.md's
# "grade the reason, not the verdict" applied to two rungs that answer one
# stimulus.
_unknown = validate_startup_config(vbase(price_provider="nordpool"),
-5000, 5000)
_retired = {pid: validate_startup_config(vbase(price_provider=pid),
-5000, 5000)
for pid in sorted(RETIRED_PROVIDERS)}
check(f"every retired id is refused at startup {sorted(_retired)}",
len(_retired) == 3 and all(len(v) == 1 for v in _retired.values()))
check("...and NOT by the unknown-id message, which would tell an operator they "
"mistyped an id this add-on offered them and accepted for months",
all("not a shipped plugin" not in v[0] for v in _retired.values())
and "WITHDRAWN" not in _unknown[0])
check("...and each message NAMES ITS OWN ID, so the operator can tell it is "
"about their configuration and not a generic notice",
all(repr(pid) in v[0] for pid, v in _retired.items()))
check("...and (a) says the plugin never fetched a price, naming the endpoint "
"that does not exist",
all("NEVER FETCHED A PRICE" in v[0] for v in _retired.values())
and "api.engie.be/dynamic/be" in _retired["engie_dynamic"][0]
and "api.totalenergies.be/prices/dynamic"
in _retired["totalenergies_dynamic"][0]
and "api.eneco.be/eddy/prices" in _retired["eneco_eddy"][0])
check("...and (b) says what the site has ACTUALLY been running instead - the "
"half §11.4 made invisible",
all("SILENT" in v[0] and "static_profile" in v[0]
for v in _retired.values()))
check("...and (c) says what to do, naming a live plugin and refusing to "
"pretend an alias could exist",
all("generic_dayahead" in v[0] and "manual_csv" in v[0]
and "NO alias" in v[0] for v in _retired.values()))
# ⚠️ AND THE SENTENCE CHANGES WITH THE SITE. "You have been on the static
# profile" is FALSE where no profile was ever set, and that site is in a WORSE
# state - no tariff at all - so the message must not flatten the two. Graded by
# asserting the two renderings DIFFER, because a check that only looked for
# "static_profile" would pass on a message that says the same thing to both.
_PROF = ('{"periods": [{"from":"00:00","to":"24:00","days":"all",'
'"eur_per_kwh":0.31}]}')
_with_profile = validate_startup_config(
vbase(price_provider="eneco_eddy", static_profile=_PROF), -5000, 5000)[0]
_without = _retired["eneco_eddy"][0]
check("a retired id on a site WITH a static profile is told it has been "
"running that profile",
"the static day/night tariff in static_profile" in _with_profile)
check("...and a site WITHOUT one is told it has been running nothing at all, "
"which is a different and worse state",
"NOTHING AT ALL" in _without
and "the static day/night tariff in static_profile" not in _without)
check("...so the two renderings are not the same sentence",
_with_profile != _without)
# ⚠️ THE HA OVERLAP, DECIDED. The two entities keep working alone; combining
# them with a provider is what is refused, because the entities win silently.
_p = validate_startup_config(
vbase(price_provider="generic_dayahead", price_now_entity="sensor.now",
price_avg_entity="sensor.avg"), -5000, 5000)
check("a provider AND the Home Assistant price entities is refused - two price "
"sources on one site", len(_p) == 1)
check("...and the message says which one actually wins",
why(_p, "HOME ASSISTANT ENTITIES WIN"))
# TARIFF-03 added a second source with the same silent-loser shape.
_ps = validate_startup_config(
vbase(static_profile='{"periods": [{"from":"00:00","to":"24:00",'
'"days":"all","eur_per_kwh":0.31}]}',
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
-5000, 5000)
check("a static_profile AND the Home Assistant price entities is refused too - "
"main.py reads one of them for `cheap` and the loser is silent",
len(_ps) == 1 and why(_ps, "HOME ASSISTANT"))
check("the Home Assistant entities alone still start, so nothing is taken away "
"from a live install",
validate_startup_config(vbase(price_now_entity="sensor.now",
price_avg_entity="sensor.avg"),
-5000, 5000) == [])
# ⚠️ THIS TRIPWIRE FIRED, AND THIS IS WHAT IT LOOKS LIKE AFTERWARDS.
#
# TARIFF-02 left a check here asserting that NOTHING in app/ consumed
# `load_prices()`, because rule 7's stated reason - "the supplier you configured
# would decide nothing" - was true only for as long as that held. The
# instruction attached to it was: when this goes red, update the message in
# validate.py in the same commit, do not delete the check.
#
# TARIFF-03 landed `app/static_tariff.py`, whose `resolve_tariff()` consumes
# `load_prices()` and turns a series into a tariff plan, a cheap-window flag and
# an arbitrage verdict. The check went red, naming that file, and validate.py's
# message was rewritten in the same commit to say what is true now instead. So
# the tripwire is KEPT and RE-AIMED rather than removed, at the next thing that
# can go quietly wrong:
#
# * the consumer set is PINNED. A second consumer appearing - OPT-04 wiring a
# strategy in, most likely - goes red again, and whoever does that has to
# re-read rule 7's message against what their change makes true.
# * the EXPIRED PHRASE is asserted GONE. If the old wording is ever restored
# by a bad merge, this says so.
# * and the message has to keep naming its consumer, so it cannot decay into
# a refusal with no stated reason at all.
_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app")
# ⚠️ AST, NOT A SUBSTRING, AND THE FIRST DRAFT PROVED WHY. A text scan for
# "load_prices" matched the COMMENT in validate.py that explains this very
# tripwire, so the check went red against its own documentation - the same
# false-positive class as the plugin docstrings in section 4b, hit twice in one
# ticket. Comments and docstrings are not in an AST, so a name/import walk sees
# code and only code.
def _imports_or_calls(path, name):
try:
tree = ast.parse(open(path, encoding="utf-8").read())
except (OSError, SyntaxError):
return False
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and any(a.name == name
for a in node.names):
return True
if isinstance(node, ast.Name) and node.id == name:
return True
if isinstance(node, ast.Attribute) and node.attr == name:
return True
return False
_consumers = derived(lambda: sorted(
f for f in os.listdir(_APP)
if f.endswith(".py") and f != "price.py"
and _imports_or_calls(os.path.join(_APP, f), "load_prices")))
_EXPECTED_CONSUMERS = ["static_tariff.py"]
check(f"load_prices() is consumed by exactly the modules rule 7's message "
f"names {_consumers}. When a SECOND one appears this goes red, and the "
f"fix is to re-read that message against what the new consumer makes "
f"true - not to widen this list without doing so",
_consumers == _EXPECTED_CONSUMERS)
_RULE7 = derived(lambda: open(os.path.join(_APP, "validate.py"),
encoding="utf-8").read())
check("...and the phrase the tripwire expired - 'would decide nothing' about "
"the provider - is GONE from validate.py, so a bad merge that restores "
"the old reason is red rather than quiet",
isinstance(_RULE7, str)
and "supplier you configured would decide nothing" not in _RULE7)
check("...and the refusal still names the consumer that exists, so it cannot "
"decay into a rule with no stated reason",
isinstance(_RULE7, str) and "static_tariff.py" in _RULE7)
# ...and the other half: the registry IS on the startup path, which is what
# makes an unwired seam anchored rather than dead code.
check("...while validate.py does import the registry, so the seam is exercised "
"on every boot",
derived(lambda: "PROVIDERS" in open(
os.path.join(_APP, "validate.py"), encoding="utf-8").read()) is True)
check("manual_csv with no path is refused - the offline fallback with no file "
"is §11.4 reached by accident",
why(validate_startup_config(vbase(price_provider="manual_csv",
price_csv_path=""), -5000, 5000),
"price_csv_path"))
check("...and with a path it starts",
validate_startup_config(vbase(price_provider="manual_csv",
price_csv_path=CSV_PATH),
-5000, 5000) == [])
# ⚠️ price_provider 'off' must NEVER become a refusal: §9.2 puts a large share
# of the installed base on a fixed or static tariff, and §11.4 makes no-price
# data a supported state.
check("'off' is not refused, on any of the other configurations",
all(validate_startup_config(vbase(price_provider="off", **extra),
-5000, 5000) == []
for extra in ({}, {"price_now_entity": "sensor.now",
"price_avg_entity": "sensor.avg"},
{"price_csv_path": ""})))
# --------------------------------------------------------------------------- #
print()
print("13. TARIFF-10 - a configured provider is FETCHED, and trading is still "
"its own switch")
# WHAT SHIPPED, AND WHY THIS SECTION IS NOT A CHECK ON AN `if`. `amain()` read
#
# if arbitrage.enabled(opts):
# tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
#
# `run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
# only caller of `price_history.carry()`, and `arbitrage_enabled` ships FALSE.
# So on a shipped install with a provider configured NOTHING was fetched, no
# plan was resolved, /data/price_history.json was never created, the *Price
# history* row read `empty` for the life of the install, and TARIFF-09's
# short-horizon warning could not fire because no fetch happened to warn about.
# Measured over a 40 h two-instance soak: zero feed requests and
# `present: false` across all 802 samples.
#
# ⚠️ A CHECK THAT PINS THE `if` WOULD NOT HAVE CAUGHT IT, because the `if` was
# EXACTLY WHAT SOMEBODY MEANT TO WRITE. What was wrong is the SET OF
# CONFIGURATIONS FROM WHICH A FETCH CAN BE REACHED, so that is what is graded:
# the guards in `amain()` are EVALUATED against real option dicts (whatever they
# are spelled as), the call graph under `app/` is walked to see whether the
# tasks those guards start can arrive at `resolve_tariff` (wherever it has been
# moved to), and then the fetch is DRIVEN and the requests counted, because a
# reachability graph is an argument and a counted request is an observation.
# -- the predicate the gate is built on, before anything is wired to it -------
# ⚠️ ONE SPELLING, AND IT NOW HAS THREE READERS. `str(price_option(opts,
# "price_provider") or "off").strip()` was written out TWICE - in
# `load_prices()` and in `static_tariff.resolve_tariff()` - and main.py's
# startup gate would have been a third. "Is a provider configured" is now a
# DECISION rather than a detail, because it is what starts a background task,
# so the three readers have to agree about the edge cases and `provider_id()`
# is the one place they can. (`validate.py` keeps its own reader on purpose: it
# validates the RAW options blob and must not route through the defaults.)
check("`provider_configured()` is False on the shipped default, on an explicit "
"'off', and on a key that is absent, empty or None - `price_option()` "
"maps the last three onto the default and none of them may read as a "
"configured feed",
not any(_price.provider_configured(o) for o in
({}, {"price_provider": "off"}, {"price_provider": ""},
{"price_provider": None})))
check("...and True on each shipped plugin id, so it is a predicate and not a "
f"constant {sorted(PROVIDERS)}",
all(_price.provider_configured({"price_provider": pid})
for pid in PROVIDERS))
# ⚠️ WHITESPACE IS THE EDGE THE `.strip()` IS FOR, AND NOTHING GRADED IT. The
# Supervisor's `list(off|...)` schema cannot produce `" off "`, but
# `validate_startup_config()` is also sim/inject.py's entry point and takes any
# dict at all - and dropping the `.strip()` left all four price suites green
# (measured, -8 B) until this pair was written.
check("...and a padded id is normalised on both sides of the decision: "
"' off ' is still off, and ' generic_dayahead ' is still that plugin",
_price.provider_id({"price_provider": " off "}) == "off"
and not _price.provider_configured({"price_provider": " off "})
and _price.provider_id({"price_provider": " generic_dayahead "})
== "generic_dayahead"
and _price.provider_configured(
{"price_provider": " generic_dayahead "}))
check("...and `load_prices()` answers `no_data` for exactly the dicts "
"`provider_configured()` calls unconfigured, so the startup gate and the "
"fetch cannot disagree about what 'a provider is set' means",
all((run(load_prices(o, datetime(2026, 1, 1, tzinfo=UTC),
datetime(2026, 1, 2, tzinfo=UTC),
transport=None)).kind == "no_data")
== (not _price.provider_configured(o))
for o in ({}, {"price_provider": "off"},
{"price_provider": " off "},
{"price_provider": "manual_csv",
"price_csv_path": CSV_PATH})))
def _coroutine_name(node):
"""`controller.run_tariff_refresh()` -> `run_tariff_refresh`."""
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Attribute):
return node.func.attr
if isinstance(node.func, ast.Name):
return node.func.id
return ast.unparse(node)
def _task_sites(node, guards=()):
"""Every `create_task(...)` under `node`, with the `if`s that guard it.
The guard is kept as SOURCE and evaluated later against an options dict, so
this reports what the tree says rather than what any one ticket expected to
find there. An `else` branch is recorded as `not (<test>)`.
"""
out = []
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.If):
test = ast.unparse(child.test)
for stmt in child.body:
out += _task_sites(stmt, guards + (test,))
for stmt in child.orelse:
out += _task_sites(stmt, guards + (f"not ({test})",))
continue
if isinstance(child, ast.Call) and \
ast.unparse(child.func).endswith("create_task"):
if child.args:
out.append((guards, _coroutine_name(child.args[0])))
continue
out += _task_sites(child, guards)
return out
def _call_graph(app_dir):
"""name -> the names it calls, over every module in `app/`.
⚠️ IT IS KEYED BY NAME AND THEREFORE OVER-APPROXIMATES, AND THAT IS THE
SAFE DIRECTION HERE. Two functions called `run` in two modules are one node;
a method call on an unrelated object joins the same node. So a `reachable`
answer is an upper bound and a `NOT reachable` answer is the strong one -
which is why every positive row below is also DRIVEN, with the requests
counted, rather than being left to this graph (MUTATION.md habit 13).
"""
graph = {}
for name in sorted(os.listdir(app_dir)):
if not name.endswith(".py"):
continue
tree = ast.parse(open(os.path.join(app_dir, name),
encoding="utf-8").read())
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
called = set()
for inner in ast.walk(node):
if isinstance(inner, ast.Call):
fn = inner.func
if isinstance(fn, ast.Attribute):
called.add(fn.attr)
elif isinstance(fn, ast.Name):
called.add(fn.id)
graph.setdefault(node.name, set()).update(called)
return graph
def _reaches(graph, start, target):
"""Can `start` arrive at `target` through the call graph?"""
seen, stack = set(), [start]
while stack:
cur = stack.pop()
if cur in seen:
continue
seen.add(cur)
for nxt in graph.get(cur, ()):
if nxt == target:
return True
stack.append(nxt)
return False
def _started(sites, opts):
"""[(coroutine, guards, verdict)] for `opts`. Verdict True/False/'?'.
`'?'` is a guard this cannot decide from the options alone - `p1_source is
not None` is one - and it is reported rather than assumed either way. A
fetch-reaching task whose guard is undecidable would make every row below
vacuous, so that is a check of its own.
"""
out = []
for guards, name in sites:
verdict = True
for guard in guards:
try:
answer = bool(eval(guard, vars(_main), {"opts": opts}))
except NameError:
verdict = "?"
break
if not answer:
verdict = False
break
out.append((name, guards, verdict))
return out
_MAIN_TREE = derived(lambda: ast.parse(open(os.path.join(_APP, "main.py"),
encoding="utf-8").read()))
_AMAIN = derived(lambda: next(n for n in ast.walk(_MAIN_TREE)
if isinstance(n, ast.AsyncFunctionDef)
and n.name == "amain"))
_SITES = derived(lambda: _task_sites(_AMAIN))
_GRAPH = derived(lambda: _call_graph(_APP))
_FETCH_ENTRY = "resolve_tariff"
print(f" create_task sites found in amain(): "
f"{[(n, list(g)) for g, n in _SITES]}")
print(f" call graph: {len(_GRAPH)} named functions across app/")
print(" ⚠️ what this instrument CANNOT see, largest hole first: a coroutine "
"started by anything other than `create_task` (gather, ensure_future, a "
"thread, a timer); a guard that is not an enclosing `if` in amain() (a "
"while, a try/except, an early return above the site, or a refusal "
"INSIDE the coroutine); any module outside app/; and - because the graph "
"is keyed by NAME - two same-named functions as one node, which makes "
"every `reachable` answer an UPPER bound.")
# -- the instrument is calibrated before anything is graded with it (habit 1) --
check(f"the scanner finds amain()'s create_task sites at all - it found "
f"{len(_SITES) if not isinstance(_SITES, _Raised) else _SITES}, and at "
f"zero every row below would be vacuously true",
not isinstance(_SITES, _Raised) and len(_SITES) >= 2)
check(f"...and the call graph can tell reaching from not-reaching: "
f"`run_tariff_refresh` arrives at {_FETCH_ENTRY!r} and `run_control` - "
f"the control tick, which must never await a fetch - does not. A graph "
f"that answered the same for both would pass every row below however the "
f"gate was wired",
_reaches(_GRAPH, "run_tariff_refresh", _FETCH_ENTRY)
and not _reaches(_GRAPH, "run_control", _FETCH_ENTRY))
def _fetchers(opts):
"""The coroutines amain() STARTS, for `opts`, that can reach the feed."""
return sorted(n for n, _, v in _started(_SITES, opts)
if v is True and _reaches(_GRAPH, n, _FETCH_ENTRY))
def _undecidable_fetchers(opts):
return sorted(n for n, _, v in _started(_SITES, opts)
if v == "?" and _reaches(_GRAPH, n, _FETCH_ENTRY))
# The four configurations. `_T10_BASE` carries no price keys at all, so the
# shipped defaults in PRICE_OPTION_DEFAULTS are what answer - which is the
# install the defect was measured on.
_T10_BASE = {"setpoint_entity": "number.sp", "max_w": 5000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000}
_T10_PROFILE = json.dumps({"periods": [
{"name": "night", "days": "all", "from": "22:00", "to": "07:00",
"eur_per_kwh": 0.20},
{"name": "day", "days": "all", "from": "07:00", "to": "22:00",
"eur_per_kwh": 0.284}]})
_T10_CASES = (
("shipped default: no provider, no trading", {}, False),
("PROVIDER configured, trading OFF (the defect)",
{"price_provider": "generic_dayahead"}, True),
("PROVIDER configured, trading ON",
{"price_provider": "generic_dayahead", "arbitrage_enabled": True}, True),
("no provider, trading ON on a static profile",
{"arbitrage_enabled": True, "static_profile": _T10_PROFILE}, True),
)
print(" startup reachability of resolve_tariff(), by configuration:")
for _label, _over, _want in _T10_CASES:
print(f" {'reachable' if _fetchers(dict(_T10_BASE, **_over)) else '- '}"
f" {_label}")
check(f"the shipped defaults really do leave `arbitrage_enabled` off "
f"({PRICE_OPTION_DEFAULTS['arbitrage_enabled']!r}) and `price_provider` "
f"off ({PRICE_OPTION_DEFAULTS['price_provider']!r}) - the precondition "
f"every row below varies from, and if a later card changes either "
f"default these rows stop meaning what they say. Section 1 is what pins "
f"this dict to config.yaml",
PRICE_OPTION_DEFAULTS["price_provider"] == "off"
and PRICE_OPTION_DEFAULTS["arbitrage_enabled"] is False
and not _arb.enabled(dict(_T10_BASE))
and not _price.provider_configured(dict(_T10_BASE)))
check(f"AC1: with a `price_provider` set and `arbitrage_enabled` at its shipped "
f"default, the startup path REACHES the feed "
f"{_fetchers(dict(_T10_BASE, price_provider='generic_dayahead'))}. This "
f"is the shipped defect: it read `[]`, so nothing was ever fetched",
_fetchers(dict(_T10_BASE, price_provider="generic_dayahead")) != [])
check(f"AC2: with `price_provider: off` and trading off - the true shipped "
f"default - NO task reaches the feed "
f"{_fetchers(dict(_T10_BASE))}, so an install that asked for no prices "
f"acquires no periodic work from this card",
_fetchers(dict(_T10_BASE)) == [])
check("...and the guard on the fetch-reaching task is decidable from the "
"OPTIONS alone. A gate moved onto a local variable would make every row "
"in this section vacuous rather than red",
_undecidable_fetchers(dict(_T10_BASE, price_provider="generic_dayahead"))
== [])
# ⚠️ THE `or` IS LOAD-BEARING AND THIS IS THE ROW THAT SAYS SO. `price_provider:
# off` + a `static_profile` + `arbitrage_enabled: true` is a configuration
# validate.py ACCEPTS and that trades (measured: ARBITRAGE_CHARGE -5000 W at
# `cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile). Gating
# the refresh on the provider ALONE would leave that site's `tariff_plan` at
# None forever and silently disarm a strategy the UI says is running.
_T10_OLD = {lbl: _arb.enabled(dict(_T10_BASE, **over))
for lbl, over, _ in _T10_CASES}
_T10_NEW = {lbl: bool(_fetchers(dict(_T10_BASE, **over)))
for lbl, over, _ in _T10_CASES}
print(f" old gate (arbitrage.enabled alone) -> {list(_T10_OLD.values())}")
print(f" shipped gate -> {list(_T10_NEW.values())}")
check(f"the gate is a STRICT WIDENING: every configuration that started the "
f"refresh before still starts it, so nothing was taken away from a live "
f"install {[l for l in _T10_OLD if _T10_OLD[l] and not _T10_NEW[l]]}",
all(_T10_NEW[l] for l in _T10_OLD if _T10_OLD[l]))
check("...and it is a widening rather than a no-op: at least one configuration "
"fetches now that did not before, which is the whole ticket",
any(_T10_NEW[l] and not _T10_OLD[l] for l in _T10_OLD))
# -- and the fetch is DRIVEN, because a graph is an argument and a counted ----
# -- request is an observation (habit 4) -------------------------------------
_CAPTURE = derived(lambda: json.load(open(
os.path.join(HERE, "scenarios",
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json"),
encoding="utf-8")))
def _restamped_body(now):
"""The COMMITTED capture, with only its instants moved onto `now`.
⚠️ THE SHAPE AND THE PRICES ARE THE CAPTURE'S (TARIFF-08, habit 19). What
is synthetic is the CLOCK and nothing else: `run_tariff_refresh` reads the
wall clock and there is no seam to inject one, so a 2026-09-09 capture
would fall outside every horizon it asks for. Section 2b replays the
capture unmodified against the parser; this one is grading the WIRING.
"""
base = int(now.timestamp()) // 3600 * 3600
doc = dict(_CAPTURE)
doc["unix_seconds"] = [base + 900 * i
for i in range(len(_CAPTURE["price"]))]
return json.dumps(doc).encode()
class _T10Hass:
async def limits(self, entity):
return (-5000.0, 5000.0, 1.0)
async def number(self, entity, invert=False):
return {"sensor.grid": 0.0, "sensor.soc": 55.0,
"sensor.batt": 0.0}.get(entity)
async def set_number(self, entity, value):
return True
async def call(self, domain, service, data):
return True
class _T10Store:
data = {}
def set(self, *a):
pass
def get_time(self, key):
return None
class _T10Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
def _drive_startup(over):
"""Start exactly what amain() would start for `opts`, and watch the wire.
⚠️ THE TASK IS STARTED ONLY IF THE SHIPPED GUARD SAYS SO. That is the whole
point: the coroutine is not called because this file knows its name, it is
called because `_fetchers()` evaluated amain()'s own guard against these
options. A gate that goes back to `arbitrage.enabled(opts)` leaves the
coroutine uncalled and the request count at zero.
⚠️ AND IT RUNS UNDER A DEADLINE (habit 5). The refresh loop is infinite by
design; the failure mode of a mutation that never resolves a plan is a HANG,
which reads like flaky infrastructure rather than like a named check.
"""
opts = dict(_T10_BASE, **over)
tmp = tempfile.mkdtemp(prefix="tariff10-")
_tmpdirs_t10.append(tmp)
store_path = os.path.join(tmp, "price_history.json")
seen = []
async def _counting_get(url, timeout_s):
seen.append(url)
return _restamped_body(datetime.now(timezone.utc))
real_get = _price.https_get
_price.https_get = _counting_get
try:
async def _go():
stop = asyncio.Event()
ctl = _main.Controller(opts, _T10Hass(), _T10Store(), _T10Pub(),
stop=stop,
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=store_path)
names = _fetchers(opts)
if "run_tariff_refresh" not in names:
# Nothing amain() starts reaches the feed. Anything ELSE that
# does is a task this harness cannot drive, and it is a FAILURE
# rather than a shrug - see the check below.
return ctl, names
task = asyncio.create_task(ctl.run_tariff_refresh())
for _ in range(250): # ~5 s ceiling
if ctl.tariff_at is not None:
break
await asyncio.sleep(0.02)
ctl.stopping = True
stop.set()
await asyncio.wait_for(task, timeout=5)
return ctl, names
ctl, names = asyncio.run(_go())
finally:
_price.https_get = real_get
try:
n_points = len(list(ctl.tariff_plan.read.points))
except Exception: # noqa: BLE001 - Absence
n_points = 0
return {"requests": seen, "started": names,
"store_present": os.path.exists(store_path),
"points": n_points,
"history": ctl.price_history.words}
_tmpdirs_t10 = []
_on = derived(lambda: _drive_startup({"price_provider": "generic_dayahead"}))
_off = derived(lambda: _drive_startup({}))
_static_armed = derived(lambda: _drive_startup(
{"arbitrage_enabled": True, "static_profile": _T10_PROFILE}))
print(f" provider + trading OFF -> {len(_on['requests'])} request(s), "
f"store written {_on['store_present']}, {_on['points']} point plan, "
f"Price history row: {_on['history'][:70]!r}")
print(f" no provider -> {len(_off['requests'])} request(s), "
f"store written {_off['store_present']}, "
f"Price history row: {_off['history'][:40]!r}")
check(f"AC1, DRIVEN: a shipped-default install with a provider configured "
f"actually ASKS THE FEED - {len(_on['requests'])} request(s), "
f"{_on['requests'][:1]}. The soak measured ZERO here over 40 hours",
len(_on["requests"]) >= 1)
check(f"...and a plan RESOLVES from it: {_on['points']} points, against the "
f"{len(_CAPTURE['price'])} the capture publishes",
_on["points"] == len(_CAPTURE["price"]))
check(f"...and /data/price_history.json is WRITTEN - present={_on['store_present']}. "
f"The soak measured `present: false` on all 802 samples",
_on["store_present"] is True)
check(f"...and the *Price history* status row POPULATES rather than reading "
f"`empty` forever: {_on['history'][:60]!r}",
isinstance(_on["history"], str)
and not _on["history"].startswith("empty")
and not _on["history"].startswith("unavailable"))
check(f"AC2, DRIVEN: `price_provider: off` issues NO request and creates no "
f"store - {len(_off['requests'])} request(s), "
f"present={_off['store_present']}",
_off["requests"] == [] and _off["store_present"] is False)
check(f"...and neither does the one configuration that keeps its task without "
f"a provider - trading armed on a static profile makes "
f"{len(_static_armed['requests'])} request(s), because "
f"`resolve_tariff()` answers a provider-off site out of the static "
f"profile without opening a socket. NOBODY acquires a periodic HTTPS "
f"call from this card",
_static_armed["requests"] == []
and "run_tariff_refresh" in _static_armed["started"])
check(f"...and no OTHER startup task can reach the feed - if one appears, this "
f"harness cannot drive it and every driven row above stops covering the "
f"startup path {_on['started']}",
_on["started"] == ["run_tariff_refresh"])
# -- AC3, and it is the property this card is most dangerous to: ARBITRAGE -----
# -- OFF STILL MEANS NO TRADE, EVER. Graded by DRIVING `Controller.cycle()` ---
# -- with a provider plan resolved - the state TARIFF-10 newly creates - and ---
# -- moving exactly one option (habit 7). ------------------------------------
#
# ⚠️ THE BASELINE IS THE HALF THAT MAKES THIS A CHECK. "No trade" is satisfied
# by a fixture in which nothing could have traded anyway - a flat series, a SoC
# outside the bounds, a peak guard that never ran - and a check like that stays
# green however the gate is wired. So the SAME plan, the SAME SoC, the SAME P1
# history and the SAME threshold are run with `arbitrage_enabled: true`, and
# that run must COMMAND THE BATTERY. If the armed row stops trading, the
# unarmed row stops being evidence and both go red together.
def _t10_sample(when, net_w):
"""One P1 telegram through the shipped derivation."""
imp, exp = split_signed(net_w)
return make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=when, ingest_mono=when.timestamp())
class _T10TradeHass(_T10Hass):
def __init__(self):
self.writes = []
async def set_number(self, entity, value):
self.writes.append(float(value))
return True
def _t10_plan(now, cheap_now):
"""A 24 h FIRM series whose cheapest hour is now, or whose dearest is.
Firm, because refusal 3 drops provisional hours from the trade set and a
plan of carried hours cannot trade whatever the gate says - which would
make the armed baseline below vacuous exactly as a flat series would.
"""
top = now.replace(minute=0, second=0, microsecond=0)
lo, hi = 0.05, 0.60
return TariffPlan(
PriceRead.series(
[PricePoint(start=top + timedelta(hours=i),
eur_per_kwh=(lo if (i < 3) == cheap_now else hi),
provisional=False)
for i in range(24)],
PriceProduct.DAY_AHEAD, "generic_dayahead", now),
None, "provider")
def _t10_trade_cycle(armed, cheap_now):
"""One real `Controller.cycle()` on a resolved provider plan."""
tmp = tempfile.mkdtemp(prefix="tariff10t-")
_tmpdirs_t10.append(tmp)
opts = dict(_T10_BASE, meter_source="ha_dsmr", meter_entity="sensor.grid",
heartbeat_s=10, price_provider="generic_dayahead",
cycle_cost_threshold_eur=0.10, arbitrage_enabled=armed)
async def _go():
hass = _T10TradeHass()
ctl = _main.Controller(opts, hass, _T10Store(), _T10Pub(),
stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
ctl.auto = True
ctl.dev_min, ctl.dev_max = -5000.0, 5000.0
now = datetime.now(timezone.utc)
# ⚠️ A WHOLE QUARTER OF TELEGRAMS, AND FIVE MINUTES IS NOT ENOUGH.
# Without any, `p1_enabled` is on, `inputs_ok` is False and cycle()
# returns before it reaches the strategy at all. With only five
# minutes, whether the quarter is COVERED depends on where the wall
# clock happens to sit inside it: run late in a quarter and the guard
# is blind, engages conservatively, and `decide()` refuses for §9.1's
# reason instead of trading - which would make the armed baseline
# below vacuous on some runs and not others. Measured: the same four
# rows came back `peak guard is holding` at one time of day and
# `ARBITRAGE_CHARGE` at another. 900 s at the shipped 5 s telegram
# cadence covers any quarter from any starting phase.
for k in range(181):
ctl.p1.submit(_t10_sample(now - timedelta(seconds=5 * (180 - k)),
300.0))
ctl.tariff_plan, ctl.tariff_at = _t10_plan(now, cheap_now), now
await ctl.read_inputs()
ctl.tariff_plan, ctl.tariff_at = _t10_plan(now, cheap_now), now
await ctl.cycle()
return ctl, hass
ctl, hass = asyncio.run(_go())
return {"arb": None if ctl.arb is None else ctl.arb.action,
"target": ctl.target, "reason": ctl.reason,
"writes": hass.writes,
"guard": None if ctl.peak_guard is None else ctl.peak_guard.engaged}
_T10_TRADE = derived(lambda: {(a, c): _t10_trade_cycle(a, c)
for a in (False, True) for c in (True, False)})
for _key in sorted(_T10_TRADE, key=str):
_r = _T10_TRADE[_key]
print(f" arbitrage_enabled={str(_key[0]):5s} cheapest hour is now="
f"{str(_key[1]):5s} -> arb={str(_r['arb']):16s} "
f"commanded {_r['target']:+.0f} W, reason {_r['reason'][:40]!r}")
# ⚠️ THE PRECONDITION, AND IT IS THE MASKING HAZARD IN THIS FIXTURE (habit 1).
# `decide()` refuses outright while the peak guard is engaged, §9.1 outranking
# §10.1 - so a fixture whose guard is holding answers "no trade" in EVERY row,
# the armed one included, and the AC3 rows below would grade the guard rather
# than the gate. It is asserted, not assumed.
check(f"the peak guard is CLEAR in all four rows, so what refuses a trade "
f"below is the arbitrage gate and not §9.1's interlock "
f"{ {k: _T10_TRADE[k]['guard'] for k in sorted(_T10_TRADE, key=str)} }",
all(_T10_TRADE[k]["guard"] is False for k in _T10_TRADE))
check("the ARMED baseline actually trades on the plan TARIFF-10 now resolves - "
"it charges in the cheap hour and discharges in the dear one. Without "
"this row, 'trading off never trades' would be satisfied by a fixture "
"that could not have traded either way",
_T10_TRADE[(True, True)]["arb"] == "ARBITRAGE_CHARGE"
and _T10_TRADE[(True, False)]["arb"] == "ARBITRAGE_DISCHARGE")
check(f"...and the site is actually COMMANDED there, which is the only "
f"observable that moves energy: {_T10_TRADE[(True, True)]['target']:+.0f} W "
f"on the cheap hour against "
f"{_T10_TRADE[(True, False)]['target']:+.0f} W on the dear one",
_T10_TRADE[(True, True)]["target"] < -1000.0
and _T10_TRADE[(True, False)]["target"] > 1000.0)
check(f"AC3: with `arbitrage_enabled: false` and the SAME resolved provider "
f"plan, the strategy is never consulted and NOTHING trades - "
f"arb={_T10_TRADE[(False, True)]['arb']!r} / "
f"{_T10_TRADE[(False, False)]['arb']!r} in both directions",
_T10_TRADE[(False, True)]["arb"] is None
and _T10_TRADE[(False, False)]["arb"] is None)
check(f"...and the command is the LOOP's, not a trade that merely went "
f"unnamed: {_T10_TRADE[(False, True)]['target']:+.0f} W / "
f"{_T10_TRADE[(False, False)]['target']:+.0f} W, reason "
f"{_T10_TRADE[(False, True)]['reason'][:30]!r} - the same small "
f"self-consumption figure on both plans, where the armed rows differ by "
f"10 kW between them",
all(abs(_T10_TRADE[(False, c)]["target"]) < 1000.0
and "arbitrage" not in _T10_TRADE[(False, c)]["reason"]
for c in (True, False)))
check("...and the trading gate is untouched by this card: it is still "
"`arbitrage.enabled()` and it is still the only thing the price "
"strategy is gated on - the four rows above differ in that option and "
"in nothing else",
_arb.enabled({"arbitrage_enabled": True})
and not _arb.enabled({})
and not _arb.enabled({"price_provider": "generic_dayahead"}))
# -- AC4: the four surfaces that DESCRIBE this, pinned as a PAIR with it ------
# ⚠️ THIS IS A TEXT CHECK AND IT IS WEAK ON PURPOSE (test_translations.py §3's
# argument). It cannot know a sentence is TRUE. What it CAN do is refuse to let
# the code and the prose move independently: the left side of each pairing is
# DRIVEN above, the right side is read off the file, and the two must agree. It
# shipped as False against True - the docs described Option A for as long as
# both existed - and an overclaim in a doc is a defect in this repository.
_T10_DOCS = derived(lambda: open(os.path.join(HERE, "DOCS.md"),
encoding="utf-8").read())
_T10_CFG = derived(lambda: open(os.path.join(HERE, "config.yaml"),
encoding="utf-8").read())
_T10_TR = derived(lambda: {lang: open(os.path.join(HERE, "translations",
f"{lang}.yaml"),
encoding="utf-8").read()
for lang in ("en", "nl")})
_T10_FETCHES = bool(_on["requests"])
_T10_DOCS_SAYS = "selecting a provider is what starts the fetch" \
in _T10_DOCS.lower()
check(f"AC4: the BEHAVIOUR and DOCS.md agree about what a configured provider "
f"does on a shipped-default install - it fetches = {_T10_FETCHES}, DOCS "
f"says it fetches = {_T10_DOCS_SAYS}. These two are the pair that has to "
f"move together",
_T10_FETCHES == _T10_DOCS_SAYS)
# The claim that had rotted in the OTHER direction: OPT-04 shipped the
# consumer, so "nothing consumes the price series yet" had been false for a
# release before this card made the first half false too.
_T10_STALE = {"config.yaml": "consumes\n # the series yet" in _T10_CFG
or "nothing consumes the price series" in _T10_CFG,
"en.yaml": "nothing consumes the price series yet"
in _T10_TR["en"],
"nl.yaml": "er is nog niets dat de prijsreeks gebruikt"
in _T10_TR["nl"]}
check(f"...and no surface still says the price series has no consumer - untrue "
f"since OPT-04, and it sat beside the claim this card is fixing in all "
f"three files {[k for k, v in _T10_STALE.items() if v]}",
not any(_T10_STALE.values()))
check("...and BOTH translations tell an operator which switch does which - "
"the provider option says it is what starts the fetch, and the trading "
"option says turning it off does not turn the feed off. One language "
"updated and not the other is how this surface rots",
"STARTS THE PRICE FETCH" in _T10_TR["en"]
and "DOES NOT TURN THE PRICE FEED OFF" in _T10_TR["en"]
and "HET OPHALEN VAN PRIJZEN START" in _T10_TR["nl"]
and "ZET DE PRIJSFEED NIET UIT" in _T10_TR["nl"])
# --------------------------------------------------------------------------- #
print()
print("14. TARIFF-14 - the point whose OWN interval covers the instant")
# ⚠️ THE RESOLUTION IS THE WHOLE SECTION, AND ITS ABSENCE IS WHY THE DEFECT
# SHIPPED. Every price fixture in this repository before TARIFF-08 was the
# invented HOURLY row shape, and on an hourly series `s <= u < s + 1 h` and
# "until the next point's start" AGREE AT EVERY INSTANT - so the suite had
# been exercising the one resolution that cannot expose the bug. Section 8
# counts points on the 23- and 25-hour days and never asks `at()` for a
# quarter-hour. Belgium moved to a 15-minute MTU on 2025-10-01 and the live
# day-ahead feed returns 96 points at 900 s.
# ⚠️ BRUSSELS MIDNIGHT, NOT UTC MIDNIGHT, so that every wall-clock time in
# a check name below is the time the series really renders - "00:15" and
# "23:45" are what `canonical()` writes, not an offset away from it.
Q0 = datetime(2026, 1, 15, 0, 0, tzinfo=timezone(timedelta(hours=1)))
HOUR = timedelta(hours=1)
def spaced(gaps, first=0.10, step=0.01, provisional=None, start=Q0):
"""A series whose consecutive gaps are exactly `gaps`, in SECONDS.
`len(points) == len(gaps) + 1`. Prices ascend by `step`, so a point can be
named by VALUE as well as by identity and a sweep that compared only
prices could still tell two points apart.
"""
n = len(gaps) + 1
flags = [False] * n if provisional is None else list(provisional)
pts, t = [], start
for i in range(n):
pts.append(PricePoint(brussels(t), round(first + step * i, 6),
bool(flags[i])))
if i < len(gaps):
t += timedelta(seconds=gaps[i])
return PriceRead.series(pts, PriceProduct.DAY_AHEAD, "tariff14", start)
def gaps_of(s):
u = [p.start.astimezone(UTC) for p in s.points]
return [int((b - a).total_seconds()) for a, b in zip(u, u[1:])]
# The three shapes the acceptance criteria name, plus the two degenerate ones.
Q = spaced([900] * 95) # the shipped 96-point Belgian day
H = spaced([3600] * 23) # what every older fixture carried
# ⚠️ FCST-04'S STEADY STATE, NOT AN EDGE CASE. Eight firm quarter-hours, then
# the carried HOURLY points the fill adds for the hours nobody published. The
# seam between them is a 900 s gap and the tail is 3600 s.
MIX = spaced([900] * 8 + [3600] * 3,
provisional=[False] * 9 + [True] * 3)
# The same seam made the END of the series - the last point is the FIRST
# carried hourly one, and the gap behind it is 900 s. This is the fixture the
# tail decision is graded on.
MIX_TAIL = spaced([900] * 8, provisional=[False] * 8 + [True])
ONE = spaced([])
print("14a. the fixtures are not degenerate - asserted before anything else")
check(f"the quarter-hourly fixture is the live shape - 96 points, spacing "
f"{sorted(set(gaps_of(Q)))} s",
len(Q.points) == 96 and set(gaps_of(Q)) == {900})
check(f"the hourly fixture is 24 points at {sorted(set(gaps_of(H)))} s - and "
f"it is a REGRESSION guard, not a witness: the shipped rule and this "
f"one agree at every instant of it, which is exactly why nothing caught "
f"this",
len(H.points) == 24 and set(gaps_of(H)) == {3600})
check(f"...while the MIXED fixture really is mixed, spacing "
f"{sorted(set(gaps_of(MIX)))} s - the pair the FCST-04 soak measured in "
f"70 % of samples, so a window derived from ONE global spacing would be "
f"wrong in the NORMAL steady state",
set(gaps_of(MIX)) == {900, 3600}
and set(gaps_of(MIX_TAIL)) == {900}
and gaps_of(MIX)[-1] == 3600 and gaps_of(MIX_TAIL)[-1] == 900)
check("...and every point of every fixture carries a DISTINCT price, so 'the "
"covering point' names one point and a wrong answer cannot coincide "
"with the right one",
all(len({p.eur_per_kwh for p in s.points}) == len(s.points)
for s in (Q, H, MIX, MIX_TAIL)))
check("...and the mixed fixtures carry BOTH firm and carried points, which is "
"what makes them FCST-04's output rather than a spacing exercise",
all(any(p.provisional for p in s.points)
and any(not p.provisional for p in s.points)
for s in (MIX, MIX_TAIL)))
print()
print("14b. the reading the shipped scan got wrong, stated as literals")
# ⚠️ ASSERTED AGAINST TYPED-OUT INDICES AND PRICES, not against a window this
# file recomputed by price.py's own rule. The sweep below is exhaustive and
# the sweep is derived; these four are the anchors that keep it honest.
_q15 = derived(lambda: Q.at(Q0 + timedelta(minutes=15)))
check(f"at(00:15) on the 900 s series is the 00:15 point at 0.11 EUR/kWh - the "
f"shipped scan answered the 00:00 point at 0.10, because the :00 "
f"point's HOUR had not run out yet (saw {_q15!r})",
_q15 is Q.points[1] and derived(lambda: _q15.eur_per_kwh) == 0.11)
_q195 = derived(lambda: Q.at(Q0 + timedelta(minutes=195)))
check(f"at(03:15) is the 03:15 point at 0.23 - the shipped scan answered "
f"02:30 at 0.20 here, and that is the SECOND failure mode: from the "
f"second hour on the first point still inside its own hour wins, so the "
f"error COMPOUNDS with depth into the series (saw {_q195!r})",
_q195 is Q.points[13] and derived(lambda: _q195.eur_per_kwh) == 0.23)
check("at(00:30) and at(00:45) are their own quarters too, so the answer is "
"not merely 'one point later'",
derived(lambda: Q.at(Q0 + timedelta(minutes=30))) is Q.points[2]
and derived(lambda: Q.at(Q0 + timedelta(minutes=45))) is Q.points[3])
check("on the MIXED series the quarter-hourly half answers per QUARTER and "
"the carried hourly half answers per HOUR, in one series - at(00:15) is "
"the 00:15 point and at(02:30) is the 02:00 one",
derived(lambda: MIX.at(Q0 + timedelta(minutes=15))) is MIX.points[1]
and derived(lambda: MIX.at(Q0 + timedelta(minutes=150))) is MIX.points[8]
and derived(lambda: MIX.points[8].start.astimezone(UTC)) ==
Q0 + timedelta(hours=2))
print()
print("14c. the exhaustive sweep, at 900 s, 3600 s and mixed")
def windows(s):
"""`(start_utc, width)` per point, computed HERE from the gaps.
Deliberately NOT read off `_Series._cover`: a check built from the table it
grades compares a thing against itself. The rule is restated - a point runs
to the next start, capped at an hour; the last point copies the width in
front of it; a lone point gets an hour.
"""
u = [p.start.astimezone(UTC) for p in s.points]
out = []
for i, t in enumerate(u):
if i + 1 < len(u):
g = u[i + 1] - t
elif i:
g = t - u[i - 1]
else:
g = HOUR
out.append((t, g if g < HOUR else HOUR))
return out
def sweep(s):
"""Every probe whose covering point is unambiguous, and what `at()` said."""
wrong = []
for i, (t, w) in enumerate(windows(s)):
for off in (timedelta(0), w / 2, w - timedelta(seconds=1)):
got = derived(lambda: s.at(t + off))
if got is not s.points[i]:
wrong.append(f"{(t + off).isoformat()} -> {got!r}, "
f"wanted {s.points[i].eur_per_kwh}")
return wrong
for _name, _s in (("900 s", Q), ("3600 s", H), ("mixed 900/3600", MIX),
("mixed, 900 s tail", MIX_TAIL)):
_w = derived(lambda s=_s: sweep(s))
check(f"{_name}: all {3 * len(_s.points)} probes inside the series return "
f"the point whose OWN interval covers them "
f"({_w if _w != [] else 'no misses'})", _w == [])
print()
print("14d. the one-hour cap, which is what keeps FCST-04's holes holes")
# ⚠️ NOT DECORATION. FCST-04 leaves an hour OUT when nothing published it and
# yesterday has no value either, and price_forecast.py's module note names
# `at()` raising Absence for it as the behaviour that sends the caller to the
# static profile (§11.4 for that hour). Uncapped, "until the next point's
# start" would stretch the point before a three-hour hole across the whole
# hole - filling holes with a stale price, which is the defect this module
# exists to refuse, introduced by the fix for a different one.
HOLE = PriceRead.series(
[PricePoint(brussels(Q0 + timedelta(hours=i)), 0.10 + i / 100.0, False)
for i in (0, 1, 4, 5)],
PriceProduct.DAY_AHEAD, "tariff14", Q0)
_in_hole = [Q0 + timedelta(hours=2), Q0 + timedelta(hours=2, minutes=30),
Q0 + timedelta(hours=3), Q0 + timedelta(hours=3, minutes=59)]
check(f"a three-hour hole stays a hole: all {len(_in_hole)} instants inside it "
f"raise Absence, while the point BEFORE it still covers its own hour and "
f"the point after it answers from its own start",
all(raises(lambda u=u: HOLE.at(u), Absence) for u in _in_hole)
and derived(lambda: HOLE.at(Q0 + timedelta(hours=1, minutes=59)))
is HOLE.points[1]
and derived(lambda: HOLE.at(Q0 + timedelta(hours=4))) is HOLE.points[2])
check(f"...and the cap is stated once, as MAX_POINT_SPAN = "
f"{_price.MAX_POINT_SPAN}, rather than spelled into the lookup",
_price.MAX_POINT_SPAN == HOUR)
# ⚠️ AND THE OTHER HALF OF THE HOLE, WHICH IS THE ONE §8.3 READS. FCST-04
# fills a whole hour nobody published with ONE carried point, so a
# quarter-hourly day can carry an hourly carried point in the middle of it.
# The instant inside that hour is covered by the CARRIED point - and the
# shipped scan answered the FIRM quarter 52 minutes earlier, because that was
# the first point still inside its own hour. `_act()`'s `here.provisional`
# guard and `price_forecast._walk()`'s `series.at(u).provisional` both read the
# flag off whatever that lookup returned, so the flag was FIRM on an hour
# nobody published.
SANDWICH = PriceRead.series(
[PricePoint(brussels(Q0 + timedelta(minutes=15 * i)),
round(0.10 + i / 1000.0, 6), False) for i in range(44)]
+ [PricePoint(brussels(Q0 + timedelta(hours=11)), 0.500, True)]
+ [PricePoint(brussels(Q0 + timedelta(hours=12, minutes=15 * i)),
round(0.20 + i / 1000.0, 6), False) for i in range(4)],
PriceProduct.DAY_AHEAD, "tariff14", Q0)
_sand_t = Q0 + timedelta(hours=11, minutes=37)
def shipped_at(s, u):
"""The rule TARIFF-14 replaced, as the COUNTERFACTUAL only - nothing is
graded against it. It is here so the fixture can be SHOWN to separate the
two rules; a check whose data cannot reach the failure it names grades
nothing, and on an hourly series these two agree at every instant."""
uu = u.astimezone(UTC)
for p in s.points:
st = p.start.astimezone(UTC)
if st <= uu < st + HOUR:
return p
return None
check("the sandwich fixture is FCST-04's real output - 44 firm quarters, ONE "
"carried hourly point filling the hour nobody published, then firm "
"quarters again",
len(SANDWICH.points) == 49
and SANDWICH.points[44].provisional is True
and sum(1 for p in SANDWICH.points if p.provisional) == 1)
_sand_now = derived(lambda: SANDWICH.at(_sand_t))
_sand_old = derived(lambda: shipped_at(SANDWICH, _sand_t))
check(f"an instant inside the carried hour is covered by the CARRIED point, "
f"so §8.3's flag is read off the hour it belongs to. The shipped scan "
f"answered the FIRM quarter 52 minutes earlier "
f"({derived(lambda: _sand_old.start.isoformat())}, provisional="
f"{derived(lambda: _sand_old.provisional)}), which is a carried hour "
f"wearing a published hour's flag",
_sand_now is SANDWICH.points[44]
and derived(lambda: _sand_now.provisional) is True
and _sand_old is SANDWICH.points[43]
and derived(lambda: _sand_old.provisional) is False)
print()
print("14e. the LAST point's window - decided and documented, not inherited")
# There is no next start and §9.3 says nothing about how wide the final
# interval is, so THE LAST POINT IS GIVEN THE SAME SPAN AS THE POINT BEFORE IT
# (one hour when it is the only point). The alternative - a flat one-hour tail
# - is defensible for an hourly series and wrong for the live one: it answers
# 00:30 TOMORROW with today's last price, which is reaching for a neighbour
# across a day boundary.
_ql = Q.points[-1].start.astimezone(UTC)
_hl = H.points[-1].start.astimezone(UTC)
_ml = MIX_TAIL.points[-1].start.astimezone(UTC)
check("the 900 s day's last point covers its own QUARTER: at(+14:59) answers "
"and at(+15:00) raises, so today's 23:45 price cannot be returned for "
"00:30 tomorrow",
derived(lambda: Q.at(_ql + timedelta(minutes=14, seconds=59)))
is Q.points[-1]
and raises(lambda: Q.at(_ql + timedelta(minutes=15)), Absence))
check("the hourly series' last point still covers an HOUR, exactly as it "
"shipped - no static or hourly site changes behaviour",
derived(lambda: H.at(_hl + timedelta(minutes=59))) is H.points[-1]
and raises(lambda: H.at(_hl + HOUR), Absence))
check("the MIXED series ending at the firm/carried seam takes the 900 s in "
"front of it, so its last 45 minutes raise Absence. That is the SAFE "
"direction and it is CHOSEN: §11.4 sends the caller to the static "
"profile rather than trading on a span the series never demonstrated, "
"and the point is carried, so §8.3 forbids irreversible action on it "
"anyway",
derived(lambda: MIX_TAIL.at(_ml + timedelta(minutes=14, seconds=59)))
is MIX_TAIL.points[-1]
and raises(lambda: MIX_TAIL.at(_ml + timedelta(minutes=15)), Absence)
and MIX_TAIL.points[-1].provisional)
# ⚠️ HABIT 1. Without this the three checks above would all pass under a
# single global tail rule of 15 minutes, or of an hour, depending which two
# you read - so the claim that the tail FOLLOWS THE SERIES is asserted as its
# own check, at one instant where the three fixtures must disagree.
_t30 = [derived(lambda s=s, l=l: s.covers(l + timedelta(minutes=30)))
for s, l in ((Q, _ql), (H, _hl), (MIX_TAIL, _ml))]
check(f"...and those tails are genuinely three readings of one rule and not "
f"one constant: 30 minutes past its own last point the hourly series "
f"answers and both 900 s ones do not {_t30}",
_t30 == [False, True, False])
check("a series of ONE point has no gap in front of it to copy, so it gets an "
"hour - the shipped behaviour, kept deliberately for the degenerate case",
derived(lambda: ONE.at(Q0 + timedelta(minutes=59, seconds=59)))
is ONE.points[0]
and raises(lambda: ONE.at(Q0 + HOUR), Absence))
print()
print("14f. order is established by the lookup, not assumed of the caller")
# ⚠️ `at()` now reads a point's NEIGHBOURS, so it WOULD depend on sortedness.
# The index is sorted at construction so that it cannot. `_validate_raw()`
# refuses out-of-order and duplicate starts on the `load_prices()` path
# (section 9 above), `price_history._parse_points()` re-sorts what it reads off
# disk and FCST-04's fill emits ascending - but `PriceRead.series()` itself has
# never enforced any of it, and an assumption that holds today and is unstated
# is how this class of defect recurs.
_REV = PriceRead.series(tuple(reversed(Q.points)), PriceProduct.DAY_AHEAD,
"tariff14", Q0)
check("the reversed fixture really is reversed, so the check below is not "
"grading two identical series",
_REV.points[0] is Q.points[-1] and _REV.points[-1] is Q.points[0]
and _REV.points != Q.points)
_rev_bad = derived(lambda: [p.start.isoformat() for p in Q.points
if _REV.at(p.start) is not p])
check(f"...and a series handed its 96 points in REVERSE order answers at() "
f"identically at every one of them "
f"({_rev_bad if _rev_bad != [] else 'no misses'})",
_rev_bad == [])
check("...while the load path refuses an unsorted feed outright, so the two "
"defences are stated rather than one being quietly relied on",
raises(lambda: _price._validate_raw(
[RawPoint(T0 + timedelta(minutes=15), 0.1, False),
RawPoint(T0, 0.2, False)], _caps), SourceUnavailable))
check("a DUPLICATED start does not raise anywhere - not at construction and "
"not on the 30 s control path. The later list entry takes the real span "
"and the earlier one becomes unreachable through at(); load_prices() "
"refuses the shape outright, and this says what happens to anyone who "
"builds one by hand",
derived(lambda: PriceRead.series(
[PricePoint(brussels(Q0), 0.10, False),
PricePoint(brussels(Q0), 0.20, False),
PricePoint(brussels(Q0 + timedelta(minutes=15)), 0.30, False)],
PriceProduct.DAY_AHEAD, "tariff14", Q0).at(Q0).eur_per_kwh) == 0.20)
print()
print("14g. covers() shared it; window() did not")
check("covers() shared the defect EXACTLY, because it has no window rule of "
"its own - it asks at() - so the one change fixes both. On the 900 s "
"day it is now True 14:59 past the last point and False 15:00 past it, "
"where an hour-wide window said True for another 45 minutes",
derived(lambda: Q.covers(_ql + timedelta(minutes=14, seconds=59))) is True
and derived(lambda: Q.covers(_ql + timedelta(minutes=15))) is False
and derived(lambda: Q.covers(_ql + timedelta(minutes=30))) is False
and derived(lambda: Q.covers(Q0 + timedelta(minutes=15))) is True)
check("window() did NOT share it, and the reason is worth a check rather than "
"a rediscovery: it grades a point's START against a half-open range and "
"never asks how long a point lasts, so it carries no resolution "
"assumption at all. 00:07:30 .. 00:22:30 holds exactly the 00:15 point "
"- while at(00:07:30) is the 00:00 one, which is what makes it a "
"DIFFERENT question and not a coverage query",
derived(lambda: Q.window(Q0 + timedelta(minutes=7, seconds=30),
Q0 + timedelta(minutes=22, seconds=30)))
== (Q.points[1],)
and derived(lambda: Q.at(Q0 + timedelta(minutes=7, seconds=30)))
is Q.points[0])
print()
print("14h. the 'no data is not free' contract, unchanged")
check("at() answers with a PricePoint or raises Absence - never None, at any "
"resolution, so `series.at(t) or fallback` cannot become a price of zero",
all(isinstance(derived(lambda s=s: s.at(s.points[0].start)), PricePoint)
for s in (Q, H, MIX, MIX_TAIL, ONE))
and raises(lambda: Q.at(Q0 - timedelta(seconds=1)), Absence)
and raises(lambda: Q.at(Q0 + timedelta(days=2)), Absence))
_absence_words = _reason(lambda: Q.at(Q0 + timedelta(days=2)))
check(f"...and the refusal still names the range the series runs, so an "
f"operator can tell 'outside the horizon' from 'into a hole'",
Q.points[0].start.isoformat() in _absence_words
and Q.points[-1].start.isoformat() in _absence_words)
check("...and it never invents a neighbour: the instant one second before the "
"series starts is refused, not answered with the first point",
raises(lambda: Q.at(Q0 - timedelta(seconds=1)), Absence)
and raises(lambda: MIX.at(Q0 - timedelta(seconds=1)), Absence)
and raises(lambda: H.at(Q0 - timedelta(seconds=1)), Absence))
# --------------------------------------------------------------------------- #
for _d in _tmpdirs_t10:
shutil.rmtree(_d, ignore_errors=True)
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")