Files
glenn schrooyenandClaude Opus 5 dd40c56ab4
checks / unit (pull_request) Successful in 2m43s
FCST-03: yesterday's prices, flagged as yesterday's, and unable to move anything
§8.3 fills the hours a day-ahead feed has not published yet with yesterday's
profile and flags them provisional, and forbids any irreversible action on such
a price. The whole risk of this ticket is one line - a carried point and a
published point are three fields and a bool, so `provisional=y.provisional`
copies yesterday's False onto today's guess and produces stale data presented as
current. That is SAFETY-18's frozen-but-fresh meter one layer up.

So the flag is never passed through. Carried points come from a constructor with
no provisional parameter; published points pass through BY IDENTITY, so no copy
exists that could lose one; an audit re-derives the expected flag for every
emitted point and raises rather than repairing; and the check walks the module's
own AST so the pass-through idiom cannot come back looking ordinary. A published
point may itself be provisional (eneco_eddy's imbalance print is), so the rule
for a published hour is "whatever the feed said", never False.

The fill keys on the Brussels wall clock rather than t-24h, so the 23- and
25-hour days are right; a late publication rebuilds the series, moving the
number and the flag together.

AC2 and AC3 landed on TARIFF-03's existing verdict surface rather than beside
it. Refusal 3 became "fewer than two FIRM hours" - refusing on "any point is
provisional" would have switched arbitrage off every morning on every dynamic
site - and refusal 5 is new: the spread must clear a threshold widened by the
widest active uncertainty source (max, never a product). The permitting variant
now refuses to be constructed around a provisional point at all, and the
cheap-window flag became per-hour for the same reason.

The irreversible action is a value carrying the hours it commits, derived from
maintenance.py's own DRAIN_TIMEOUT_MIN; the gate blocks a maintenance drain
whose window reaches into a carried hour, and the phase machine stays idle. It
is the price-justified entry point only - a gate that blocked on no_data would
stop the monthly cycle on every default install.

Zero new config options; TEST-14's signature scan ported to cover both Gate's
and Arbitrage's constructors, closing _Refused.reason's open hole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:54:07 +02:00

862 lines
42 KiB
Python

"""Runnable check for static day/night and fully-fixed tariffs. `python3 test_static_tariff.py`
WHAT THIS FILE IS DEFENDING, in the order the ticket's acceptance criteria name
it:
AC1 `static_profile` (§14) drives TIME-WINDOW SHIFTING for a two-period
tariff - the right window at the right local instant, on both sides of
both 2026 DST transitions, and reaching the one shiftable load the add-on
actually has.
AC2 Fully-fixed pricing DISABLES ARBITRAGE and leaves self-consumption only.
AC3 The Belgian scenario suite carries a static-profile regression case.
THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
* EVERY VERDICT IS DERIVED. No `check("the split is right", True)`. Where a
check needs an expected number it is computed HERE, by different arithmetic
than app/static_tariff.py uses - the window split is re-derived by summing
over samples with an independent local-hour test, the profile mean by a
second pass, the saving by two routes that must agree.
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE. A
two-period tariff has this trap BUILT IN and it is the main hazard here:
- THE PERIOD BOUNDARY IS THE DEGENERACY. At 07:00 and at 22:00 the "which
window" question and the "is it cheap" question agree under almost any
bug, and a probe at 12:00 cannot tell a correct wrap-around from one
that treats 22:00-07:00 as 22:00-24:00. So probes sit at 06:59 / 07:00 /
07:01 and 21:59 / 22:00 / 22:01, and a Saturday 03:00 probe exists
specifically because the mon-fri night window MUST NOT cover it.
- MEAN-CHEAP AND MINIMUM-CHEAP AGREE ON A TWO-LEVEL TARIFF. Under two
prices "below the mean" and "is the cheapest price" are the same set, so
a two-period profile cannot tell `cheap_now` from `price == min`.
Section 3 therefore runs a THREE-level profile where those two answers
differ, and asserts they differ before grading anything with it.
- DAY AND NIGHT MUST HAVE DIFFERENT PRICES AND NEITHER MAY BE ZERO, or the
whole shifting question collapses. Asserted as checks in their own
right, first, rather than assumed.
- THE DST CHECKS RUN ON THE 23-HOUR AND 25-HOUR DAYS, not on a normal day
where a 24-slot implementation and a correct one agree exactly.
- THE REPLAY DAY MUST CARRY IMPORT IN BOTH WINDOWS. A day whose whole
import fell in one window grades no split at all; section 6 asserts that
before it reports a saving.
* A FAILED READ IS NOT AN OBSERVATION. A missing static_profile is not a zero
price, and zero is exactly the value that makes everything look free. So
`static_plan` with no profile is checked to produce a read with NO
`.points`, and `price_at` on it must RAISE rather than answer 0.0.
"""
import gzip
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from app.price import (
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
plan_is_provisional,
)
from app.static_tariff import (
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
parse_static_profile, resolve_tariff, static_plan, static_status,
)
from app.validate import validate_startup_config
HERE = Path(__file__).resolve().parent
UTC = timezone.utc
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def raises(fn, exc):
"""True only if fn() raises EXACTLY `exc`. A different exception is False -
'it was refused' and 'it broke on the way to being refused' must not grade
the same."""
try:
fn()
except exc:
return True
except Exception:
return False
return False
def reason(fn):
try:
fn()
except Exception as e: # noqa: BLE001
return str(e)
return ""
class _Raised:
def __init__(self, e):
self.e = e
def __eq__(self, other):
return False
def __hash__(self):
return id(self)
def __bool__(self):
return False
def __repr__(self):
return f"<raised {type(self.e).__name__}: {self.e}>"
def derived(fn):
"""A value a check grades. An exception becomes `_Raised`, which equals
nothing and is falsy - NOT a default. See test_price.py for why."""
try:
return fn()
except Exception as e: # noqa: BLE001
return _Raised(e)
def near(a, b, tol=1e-9):
try:
return abs(float(a) - float(b)) <= tol
except (TypeError, ValueError):
return False
# --------------------------------------------------------------------------- #
# The fixtures. Belgian day/night numbers, and their degeneracies asserted.
# --------------------------------------------------------------------------- #
DAY_P = 0.3410 # all-in EUR/kWh, day
NIGHT_P = 0.2570 # all-in EUR/kWh, night
FIXED_P = 0.3070 # all-in EUR/kWh, single-tariff site
TWO_PERIOD = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
"eur_per_kwh": DAY_P},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
"eur_per_kwh": NIGHT_P},
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
"eur_per_kwh": NIGHT_P},
]}
FULLY_FIXED = {"periods": [
{"name": "fixed", "from": "00:00", "to": "24:00", "days": "all",
"eur_per_kwh": FIXED_P},
]}
# ⚠️ THREE LEVELS, AND SECTION 3 EXISTS BECAUSE OF IT. Under two prices "cheaper
# than the mean" and "is the minimum price" are the same set of hours, so a
# two-period profile cannot distinguish `cheap_now`'s rule from `price == min`.
# Here the middle level sits BELOW the mean without being the minimum (the four
# expensive evening hours pull the mean up past it), so those two rules give
# DIFFERENT answers at 12:00 - and section 3 proves they differ before grading
# anything with it.
THREE_LEVEL = {"periods": [
{"name": "peak", "from": "17:00", "to": "21:00", "days": "all",
"eur_per_kwh": 0.4800},
{"name": "day", "from": "07:00", "to": "17:00", "days": "all",
"eur_per_kwh": 0.2900},
{"name": "evening", "from": "21:00", "to": "24:00", "days": "all",
"eur_per_kwh": 0.2900},
{"name": "night", "from": "00:00", "to": "07:00", "days": "all",
"eur_per_kwh": 0.1900},
]}
def opts(profile=None, **over):
o = {"price_provider": "off",
"static_profile": "" if profile is None else json.dumps(profile)}
o.update(over)
return o
# --------------------------------------------------------------------------- #
print("0. the arithmetic this file grades with must not be degenerate")
# ⚠️ These ARE the guard against a section that passes for the wrong reason. If
# any goes red, every shifting assertion below is measuring nothing.
check("day and night prices differ, so 'used the wrong window' is visible",
DAY_P != NIGHT_P)
check("...and neither is zero, so 'dropped the price entirely' is visible",
DAY_P != 0 and NIGHT_P != 0)
check("...and the fixed price is neither of them, so a fixed-tariff result "
"cannot be a day/night result in disguise",
FIXED_P not in (DAY_P, NIGHT_P))
check("...and the day/night spread is not the fixed price, so 'reported the "
"spread where the price was wanted' is visible",
not near(DAY_P - NIGHT_P, FIXED_P))
_lv = sorted({p["eur_per_kwh"] for p in THREE_LEVEL["periods"]})
check(f"the three-level fixture really has three levels {_lv}", len(_lv) == 3)
# --------------------------------------------------------------------------- #
print()
print("1. AC1 - the two-period profile puts the right window on the right hour")
TWO = parse_static_profile(json.dumps(TWO_PERIOD))
check("a JSON string parses (what config.yaml's flat `str?` option carries)",
isinstance(TWO, StaticProfile))
check("...and so does an already-parsed dict (what /data/options.json would "
"carry under a nested schema, and what sim/inject.py hands over)",
isinstance(parse_static_profile(TWO_PERIOD), StaticProfile))
check("...and a bare list of periods, without the wrapper",
isinstance(parse_static_profile(TWO_PERIOD["periods"]), StaticProfile))
# ⚠️ THE PRODUCT IS DERIVED FROM THE DATA. Nobody ticks a box saying 'static'.
check("the product is derived from the number of distinct prices, not declared: "
"two levels -> STATIC", TWO.product is PriceProduct.STATIC)
check("...and one level -> FIXED, from the same code, with no flag anywhere",
parse_static_profile(FULLY_FIXED).product is PriceProduct.FIXED)
# ...and it is really the PRICES, not the row count: three rows, one price.
_three_rows_one_price = parse_static_profile({"periods": [
dict(p, eur_per_kwh=FIXED_P) for p in TWO_PERIOD["periods"]]})
check("...and THREE rows carrying ONE price is still FIXED, so the derivation "
"is on the prices and not on len(periods)",
_three_rows_one_price.product is PriceProduct.FIXED
and len(_three_rows_one_price.periods) == 3)
# ⚠️ PROBES AT THE BOUNDARY, NOT IN THE MIDDLE OF A WINDOW. 2026-01-15 is a
# Thursday and Belgium is on CET (+01:00) then, so local == UTC + 1 h.
THU = datetime(2026, 1, 15, tzinfo=UTC)
def at_local(day, hh, mm=0):
"""A UTC instant whose BRUSSELS wall clock reads hh:mm on `day`.
Built by search rather than by adding an offset, so this helper cannot
inherit the same offset bug it is meant to detect.
"""
for minutes in range(-180, 24 * 60 + 180):
u = day + timedelta(minutes=minutes)
loc = brussels(u)
if loc.hour == hh and loc.minute == mm and loc.date() == \
brussels(day + timedelta(hours=12)).date():
return u
raise AssertionError(f"no instant reads {hh:02d}:{mm:02d} local on {day}")
_probes = [(6, 59, "night"), (7, 0, "day"), (7, 1, "day"),
(21, 59, "day"), (22, 0, "night"), (22, 1, "night"),
(12, 0, "day"), (3, 0, "night")]
_got = [(hh, mm, TWO.at(at_local(THU, hh, mm)).name) for hh, mm, _ in _probes]
check(f"the half-open boundaries land exactly - 06:59 night, 07:00 day, "
f"21:59 day, 22:00 night {_got}",
all(name == want for (hh, mm, want), (_, __, name)
in zip(_probes, _got)))
check("...and the 22:00-07:00 window really wraps midnight: Thursday 03:00 is "
"night, which a [22:00,24:00) reading would call uncovered",
TWO.at(at_local(THU, 3)).name == "night")
# ⚠️ THE DAY-TYPE TRAP. A `mon-fri` night window must NOT bleed into Saturday
# morning - a period applies on the day the CLOCK reads. The weekend row is what
# covers Saturday 03:00, and it is a DIFFERENT Period object.
SAT = datetime(2026, 1, 17, tzinfo=UTC) # a Saturday
_sat3 = TWO.at(at_local(SAT, 3))
_thu3 = TWO.at(at_local(THU, 3))
check("Saturday 03:00 is served by the WEEKEND row, not by Friday's night "
"window bleeding over - same name and price, different period",
_sat3.name == _thu3.name and _sat3 is not _thu3
and 5 in _sat3.days and 5 not in _thu3.days)
check("...and Saturday 12:00 is night-priced, because a Belgian weekend tariff "
"runs all day - a weekday-shaped lookup would call it day",
near(TWO.price_at(at_local(SAT, 12)), NIGHT_P))
check("...while Thursday 12:00 is day-priced, so the two are really different "
"answers and not one answer twice",
near(TWO.price_at(at_local(THU, 12)), DAY_P))
# next_change: the shifting primitive - "how long until the cheap window".
_nc = TWO.next_change(at_local(THU, 12))
check(f"next_change from Thursday 12:00 is the 22:00 flip "
f"{None if _nc is None else brussels(_nc).isoformat()}",
_nc is not None and brussels(_nc).hour == 22 and brussels(_nc).minute == 0)
_nc2 = TWO.next_change(at_local(SAT, 12))
check("...and from Saturday noon it is Monday 07:00, because the whole weekend "
"is one window - a scan that stopped at midnight would say 24 h",
_nc2 is not None and brussels(_nc2).hour == 7
and brussels(_nc2).weekday() == 0)
# --------------------------------------------------------------------------- #
print()
print("2. AC2 - a fully-fixed tariff disables arbitrage, by arithmetic")
FIX = parse_static_profile(FULLY_FIXED)
# ⚠️ The window OPENS A DAY EARLY. `at_local(THU, 0)` is 23:00 UTC on the 14th -
# Belgium is an hour ahead - so a plan starting at THU would not cover midnight
# local, and `price_at` would correctly raise for it. That raise is the right
# behaviour (an hour outside the series is an hour with no price) but it is not
# what these sections are grading.
SPAN_A, SPAN_B = THU - timedelta(days=1), THU + timedelta(days=2)
p_fixed = static_plan(opts(FULLY_FIXED), SPAN_A, SPAN_B)
p_two = static_plan(opts(TWO_PERIOD), SPAN_A, SPAN_B)
check("the fixed plan really produced a series (so the refusal below is about "
"the tariff, not about a failed read)",
p_fixed.has_prices and len(p_fixed.read.points) == 72)
_spread_fixed = derived(p_fixed.spread_eur_kwh)
_spread_two = derived(p_two.spread_eur_kwh)
check(f"...and its spread is exactly zero {_spread_fixed}",
near(_spread_fixed, 0.0))
check(f"...while the two-period spread is the day/night difference "
f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}",
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
_v_fixed = p_fixed.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
_v_two = p_two.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
check("a fully-fixed tariff REFUSES arbitrage", _v_fixed.kind == "refused")
check("...for the true reason - the series is flat, so there is nothing to buy "
"low and sell high, not because a FIXED enum was matched on",
"flat" in _v_fixed.reason and "self-consumption only" in _v_fixed.reason)
check("...and a two-period tariff, on the same code path, ALLOWS it - so the "
"refusal is discriminating rather than universal",
_v_two.kind == "allowed")
# ⚠️ NOT KEYED ON THE PRODUCT ENUM. A DAY-AHEAD series that happens to print one
# price all day must be refused for the same reason, or the rule is a label
# check wearing arithmetic's clothes.
_flat_dayahead = TariffPlan(
PriceRead.series(
[PricePoint(start=brussels(THU + timedelta(hours=i)),
eur_per_kwh=0.2222, provisional=False) for i in range(24)],
PriceProduct.DAY_AHEAD, "generic_dayahead", THU),
None, "provider")
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
"is arithmetic and not a product label",
_flat_dayahead.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
# The numbers an optimiser needs exist ONLY on the permitting variant.
check("the allowed verdict carries the spread, the cheapest hour and the "
"dearest hour - the whole input set for a trade",
near(_v_two.spread_eur_kwh, DAY_P - NIGHT_P)
and near(_v_two.cheapest.eur_per_kwh, NIGHT_P)
and near(_v_two.dearest.eur_per_kwh, DAY_P))
check("...and a REFUSED verdict carries none of them: .spread_eur_kwh raises "
"Absence rather than answering 0.0, which is the number that would make "
"every hour look equally good",
raises(lambda: _v_fixed.spread_eur_kwh, Absence)
and raises(lambda: _v_fixed.cheapest, Absence))
check("...and it is not iterable and has no .get(), the other two routes from "
"a refusal to an empty, harmless-looking loop",
raises(lambda: list(_v_fixed), Absence)
and raises(lambda: _v_fixed.get("spread_eur_kwh", 0.0), Absence))
check("...and match() demands BOTH arms, so the refusal cannot be fallen "
"through", raises(lambda: _v_fixed.match(allowed=lambda v: 1), TypeError))
check("...and there is no falsy variant, so `if verdict:` cannot look like a "
"guard while being none", bool(_v_fixed) is True and bool(_v_two) is True)
# §10.1 priority 2 over priority 3, expressed so OPT-04 cannot skip it.
check("peak shaving outranks arbitrage: the SAME allowed plan is refused when "
"the peak guard is holding",
p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
check("...and the reason names the ordering rather than the tariff",
"priority 2" in p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).reason)
check("...and the question cannot be skipped: peak_guard_active is required "
"and keyword-only, so a spread is not obtainable without answering it",
raises(lambda: p_two.arbitrage(), TypeError)
and raises(lambda: p_two.arbitrage(False), TypeError))
check("...nor answered with a truthy 'unknown'",
raises(lambda: p_two.arbitrage(peak_guard_active=None), TypeError)
and raises(lambda: p_two.arbitrage(peak_guard_active="no"), TypeError))
# --------------------------------------------------------------------------- #
print()
print("3. the cheap-window rule, on a tariff where it is not degenerate")
# ⚠️ WHY THREE LEVELS. Under two prices, "below the mean" and "equal to the
# minimum" select the SAME hours, so a two-period profile cannot tell
# cheap_now()'s actual rule from `price == min(prices)`. This fixture's `day`
# level sits between the two, and the check below PROVES the two rules disagree
# before anything is graded with it.
THREE = parse_static_profile(THREE_LEVEL)
p3 = static_plan(opts(THREE_LEVEL), SPAN_A,
SPAN_A + timedelta(days=MEAN_WINDOW_DAYS))
_mean3 = p3.mean_eur_kwh()
_min3 = min(p.eur_per_kwh for p in p3.read.points)
_day3 = 0.2900
check(f"the three-level fixture is non-degenerate: the minimum {_min3:.4f} is "
f"strictly below the middle level {_day3:.4f}, which is strictly below "
f"the mean {_mean3:.4f} - so 'below the mean' and 'is the minimum' select "
f"DIFFERENT sets of hours and this fixture can tell them apart",
_min3 < _day3 < _mean3)
check("...so cheap_now() calls the 07:00-17:00 MIDDLE level cheap - it is under "
"the mean without being the cheapest price of the day, which a "
"`price == min(prices)` rule would get wrong and a `price < mean` rule "
"gets right", p3.cheap_now(at_local(THU, 12)) is True)
check("...and the middle level really is not the minimum, so that check graded "
"the difference rather than restating it",
not near(p3.price_at(at_local(THU, 12)), _min3))
check("...and calls the 00:00-07:00 minimum cheap",
p3.cheap_now(at_local(THU, 3)) is True)
check("...and the 17:00-21:00 peak not cheap",
p3.cheap_now(at_local(THU, 18)) is False)
# ⚠️ STRICTLY BELOW, AND THAT IS AC2 IN THE SHIFTING HALF. On a flat series
# `<=` would make EVERY hour a cheap window and a fixed-tariff site would top
# the battery up from the grid at any hour - the opposite of self-consumption
# only.
_fixed_hours = [at_local(THU, h) for h in range(0, 24, 3)]
check(f"a fully-fixed tariff has NO cheap window at any hour of the day "
f"{[h for h in range(0, 24, 3) if p_fixed.cheap_now(at_local(THU, h))]} - "
f"which is what `<` buys and `<=` would destroy",
not any(p_fixed.cheap_now(t) for t in _fixed_hours))
check("...and the price really is defined at every one of those hours, so the "
"'no cheap window' answer is a priced no and not a missing read",
all(near(p_fixed.price_at(t), FIXED_P) for t in _fixed_hours))
# ...and on the two-period tariff the cheap hours are exactly the night ones.
_cheap_h = {h for h in range(24) if p_two.cheap_now(at_local(THU, h))}
_night_h = {h for h in range(24) if TWO.at(at_local(THU, h)).name == "night"}
check(f"on the day/night tariff the cheap hours ARE the night hours, derived "
f"two ways {sorted(_cheap_h)}",
_cheap_h == _night_h and 0 < len(_cheap_h) < 24)
# ⚠️ THE MEAN IS OVER SEVEN DAYS, NOT OVER THE HOUR YOU ASKED AT. A 48 h mean on
# a Friday afternoon is ~2/3 weekend, so the same Tuesday 09:00 would come out
# cheap or dear depending on when the question was put.
FRI = datetime(2026, 1, 16, tzinfo=UTC)
_from_thu = static_status(opts(TWO_PERIOD), at_local(THU, 12))
_from_fri = static_status(opts(TWO_PERIOD), at_local(FRI, 15))
_mean_thu = static_plan(opts(TWO_PERIOD), at_local(THU, 12),
at_local(THU, 12) + timedelta(days=MEAN_WINDOW_DAYS)
).mean_eur_kwh()
_mean_fri = static_plan(opts(TWO_PERIOD), at_local(FRI, 15),
at_local(FRI, 15) + timedelta(days=MEAN_WINDOW_DAYS)
).mean_eur_kwh()
check(f"the 7-day mean is stable across the day it is asked on "
f"({_mean_thu:.4f} vs {_mean_fri:.4f}), so a window's cheapness is a "
f"property of the tariff and not of the clock",
near(_mean_thu, _mean_fri, 5e-4))
_mean48_thu = static_plan(opts(TWO_PERIOD), at_local(THU, 12),
at_local(THU, 12) + timedelta(days=2)).mean_eur_kwh()
_mean48_fri = static_plan(opts(TWO_PERIOD), at_local(FRI, 15),
at_local(FRI, 15) + timedelta(days=2)).mean_eur_kwh()
check(f"...and the 48 h mean this replaces is NOT stable ({_mean48_thu:.4f} vs "
f"{_mean48_fri:.4f}), so the seven days are buying something real",
not near(_mean48_thu, _mean48_fri, 5e-4))
# --------------------------------------------------------------------------- #
print()
print("4. DST - 23 and 25 hour days, where a 24-slot implementation is wrong")
# ⚠️ 2026: CEST from 01:00 UTC Sunday 29 March, CET from 01:00 UTC Sunday
# 25 October (price.py's brussels_offset, checked in PR #16 against zoneinfo
# over 149,040 instants). Both are SUNDAYS, so the weekend row prices them -
# which is itself worth knowing, because the wrap-around weekday row is not what
# is under test here.
SPRING = datetime(2026, 3, 29, tzinfo=UTC)
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC)
def local_day_points(profile, day_utc):
"""Hourly points whose BRUSSELS date is that of `day_utc` + 12 h."""
target = brussels(day_utc + timedelta(hours=12)).date()
pts = profile.series(day_utc - timedelta(hours=6),
day_utc + timedelta(hours=30), provisional=False)
return [p for p in pts if p.start.date() == target]
_spring = derived(lambda: local_day_points(TWO, SPRING))
_autumn = derived(lambda: local_day_points(TWO, AUTUMN))
check(f"the spring-forward local day emits 23 points, not 24 "
f"({len(_spring) if not isinstance(_spring, _Raised) else _spring})",
not isinstance(_spring, _Raised) and len(_spring) == 23)
check(f"the autumn-back local day emits 25 points, not 24 "
f"({len(_autumn) if not isinstance(_autumn, _Raised) else _autumn})",
not isinstance(_autumn, _Raised) and len(_autumn) == 25)
check("...and the two 02:00 points in October are DIFFERENT instants at "
"DIFFERENT offsets, which a naive local-hour key would collide",
len({p.start.utcoffset() for p in _autumn if p.start.hour == 2}) == 2)
check("...and every emitted start is strictly increasing in UTC, which is what "
"price.py's boundary check demands of a fetched feed too",
all(a.start.astimezone(UTC) < b.start.astimezone(UTC)
for a, b in zip(_autumn, _autumn[1:])))
# A weekday profile across a transition, so the wrap-around path sees one too.
_wrapdays = local_day_points(TWO, datetime(2026, 3, 30, tzinfo=UTC))
check("a normal Monday still emits 24, so the 23/25 above are the transitions "
"and not a systematic miscount", len(_wrapdays) == 24)
# --------------------------------------------------------------------------- #
print()
print("5. a failed read is not an observation, and a gap is not a price")
_none = static_plan(opts(None), THU, THU + timedelta(days=1))
check("no static_profile produces a NO-DATA read, not an empty series and not "
"a zero one", not _none.has_prices)
check("...and reading a price off it RAISES rather than answering 0.0 - the "
"value that would make every hour look free",
raises(lambda: _none.price_at(THU), Absence)
and raises(_none.mean_eur_kwh, Absence)
and raises(_none.spread_eur_kwh, Absence))
check("...and its underlying read has no .points at all, so `min(read.points)` "
"cannot run against it",
raises(lambda: _none.read.points, Absence))
check("...and arbitrage is refused, per §11.4",
_none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
and arbitrage_allowed(_none.read) is False)
check("...and static_status() says so in words rather than reporting a price",
static_status(opts(None), THU) == (False, "no static profile"))
# ⚠️ THE TILING REFUSAL. An uncovered hour has no price and the only ways to
# serve it are to invent one or to fail when the clock reaches it.
_gap = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
"eur_per_kwh": DAY_P},
{"name": "night", "from": "23:00", "to": "07:00", "days": "all",
"eur_per_kwh": NIGHT_P}]}
check("a profile with a 22:00-23:00 gap is REFUSED, not defaulted",
raises(lambda: parse_static_profile(_gap), ProfileInvalid))
check("...and names the uncovered range so the operator can fix it",
"22:00-23:00" in reason(lambda: parse_static_profile(_gap)))
_overlap = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
"eur_per_kwh": DAY_P},
{"name": "night", "from": "21:00", "to": "07:00", "days": "all",
"eur_per_kwh": NIGHT_P}]}
check("...and an overlap is refused too - two prices for one hour means one "
"wins on list order",
raises(lambda: parse_static_profile(_overlap), ProfileInvalid)
and "2 prices" in reason(lambda: parse_static_profile(_overlap)))
# THE CLASSIC MISTAKE the docstring warns about: no weekend row.
_noweekend = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
"eur_per_kwh": DAY_P},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
"eur_per_kwh": NIGHT_P}]}
check("a weekday-only profile is refused because Saturday and Sunday have no "
"price - the mistake a wrap-around window invites",
raises(lambda: parse_static_profile(_noweekend), ProfileInvalid)
and "sat" in reason(lambda: parse_static_profile(_noweekend)))
# ...and the shipped shape is accepted, so the rule is not simply "refuse all".
check("...while the three-row Belgian shape IS accepted, so the tiling rule "
"discriminates rather than refusing everything",
isinstance(parse_static_profile(TWO_PERIOD), StaticProfile))
_bad = [
("not JSON at all", "day 07:00-22:00 0.34"),
("a price in cents", {"periods": [dict(FULLY_FIXED["periods"][0],
eur_per_kwh=30.7)]}),
("a non-numeric price", {"periods": [dict(FULLY_FIXED["periods"][0],
eur_per_kwh="cheap")]}),
("a missing days selector", {"periods": [
{"name": "x", "from": "00:00", "to": "24:00", "eur_per_kwh": 0.3}]}),
("an unknown day name", {"periods": [dict(FULLY_FIXED["periods"][0],
days="mon-frd")]}),
("a decimal hour", {"periods": [dict(FULLY_FIXED["periods"][0],
**{"from": "7.5"})]}),
("an unsupported timezone", dict(FULLY_FIXED, timezone="Europe/Madrid")),
("an empty periods list", {"periods": []}),
]
for label, value in _bad:
payload = value if isinstance(value, str) else json.dumps(value)
check(f"refused: {label}",
raises(lambda p=payload: parse_static_profile(p), ProfileInvalid))
check("...and every one of those refusals says something an operator can act "
"on, rather than just failing",
all(len(reason(lambda p=(v if isinstance(v, str) else json.dumps(v)):
parse_static_profile(p))) > 40 for _, v in _bad))
for label, value in (("empty", ""), ("null", "null"), ("absent", None),
("whitespace", " ")):
check(f"...while {label!r} means 'no profile' rather than an error",
parse_static_profile(value) is None)
# --------------------------------------------------------------------------- #
print()
print("6. AC3 - the static-profile regression case, on the Belgian data that exists")
# ⚠️ WHAT THE "BELGIAN SCENARIO SUITE" ACTUALLY IS IN THIS REPO. specs §17 names
# six scenarios; none of them is a static-profile case, which is why AC3 asks
# for a seventh. What exists as DATA is two days of this house's real P1 net
# power, captured from the live meter and committed as
# `scenarios/p1-net-2026-08-{20,23}.jsonl.gz` - the same fixtures test_tariff.py
# part 9 replays for the capacity tariff. Nothing longer was ever captured.
#
# So this case is measured against REAL BELGIAN HOUSEHOLD LOAD, and the two days
# happen to be one weekday and one weekend day, which is exactly the pair a
# day/night tariff needs to be graded on. It is NOT a 30-day replay and this
# section does not claim to be one; the numbers it prints are the numbers those
# two days actually produce.
REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz"))
print(f" replay fixtures found: {len(REPLAY)} "
f"({', '.join(p.name for p in REPLAY) or 'none'})")
check("the committed replay fixtures are present - a regression fixture CI "
"cannot see is not a regression fixture", len(REPLAY) >= 2)
MAX_GAP_S = 60.0 # a longer hole in the capture is not counted as energy
def replay_import(path):
"""(kWh imported per local tariff window, total kWh, span hours, weekday).
⚠️ THE WINDOW IS DECIDED HERE BY AN INDEPENDENT ROUTE - the Brussels-local
hour of the sample, tested against the fixture's own 07:00/22:00 boundaries
- rather than by asking the module under test. The two must agree; that
agreement is the check.
"""
per_window = {}
total = 0.0
first = last = None
prev = None
days = set()
with gzip.open(path, "rt", encoding="utf-8") as fh:
for line in fh:
row = json.loads(line)
t = datetime.fromisoformat(row["t"])
w = float(row["w"])
if first is None:
first = t
last = t
if prev is not None:
dt = (t - prev[0]).total_seconds()
if 0 < dt <= MAX_GAP_S and prev[1] > 0:
kwh = prev[1] * dt / 3_600_000.0
loc = brussels(prev[0])
days.add(loc.weekday())
window = ("day" if loc.weekday() < 5
and 7 <= loc.hour < 22 else "night")
per_window[window] = per_window.get(window, 0.0) + kwh
total += kwh
prev = (t, w)
span = (last - first).total_seconds() / 3600.0 if first else 0.0
return per_window, total, span, days
def cost_under(path, profile):
"""EUR the imported energy cost, priced sample by sample by the MODULE."""
total = 0.0
prev = None
with gzip.open(path, "rt", encoding="utf-8") as fh:
for line in fh:
row = json.loads(line)
t = datetime.fromisoformat(row["t"])
w = float(row["w"])
if prev is not None:
dt = (t - prev[0]).total_seconds()
if 0 < dt <= MAX_GAP_S and prev[1] > 0:
total += (prev[1] * dt / 3_600_000.0) * profile.price_at(prev[0])
prev = (t, w)
return total
replay_days = 0.0
graded = 0
for path in REPLAY:
windows, total_kwh, span_h, weekdays = replay_import(path)
replay_days += span_h / 24.0
day_kwh = windows.get("day", 0.0)
night_kwh = windows.get("night", 0.0)
kind = "weekday" if weekdays and min(weekdays) < 5 else "weekend"
print(f" {path.name}: {span_h:.1f} h, {kind} "
f"(weekday indices {sorted(weekdays)}), {total_kwh:.3f} kWh imported "
f"= {day_kwh:.3f} day + {night_kwh:.3f} night")
check(f"{path.name}: the two routes to the total agree",
near(day_kwh + night_kwh, total_kwh, 1e-9))
# The module's own pricing must reproduce the independently-split volumes.
c_two = cost_under(path, TWO)
c_expect = day_kwh * DAY_P + night_kwh * NIGHT_P
check(f"{path.name}: the module prices the real day at "
f"EUR {c_two:.4f}, which equals the independently-split "
f"{day_kwh:.3f} kWh x {DAY_P} + {night_kwh:.3f} kWh x {NIGHT_P} = "
f"EUR {c_expect:.4f}", near(c_two, c_expect, 1e-9))
c_fix = cost_under(path, FIX)
check(f"{path.name}: under a FULLY FIXED tariff the same energy costs "
f"EUR {c_fix:.4f} = {total_kwh:.3f} kWh x {FIXED_P}, one price for "
f"every hour", near(c_fix, total_kwh * FIXED_P, 1e-9))
# ⚠️ THE SHIFTING FIGURE, AND THE DEGENERACY GUARD IN FRONT OF IT. A day
# whose entire import landed in one window grades nothing: the saving would
# be 0 under a correct implementation AND under one that ignored the clock.
if day_kwh > 0 and night_kwh > 0:
graded += 1
saving = c_two - total_kwh * NIGHT_P
check(f"{path.name}: this day DOES import in both windows "
f"({day_kwh:.3f} / {night_kwh:.3f} kWh), so the split grades "
f"something", True)
check(f"{path.name}: shifting every day-window kWh into the night "
f"window saves EUR {saving:.4f}, which is exactly "
f"{day_kwh:.3f} kWh x the {DAY_P - NIGHT_P:.4f} spread - two "
f"routes to one number",
near(saving, day_kwh * (DAY_P - NIGHT_P), 1e-9) and saving > 0)
# AC2 on the same real data: under a fixed tariff there is nothing to
# shift, and that is arithmetic rather than a special case.
check(f"{path.name}: under the FIXED tariff the identical shift saves "
f"exactly nothing, so self-consumption-only is the right "
f"behaviour for that product",
near(c_fix - total_kwh * FIXED_P, 0.0, 1e-12))
else:
print(f" {path.name}: import fell in ONE window only "
f"({day_kwh:.3f} day / {night_kwh:.3f} night) - this day is a "
f"day-typing witness, not a shifting one, and no saving is "
f"claimed from it")
print()
print(f" AC3 MEASURED: {replay_days:.1f} DAYS of real Belgian household "
f"P1 data, {graded} of {len(REPLAY)} days carrying import in BOTH tariff "
f"windows and therefore grading the split.")
print(f" AC3 ASKS FOR A REGRESSION CASE IN THE BELGIAN SUITE, AND THIS IS "
f"ONE - but the suite holds two captured days, not a season, so nothing "
f"here is a claim about annual savings. §17's six named scenarios do not "
f"include a static-profile case; this is the seventh, and TEST-01 should "
f"say so.")
check("the replay produced a measurable split over a stated sample size, "
"rather than a criterion quietly redefined to fit the data",
graded >= 1 and replay_days < 30)
check("...and BOTH day types are present in the fixtures, which is what makes "
"the weekend row testable on real data at all",
len({("weekday" if min(replay_import(p)[3]) < 5 else "weekend")
for p in REPLAY}) == 2)
# --------------------------------------------------------------------------- #
print()
print("7. §11.4 - the profile is the fallback, and the fallback is provisional")
async def _dead(url, timeout_s):
raise OSError("the supplier's feed is down")
def run(coro):
import asyncio
return asyncio.run(coro)
_cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
price_feed_url="https://api.energy-charts.info/price?bzn=BE",
price_timeout_s=1, price_markup_eur_kwh=0.03,
price_taxes_eur_kwh=0.06)
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead))
check("a dead feed falls back to the static profile rather than to nothing",
_fb.origin == "fallback" and _fb.has_prices)
check("...with the SAME prices the configured-product path produces, because "
"it is one generator and not a second implementation",
[p.eur_per_kwh for p in _fb.read.points]
== [p.eur_per_kwh for p in static_plan(
opts(TWO_PERIOD), THU, THU + timedelta(days=1)).read.points])
check("...marked PROVISIONAL, which is the one flag that differs",
_fb.provisional and plan_is_provisional(_fb.read))
check("...and arbitrage is refused on it, per §11.4, even though a full series "
"exists and has a real spread",
_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P))
check("...while the SAME profile as the configured product allows it - so the "
"refusal is about provisionality and not about the profile",
static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1)
).arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed")
check("...and the provisional plan has no cheap window either: §8.3 forbids an "
"irreversible action on a provisional price, and a grid top-up is one",
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
_fb_none = run(resolve_tariff(opts(None, price_provider="generic_dayahead",
price_timeout_s=1),
THU, THU + timedelta(days=1), transport=_dead))
check("a dead feed with NO static_profile has nothing to fall back to, and "
"says that rather than inventing a tariff",
_fb_none.origin == "none" and not _fb_none.has_prices)
# A live feed still wins over the profile.
_body = json.dumps({"data": [
{"start": (THU + timedelta(hours=i)).isoformat(), "price_eur_mwh": 40 + i}
for i in range(24)]}).encode()
async def _live(url, timeout_s):
return _body
_live_plan = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1),
transport=_live))
check("a live feed is used in preference to the static profile",
_live_plan.origin == "provider"
and _live_plan.product is PriceProduct.DAY_AHEAD)
check("...and arbitrage is allowed on it, with the spread derived from the "
"feed rather than from the profile",
_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed"
and near(_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh,
0.023, 1e-9))
# --------------------------------------------------------------------------- #
print()
print("8. the startup gate, and CFG-03")
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("a good static_profile starts",
validate_startup_config(
vbase(static_profile=json.dumps(TWO_PERIOD)), -5000, 5000) == [])
check("a gapped one is REFUSED at startup, not at 22:00 on the day it bites",
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
-5000, 5000),
"static_profile cannot be used"))
check("...and the refusal names the uncovered range",
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
-5000, 5000), "22:00-23:00"))
check("static_profile alongside the Home Assistant price entities is refused - "
"main.py reads one of them and the loser is silent",
why(validate_startup_config(
vbase(static_profile=json.dumps(TWO_PERIOD),
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
-5000, 5000), "HOME ASSISTANT"))
check("...and the Home Assistant pair ALONE still starts, 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) == [])
check("...and an empty static_profile is never refused: §9.2 puts a large share "
"of the installed base on a fixed tariff, and §11.4 makes no-price-data a "
"supported state",
validate_startup_config(vbase(static_profile=""), -5000, 5000) == []
and validate_startup_config(vbase(), -5000, 5000) == [])
check("...and a fully-fixed profile starts, since a one-price site is a "
"supported product and not a misconfiguration",
validate_startup_config(vbase(static_profile=json.dumps(FULLY_FIXED)),
-5000, 5000) == [])
# ⚠️ CFG-03 IS NOT WIDENED BY THIS TICKET. `static_profile` was added to
# app/price.py's PRICE_OPTION_DEFAULTS - the ONE in-code mirror TARIFF-02
# created and pinned to config.yaml - rather than to a second defaults dict in
# the new module. That mirror is enforced by test_price.py section 1, which
# reads config.yaml; this check is the other half of the claim: the new module
# must carry NO defaults dict of its own.
_SRC = (HERE / "app" / "static_tariff.py").read_text(encoding="utf-8")
check("the new module holds no option-defaults dict of its own, so this ticket "
"added ZERO new places an option default lives (CFG-03)",
"OPTION_DEFAULTS" not in _SRC.replace("PRICE_OPTION_DEFAULTS", ""))
check("...and it reads the option through price.price_option(), the framework's "
"single read path, rather than through opts.get(key, <a number>)",
"price_option(opts" in _SRC and 'opts.get("static_profile"' not in _SRC)
_MAIN = (HERE / "app" / "main.py").read_text(encoding="utf-8")
check("...and main.py grew no `o.get(\"static_profile\", <a default>)` fallback "
"either - it calls static_status(o, now), and the option is read once, "
"inside the framework",
'get("static_profile"' not in _MAIN and "static_status(" in _MAIN)
# --------------------------------------------------------------------------- #
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")