- TariffPlan.arbitrage(now=...) is required (keyword-only, no default); so are homeview.trade_verdict/refusal_key's now. Existing test calls pass one. - test_arbitrage.py: no module-level decide wrapper. Fixtures that probed a one-day plan after its extremes now hold two days (as a site does); the flap day gets a unique cheapest and dearest hour; the replay breaks its ties without direction. The flap bound is <= 4 and the replay check is len(_trades) < 365 again. 228 checks pass on the old and the new code. - test_price02.py part 6: ties (equal quarters, a real 15-minute day, alternating hourly prices) and the required now. - Identity pins: the masked digest is only for the older-tree comparison; this tree's own digests are pinned with nothing hidden. - DECISIONS: the tie analysis with numbers. Co-Authored-By: Claude <noreply@anthropic.com>
1342 lines
68 KiB
Python
1342 lines
68 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.
|
|
|
|
Section 9 is TEST-16 and is not one of those criteria. It pins the two
|
|
`arbitrage()` rungs SAFETY-26's AC4 sweep found pinned from ONE SIDE ONLY -
|
|
`spread == 0.0` and `len(firm) < 2` - and, more to the point, pins them so
|
|
they stop MASKING EACH OTHER. Read its header before editing either rung.
|
|
|
|
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.
|
|
|
|
⚠️ 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 gzip
|
|
import json
|
|
import sys
|
|
|
|
from named_absence import first
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
from app.price import (
|
|
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
|
|
plan_is_provisional, required_spread, uncertainty_of,
|
|
)
|
|
from app.price_history import NoHistory
|
|
from app.static_tariff import (
|
|
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TAXES_PROFILE_OPTION,
|
|
TariffPlan, parse_static_profile, parse_taxes_profile, resolve_tariff,
|
|
static_plan, static_status,
|
|
)
|
|
from app.validate import validate_startup_config
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
UTC = timezone.utc
|
|
|
|
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
|
# point these fixtures build, so no period has ended and the whole series counts.
|
|
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
|
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
def _reason_of(fn):
|
|
"""The message fn() raised, or "" - so a check can assert WHICH rule
|
|
refused rather than only that something did (MUTATION.md habit 6)."""
|
|
try:
|
|
fn()
|
|
except Exception as e: # noqa: BLE001
|
|
return str(e)
|
|
return ""
|
|
|
|
|
|
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(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
|
|
_v_two = p_two.arbitrage(now=LONG_AGO, 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(now=LONG_AGO, 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(now=LONG_AGO, 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(now=LONG_AGO, 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(now=LONG_AGO, peak_guard_active=None), TypeError)
|
|
and raises(lambda: p_two.arbitrage(now=LONG_AGO, 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(now=LONG_AGO, 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 fixed tariff set"))
|
|
|
|
# ⚠️ 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)
|
|
|
|
|
|
# ⚠️ NO `price_feed_url` (TARIFF-09): an override is used verbatim and the
|
|
# plugin appends no date range to it, so setting one - even to the feed's own
|
|
# address - exercises the override path rather than the shipped default.
|
|
_cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
|
|
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,
|
|
history=NoHistory()))
|
|
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(now=LONG_AGO, 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(now=LONG_AGO, 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,
|
|
history=NoHistory()))
|
|
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.
|
|
# ⚠️ COLUMNAR (TARIFF-08). `api.energy-charts.info` publishes parallel
|
|
# `unix_seconds`/`price` arrays and no `data` key; the `{"data": [...]}` body
|
|
# this used to build was invented by the parser it was grading, which is how a
|
|
# provider that could never parse a live response shipped green. Real captured
|
|
# bodies are replayed in test_price.py section 2b - the prices here are
|
|
# synthetic, the SHAPE is not.
|
|
_body = json.dumps({
|
|
"unix_seconds": [int((THU + timedelta(hours=i)).timestamp())
|
|
for i in range(24)],
|
|
"price": [40 + i for i in range(24)],
|
|
"unit": "EUR / MWh",
|
|
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
|
"from Bundesnetzagentur | SMARD.de",
|
|
"deprecated": False}).encode()
|
|
|
|
|
|
async def _live(url, timeout_s):
|
|
return _body
|
|
|
|
|
|
_live_plan = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1),
|
|
transport=_live, history=NoHistory()))
|
|
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(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed"
|
|
and near(_live_plan.arbitrage(now=LONG_AGO, 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),
|
|
"“Fixed or day/night tariff” 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), "(price_avg_entity) would win over it"))
|
|
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()
|
|
print("9. TEST-16 - the two arbitrage rungs, pinned from BOTH sides and "
|
|
"un-masked from each other")
|
|
# SAFETY-26's AC4 gave the same tolerance mutation to every price and quantity
|
|
# boundary in arbitrage.py and static_tariff.py. Everything in arbitrage.py went
|
|
# red. The two survivors are the two rungs graded below, and section 2 above is
|
|
# why they survived: it probes the flat rung with an EXACTLY FLAT series and
|
|
# never probes the firm-pair rung at all.
|
|
#
|
|
# D `spread == 0.0` weakened to `spread <= 0.001` SURVIVED (+2 B). An
|
|
# exactly flat series refuses under both spellings, so a tolerance is
|
|
# invisible to a flat probe. It is NOT cosmetic: a real 0.0005 spread
|
|
# flips ALLOWED -> REFUSED at min_spread_eur_kwh=0.0, which arbitrage()'s
|
|
# own docstring documents as supported ("pass 0.0 to mean any positive
|
|
# spread, explicitly") and which its validator at :861 accepts on purpose.
|
|
#
|
|
# F `len(firm) < 2` weakened to `< 1` SURVIVED, at +0 BYTES - the delta
|
|
# MUTATION.md names as the risky one.
|
|
#
|
|
# ⚠️ AND THE FINDING IS THE INTERACTION, NOT EITHER ROW. D AND F MASK EACH
|
|
# OTHER. A single firm hour has a spread of 0.0, so with F weakened the FLAT
|
|
# rung catches the stimulus and the verdict is still "refused"; with D weakened
|
|
# the tiny-spread stimulus is still refused, just for a lie. Each rung's failure
|
|
# is absorbed by the other, so BOTH can be weakened together and a suite that
|
|
# grades only `kind` reports nothing. That is invisible to single-mutation
|
|
# testing, which is how this pair survived four rounds of review on TARIFF-03
|
|
# and FCST-03.
|
|
#
|
|
# TWO THINGS FOLLOW, and they shape every check below.
|
|
#
|
|
# 1. THE REASON IS GRADED, NOT JUST THE REFUSAL. The masking case REFUSES
|
|
# CORRECTLY WHILE REPORTING THE WRONG SENTENCE - an operator reading "the
|
|
# tariff is flat" about a tariff that is not flat, or reading it about a
|
|
# series whose real problem is that §8.3 has no firm pair to trade on. A
|
|
# check that asserts only "refused" passes on a broken tree. So each
|
|
# stimulus below is graded on WHICH RUNG ANSWERED IT.
|
|
# 2. EACH RUNG NEEDS A STIMULUS THE OTHER CANNOT ABSORB, or the pinning is
|
|
# circular. The flat rung gets a series with THREE firm hours, where the
|
|
# firm-pair rung cannot fire under any weakening down to `< 0`. The
|
|
# firm-pair rung gets a ONE-FIRM series, which the flat rung CAN answer -
|
|
# so its attribution is the whole check, and that is why row 2 of the table
|
|
# is written as a rung name rather than as a verdict.
|
|
#
|
|
# ⚠️ SHIPPED BEHAVIOUR IS UNCHANGED BY THIS SECTION. Both rungs are correct
|
|
# today; nothing in app/ was touched. This is the missing measurement, not a fix.
|
|
#
|
|
# THE LEDGER. Eight rounds through `tools/mutation_run.sh` (TEST-15): recursive
|
|
# `__pycache__` purge before each, `python3 -B`, the tree re-copied FROM DISK
|
|
# between rounds, the mutated line grepped out of the file before the run, and
|
|
# the byte delta printed. `test_static_tariff.py` and `test_arbitrage.py` graded
|
|
# each time. Both suites green on the unmutated tree.
|
|
#
|
|
# round delta test_static_tariff.py
|
|
# --------------------------------------------- ------ ---------------------
|
|
# baseline, shipped +0 B PASS
|
|
# D1 `spread == 0.0` -> `<= 0.001` +2 B RED, 3 checks
|
|
# D2 `spread == 0.0` -> `<= 0.000001` +5 B RED, AC1 alone
|
|
# D3 `spread == 0.0` -> `< 0.0` (rung gone) +0 B RED (section 2 too)
|
|
# F1 `len(firm) < 2` -> `< 1` +0 B RED, 3 checks
|
|
# F2 `len(firm) < 2` -> `< 3` +0 B RED, 6 checks
|
|
# F3 `len(firm) < 2` -> `< 0` (rung gone) +0 B RED (and ValueError)
|
|
# D1+F1 BOTH AT ONCE - THE TICKET +2 B RED, 5 checks
|
|
#
|
|
# Read the rows in pairs. D1 and F1 are SAFETY-26's two survivors and each is
|
|
# now red on its own; D2 is the tolerance bisected to the rung's own resolution,
|
|
# so "pinned at exactly zero" is measured rather than asserted at one epsilon;
|
|
# F2 and F3 pin the firm-pair count from ABOVE and BELOW, which the ticket's
|
|
# criteria do not ask for but which is what stops this section being one-sided
|
|
# in the way it was written to fix. THE LAST ROW IS THE TICKET: with BOTH rungs
|
|
# weakened together - the arrangement that reported nothing before this section
|
|
# existed - five checks go red, including AC1 and AC2, because neither of them
|
|
# is spelled in terms of the other rung's behaviour.
|
|
#
|
|
# ⚠️ FOUR OF THOSE SEVEN MUTATIONS ARE +0 BYTES, which MUTATION.md names as the
|
|
# delta stale bytecode can hide. They are all REDS, the direction the bytecode
|
|
# defect cannot fabricate - but they were run under the purge anyway, because
|
|
# "it went red so the cache cannot have lied" is a conclusion, not a procedure.
|
|
|
|
_A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
|
uncertainty=frozenset())
|
|
|
|
|
|
def plan9(prices, provisional=None, product=PriceProduct.DAY_AHEAD):
|
|
"""A bare hourly series, built here rather than through a profile.
|
|
|
|
A `static_profile` cannot express a 0.0005 spread or a part-provisional day,
|
|
and going through one would grade the profile generator as well. These are
|
|
the smallest series that put exactly one rung in question.
|
|
"""
|
|
flags = provisional if provisional is not None else [False] * len(prices)
|
|
pts = [PricePoint(start=brussels(THU + timedelta(hours=i)),
|
|
eur_per_kwh=p, provisional=v)
|
|
for i, (p, v) in enumerate(zip(prices, flags))]
|
|
return TariffPlan(PriceRead.series(pts, product, "generic_dayahead", THU),
|
|
None, "provider")
|
|
|
|
|
|
def rung9(verdict):
|
|
"""WHICH refusal answered, named from the reason the operator would read.
|
|
|
|
Deliberately not a code inspection: the sentence in the log IS the artefact
|
|
that degrades when these two rungs mask each other, so it is the sentence
|
|
that gets graded. An unrecognised refusal is reported as such rather than
|
|
quietly matching nothing - "no rung fired" and "a rung I did not list fired"
|
|
must not grade the same.
|
|
"""
|
|
if verdict.kind == "allowed":
|
|
return "allowed"
|
|
r = verdict.reason
|
|
hit = [n for n, needle in (("flat", "the tariff is flat"),
|
|
("firm-pair", "carry a published price"),
|
|
("threshold", "needs"),
|
|
("peak", "peak guard"),
|
|
("no-series", "No series exists"))
|
|
if needle in r]
|
|
return hit[0] if len(hit) == 1 else f"UNATTRIBUTED<{r[:60]}>"
|
|
|
|
|
|
# --- the sweep D survived: a small but REAL spread, at min_spread 0.0 --------
|
|
# ⚠️ NOT AN EXACTLY FLAT SERIES, which is the whole point - a flat probe cannot
|
|
# see a tolerance. The threshold is 0.0 so the `spread < need` rung below cannot
|
|
# fire either (required_spread(0.0, anything) is 0.0, asserted first), leaving
|
|
# the flat rung as the only thing that can refuse these.
|
|
check(f"the widened threshold at min_spread_eur_kwh=0.0 is still 0.0, so the "
|
|
f"probes below are graded by the FLAT rung and not by the threshold rung "
|
|
f"({required_spread(0.0, uncertainty_of(PriceProduct.DAY_AHEAD)):.6f})",
|
|
near(required_spread(0.0, uncertainty_of(PriceProduct.DAY_AHEAD)), 0.0))
|
|
|
|
# 1e-6 is the floor of the question, not of the check: app/static_tariff.py
|
|
# rounds the spread to 6 decimals before comparing, so a 5e-7 difference IS
|
|
# zero to this rung by construction. Graded as its own row below rather than
|
|
# left as an unstated edge.
|
|
TINY9 = (0.0005, 0.0001, 0.00001, 0.000001)
|
|
_tiny9 = [(d, plan9([0.20000, round(0.20000 + d, 6)])) for d in TINY9]
|
|
check(f"each tiny-spread probe really carries the spread it is named for, and "
|
|
f"two FIRM hours - so the firm-pair rung cannot be what answers them "
|
|
f"{[(f'{d:.6f}', f'{p.spread_eur_kwh():.6f}') for d, p in _tiny9]}",
|
|
all(near(p.spread_eur_kwh(), d, 1e-12)
|
|
and len([q for q in p.read.points if not q.provisional]) == 2
|
|
for d, p in _tiny9))
|
|
_tinyv9 = [(d, rung9(p.arbitrage(now=LONG_AGO, **_A9))) for d, p in _tiny9]
|
|
check(f"TEST-16 AC1: a spread that is SMALL BUT REAL is ALLOWED at "
|
|
f"min_spread_eur_kwh=0.0 - down to 0.000001 EUR/kWh, so the flat rung is "
|
|
f"`spread == 0.0` and not `spread <= epsilon` for any epsilon "
|
|
f"{_tinyv9}",
|
|
all(r == "allowed" for _d, r in _tinyv9))
|
|
check("...and the exactly flat series on the OTHER side of that boundary is "
|
|
"still refused, and refused BY THE FLAT RUNG - without this the sweep "
|
|
"above would also pass for a rung that had been deleted outright",
|
|
rung9(plan9([0.20000, 0.20000]).arbitrage(now=LONG_AGO, **_A9)) == "flat")
|
|
_q9 = plan9([0.20000, 0.2000005])
|
|
check(f"...and a difference below the rung's own 6-decimal rounding is flat BY "
|
|
f"CONSTRUCTION and says so: the raw difference is "
|
|
f"{_q9.spread_eur_kwh():.7f} and non-zero, `round(..., 6)` makes it "
|
|
f"exactly 0.0, and the rung agrees. That is the QUANTISATION the shipped "
|
|
f"line chooses in the open, not a tolerance smuggled into the comparison "
|
|
f"- and it is why the sweep above stops at 0.000001 rather than pretending "
|
|
f"to bisect further",
|
|
not near(_q9.spread_eur_kwh(), 0.0, 1e-9)
|
|
and near(round(_q9.spread_eur_kwh(), 6), 0.0, 1e-12)
|
|
and rung9(_q9.arbitrage(now=LONG_AGO, **_A9)) == "flat")
|
|
|
|
# --- and what D's survival actually costs: the sentence, not the verdict -----
|
|
_lie9 = plan9([0.20000, 0.20050]).arbitrage(now=LONG_AGO, **_A9)
|
|
check(f"TEST-16 AC4: the 0.0005 verdict carries the real spread rather than a "
|
|
f"flatness claim about a tariff that is not flat - the false sentence is "
|
|
f"what a loosened rung ships to the operator while the outcome at a "
|
|
f"shipped threshold looks unchanged ({_lie9.kind})",
|
|
_lie9.kind == "allowed" and near(_lie9.spread_eur_kwh, 0.0005)
|
|
and near(_lie9.cheapest.eur_per_kwh, 0.20000)
|
|
and near(_lie9.dearest.eur_per_kwh, 0.20050))
|
|
|
|
# --- the rung F survived: ON ITS OWN, not through the flat rung --------------
|
|
# ⚠️ THIS ROW IS THE ANTI-MASKING ONE. A one-firm series has a spread of 0.0,
|
|
# so with `< 2` weakened to `< 1` the FLAT rung refuses it and `kind` never
|
|
# moves. The only thing that moves is the sentence: §8.3's honest "only 1 of 3
|
|
# hours carry a published price" degrades to "the tariff is flat" about a series
|
|
# whose firm and provisional prices differ by 0.20 EUR/kWh. Grading the rung
|
|
# name is what makes `< 1` red without borrowing anything from the flat rung.
|
|
_one9 = plan9([0.20, 0.40, 0.10], [False, True, True])
|
|
check(f"the one-firm probe is well formed: exactly ONE firm hour out of three, "
|
|
f"and its full series is anything but flat (spread "
|
|
f"{max(p.eur_per_kwh for p in _one9.read.points) - min(p.eur_per_kwh for p in _one9.read.points):.2f} "
|
|
f"EUR/kWh) - so 'flat' would be a false sentence about it",
|
|
len([p for p in _one9.read.points if not p.provisional]) == 1
|
|
and len(_one9.read.points) == 3)
|
|
check("TEST-16 AC2: one firm hour is refused BY §8.3's FIRM-PAIR RUNG, named "
|
|
"in the reason - not by the flat rung that would also have refused it. "
|
|
"This is what takes `len(firm) < 2` -> `< 1` red on its own",
|
|
rung9(_one9.arbitrage(now=LONG_AGO, **_A9)) == "firm-pair")
|
|
check("...and the sentence is §8.3's in full, so the operator is told the "
|
|
"series is half-published rather than told it is fixed-price",
|
|
"§8.3" in _one9.arbitrage(now=LONG_AGO, **_A9).reason
|
|
and "only 1 of 3" in _one9.arbitrage(now=LONG_AGO, **_A9).reason
|
|
and "flat" not in _one9.arbitrage(now=LONG_AGO, **_A9).reason)
|
|
# The rung pinned from ABOVE as well. Without this, `len(firm) < 3` - the same
|
|
# +0 B edit in the other direction - would pass everything above.
|
|
_two9 = plan9([0.20, 0.40, 0.10], [False, False, True])
|
|
check("...and TWO firm hours are ALLOWED on the identical series shape, so the "
|
|
"rung is pinned from above too and `< 3` is red as well as `< 1`",
|
|
rung9(_two9.arbitrage(now=LONG_AGO, **_A9)) == "allowed"
|
|
and near(_two9.arbitrage(now=LONG_AGO, **_A9).spread_eur_kwh, 0.20))
|
|
# ...and from below, where the failure is not a wrong sentence but a crash:
|
|
# `max()` over an empty `firm` raises ValueError, so `< 0` does not degrade the
|
|
# reason, it takes the verdict away entirely.
|
|
_zero9 = plan9([0.20, 0.40, 0.10], [True, True, True])
|
|
check("...and a FULLY provisional series is refused by the same rung and does "
|
|
"not raise - `max()` over an empty firm set is a ValueError, so this row "
|
|
"is what makes `len(firm) < 0` red",
|
|
rung9(derived(lambda: _zero9.arbitrage(now=LONG_AGO, **_A9))) == "firm-pair")
|
|
|
|
# --- AC3: the two rungs do not mask each other ------------------------------
|
|
# ⚠️ THE POINT OF THE TICKET, AND IT IS A PROPERTY OF THE TABLE, NOT OF A ROW.
|
|
# Masking is possible exactly when every stimulus that one rung answers could
|
|
# also be answered by the other with an acceptable-looking result. The table
|
|
# below denies that on both sides:
|
|
#
|
|
# * the flat rung is asked a question the firm-pair rung CANNOT reach - three
|
|
# firm hours, where `len(firm) < N` refuses for no N a person would write;
|
|
# * the firm-pair rung is asked a question the flat rung CAN reach - one firm
|
|
# hour - and the answer is graded on WHICH rung gave it, so the flat rung
|
|
# absorbing it is a failure rather than a pass;
|
|
# * and the tiny-spread row is allowed, which neither rung may refuse.
|
|
#
|
|
# Verified by running BOTH mutations at once (`spread <= 0.001` AND
|
|
# `len(firm) < 1`, purged harness, +2 B and +0 B): three rows move. Under
|
|
# single-mutation testing this arrangement reports nothing at all.
|
|
_TABLE9 = (
|
|
("three firm hours, all 0.2000", plan9([0.2, 0.2, 0.2]), "flat"),
|
|
("two firm hours, 0.0005 apart", plan9([0.20000, 0.20050]), "allowed"),
|
|
("one firm hour of three", _one9, "firm-pair"),
|
|
("two firm hours of three", _two9, "allowed"),
|
|
("no firm hours at all", _zero9, "firm-pair"),
|
|
)
|
|
_got9 = [(name, want, rung9(derived(lambda p=pl: p.arbitrage(now=LONG_AGO, **_A9))))
|
|
for name, pl, want in _TABLE9]
|
|
check(f"TEST-16 AC3: each stimulus is answered by the rung that OWNS it, and "
|
|
f"by no other - the flat rung is asked a question the firm-pair rung "
|
|
f"cannot reach, and the firm-pair rung is asked one the flat rung could "
|
|
f"reach but must not be allowed to. Weakening EITHER rung moves a row; "
|
|
f"weakening BOTH TOGETHER still moves three "
|
|
f"{[(n, w, g) for n, w, g in _got9]}",
|
|
all(want == got for _n, want, got in _got9))
|
|
check("...and the table really does exercise both rungs and the allowing path, "
|
|
"so 'every row matched' is not a statement about one rung repeated",
|
|
{want for _n, _p, want in _TABLE9} == {"flat", "firm-pair", "allowed"})
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("10. TARIFF-17 - ONE period grammar, not two. A second tiling rule is a "
|
|
"second set of DST bugs")
|
|
# ⚠️ THE ACCEPTANCE CRITERION THIS GRADES, and why it needs two checks rather
|
|
# than one. `price_taxes_profile` carries the TAXES component as a period table
|
|
# so that a Walloon day/night distribution band can reach the optimiser. The
|
|
# obvious way to build it is a second parser next to this one - and the two
|
|
# would agree on every ordinary day and diverge on the 23- and 25-hour days,
|
|
# where nobody looks and where §11.4 makes the divergence quiet.
|
|
#
|
|
# So the claim is an IDENTITY ("it is this grammar"), and MUTATION.md habit 10
|
|
# says pin an identity structurally and a meaning behaviourally. Both are here:
|
|
# 10a the grammar family is DEFINED IN EXACTLY ONE MODULE, computed by
|
|
# parsing every file under app/ rather than by a list somebody typed;
|
|
# 10b the two options produce the SAME REFUSAL SENTENCES, derived from each
|
|
# other, which is what a fork would break first.
|
|
|
|
print()
|
|
print("10a. the grammar is defined in exactly one module under app/, and the "
|
|
"scope is COMPUTED")
|
|
# ⚠️ A HAND-WRITTEN SCOPE IS THE SAME DEFECT AS A HAND-WRITTEN FIXTURE
|
|
# (habit 12): it passes while the copy sits in the file nobody added to the
|
|
# list. Every `app/*.py` is parsed; the family is taken from THIS module's own
|
|
# top-level names, so it cannot drift from what the grammar actually is.
|
|
#
|
|
# ⚠️ AND THE FAMILY LIST HAS ONE DELIBERATE OMISSION, STATED HERE RATHER THAN
|
|
# ROUNDED OFF (habit 13). `_days` is NOT in it, because `maintenance.py` has a
|
|
# `_days` of its own - a NAMESAKE, not a copy - and the check below would go
|
|
# red on it and be deleted by whoever hit it. The namesake is asserted to BE a
|
|
# namesake, by signature, immediately afterwards, so the omission is graded
|
|
# rather than assumed.
|
|
_T17_FAMILY = ("Period", "StaticProfile", "ProfileInvalid", "_check_tiling",
|
|
"_raise_gap", "_raise_overlap", "_minutes", "_price",
|
|
"parse_static_profile", "DAY_NAMES", "MINUTES_PER_DAY",
|
|
"_DAY_INDEX", "_DAY_ALIASES", "SUPPORTED_TZ")
|
|
|
|
|
|
def _t17_defs(path):
|
|
"""Top-level names a module DEFINES - classes, functions and assignments."""
|
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
out = set()
|
|
for node in tree.body:
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
|
|
ast.ClassDef)):
|
|
out.add(node.name)
|
|
elif isinstance(node, ast.Assign):
|
|
out |= {t.id for t in node.targets if isinstance(t, ast.Name)}
|
|
return out
|
|
|
|
|
|
_T17_APP = sorted((HERE / "app").glob("*.py"))
|
|
_T17_WHERE = {name: sorted(p.name for p in _T17_APP if name in _t17_defs(p))
|
|
for name in _T17_FAMILY}
|
|
for _n, _w in sorted(_T17_WHERE.items()):
|
|
print(f" {_n:<22} defined in {_w}")
|
|
check(f"every one of the {len(_T17_FAMILY)} grammar names is defined in "
|
|
f"EXACTLY ONE of the {len(_T17_APP)} modules under app/ - a copy of "
|
|
f"`_check_tiling` in price.py would take this red",
|
|
all(len(w) == 1 for w in _T17_WHERE.values()))
|
|
check("...and that module is static_tariff.py in every case, so the grammar "
|
|
"did not move house either",
|
|
{w[0] for w in _T17_WHERE.values() if w} == {"static_tariff.py"})
|
|
check("...and the pin is not vacuous: each of those names really is a "
|
|
"top-level definition, so the scan found something rather than nothing",
|
|
all(w for w in _T17_WHERE.values()) and len(_T17_APP) > 5)
|
|
|
|
|
|
def _t17_sig(module, name):
|
|
tree = ast.parse((HERE / "app" / module).read_text(encoding="utf-8"))
|
|
fn = first((n for n in tree.body
|
|
if isinstance(n, ast.FunctionDef) and n.name == name),
|
|
f"def {name} in {module}")
|
|
return [a.arg for a in fn.args.args]
|
|
|
|
|
|
_T17_DAYS = (_t17_sig("static_tariff.py", "_days"),
|
|
_t17_sig("maintenance.py", "_days"))
|
|
print(f" _days: static_tariff{_T17_DAYS[0]} vs maintenance{_T17_DAYS[1]}")
|
|
check(f"...and the ONE name held out of the family is held out honestly: "
|
|
f"`maintenance._days{tuple(_T17_DAYS[1])}` renders an age in words and "
|
|
f"is not a second copy of the grammar's "
|
|
f"`_days{tuple(_T17_DAYS[0])}` day selector",
|
|
_T17_DAYS[0] != _T17_DAYS[1] and _T17_DAYS[0] == ["value", "field"]
|
|
and _T17_DAYS[1] == ["n"])
|
|
# ⚠️ WHAT THIS PIN CANNOT SEE, WITH ITS SHAPE (habit 13). It grades WHERE the
|
|
# grammar is defined, never WHAT it means: `_check_tiling` could be rewritten
|
|
# in place to accept a gap and this would still pass. Section 5 and 10b are
|
|
# what grade the meaning. It also covers `app/` only, and a second grammar
|
|
# living under `tools/` or in a check file is outside it.
|
|
|
|
_T17_BODY = ast.parse(
|
|
(HERE / "app" / "static_tariff.py").read_text(encoding="utf-8"))
|
|
_T17_WRAP = next(
|
|
(n for n in _T17_BODY.body
|
|
if isinstance(n, ast.FunctionDef) and n.name == "parse_taxes_profile"),
|
|
None)
|
|
_T17_CALLS = ([ast.unparse(n.func) for n in ast.walk(_T17_WRAP)
|
|
if isinstance(n, ast.Call)] if _T17_WRAP else [])
|
|
check(f"...and `parse_taxes_profile` is a WRAPPER and not a fork: its whole "
|
|
f"body calls {_T17_CALLS}, pinned on the AST rather than on the source "
|
|
f"text, because `\"parse_static_profile\" in src` is true of a copy that "
|
|
f"merely mentions it",
|
|
_T17_CALLS == ["parse_static_profile"])
|
|
|
|
|
|
print()
|
|
print("10b. and the two options give the SAME refusals - derived from each "
|
|
"other, not typed twice")
|
|
# ⚠️ EVERY EXPECTED SENTENCE HERE IS THE OTHER OPTION'S OWN SENTENCE with the
|
|
# key substituted. Nothing is transcribed, so a fork that reworded one refusal
|
|
# - or that quietly stopped refusing something - is red on the row it broke and
|
|
# not on all of them.
|
|
_T17_BAD = [
|
|
("a gap", {"periods": [{"name": "d", "from": "07:00", "to": "22:00",
|
|
"days": "all", "eur_per_kwh": 0.13}]}),
|
|
("an overlap", {"periods": [
|
|
{"name": "a", "from": "00:00", "to": "24:00", "days": "all",
|
|
"eur_per_kwh": 0.13},
|
|
{"name": "b", "from": "07:00", "to": "22:00", "days": "all",
|
|
"eur_per_kwh": 0.07}]}),
|
|
("a bad HH:MM", {"periods": [{"name": "d", "from": "7.5", "to": "22:00",
|
|
"days": "all", "eur_per_kwh": 0.13}]}),
|
|
("a bad day", {"periods": [{"name": "d", "from": "00:00", "to": "24:00",
|
|
"days": "funday", "eur_per_kwh": 0.13}]}),
|
|
("missing days", {"periods": [{"name": "d", "from": "00:00", "to": "24:00",
|
|
"eur_per_kwh": 0.13}]}),
|
|
("a zero-length window", {"periods": [
|
|
{"name": "d", "from": "07:00", "to": "07:00", "days": "all",
|
|
"eur_per_kwh": 0.13}]}),
|
|
("no periods list", {"timezone": "europe/brussels"}),
|
|
("an empty periods list", {"periods": []}),
|
|
("a foreign timezone", {"timezone": "utc", "periods": []}),
|
|
("periods that are not a list", {"periods": 3}),
|
|
("a row that is not an object", {"periods": ["dag"]}),
|
|
("invalid JSON", "{not json"),
|
|
("a 24:00 start", {"periods": [{"name": "d", "from": "24:00", "to": "07:00",
|
|
"days": "all", "eur_per_kwh": 0.13}]}),
|
|
]
|
|
_T17_ROWS = []
|
|
for _what, _bad in _T17_BAD:
|
|
_a = _reason_of(lambda: parse_static_profile(_bad))
|
|
_b = _reason_of(lambda: parse_taxes_profile(_bad))
|
|
_T17_ROWS.append((_what, _a, _b,
|
|
_a.replace("static_profile", TAXES_PROFILE_OPTION) == _b))
|
|
for _what, _a, _b, _same in _T17_ROWS:
|
|
print(f" {'ok ' if _same else 'XX '}{_what:<28} {_b[:74]}")
|
|
check(f"all {len(_T17_ROWS)} malformations are refused IDENTICALLY under both "
|
|
f"options, sentence for sentence, once the key name is substituted - "
|
|
f"which is what one parser means and a fork could not hold",
|
|
all(row[3] for row in _T17_ROWS))
|
|
check("...and every one of them really did refuse, or 'the two agree' would be "
|
|
"two empty strings agreeing",
|
|
all(row[1] and row[2] for row in _T17_ROWS))
|
|
check("...and the sentences are not all the same sentence, or the row above "
|
|
"would be one comparison repeated 13 times",
|
|
len({row[2] for row in _T17_ROWS}) == len(_T17_ROWS))
|
|
|
|
# ⚠️ THE ONE REFUSAL THAT IS DELIBERATELY DIFFERENT, and it is different
|
|
# because the two options carry different QUANTITIES in the same grammar. A
|
|
# period without `eur_per_kwh` gets a sentence naming what was wanted, and an
|
|
# operator who typed an ALL-IN price into the taxes table is out by an energy
|
|
# price with nothing reporting it.
|
|
_T17_NOPRICE = {"periods": [{"name": "d", "from": "00:00", "to": "24:00",
|
|
"days": "all"}]}
|
|
_T17_M_S = _reason_of(lambda: parse_static_profile(_T17_NOPRICE))
|
|
_T17_M_T = _reason_of(lambda: parse_taxes_profile(_T17_NOPRICE))
|
|
print(f" static_profile : {_T17_M_S[-96:]}")
|
|
print(f" taxes table : {_T17_M_T[-96:]}")
|
|
check("the 'you left the number out' refusal is the one sentence the two do "
|
|
"NOT share, and each names its own quantity: an all-in price for "
|
|
"static_profile, the taxes component alone for the table",
|
|
"an all-in eur_per_kwh" in _T17_M_S
|
|
and "an all-in eur_per_kwh" not in _T17_M_T
|
|
and "TAXES component alone" in _T17_M_T
|
|
and "NOT an all-in price" in _T17_M_T
|
|
and _T17_M_S.replace("static_profile", TAXES_PROFILE_OPTION) != _T17_M_T)
|
|
check("...and both still name the ROW the operator has to go and fix, which is "
|
|
"the half they do share",
|
|
"periods[1].eur_per_kwh is missing" in _T17_M_S
|
|
and "periods[1].eur_per_kwh is missing" in _T17_M_T)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
|
sys.exit(1)
|
|
print("all checks passed")
|