checks / unit (pull_request) Successful in 2m43s
§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
862 lines
42 KiB
Python
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")
|