checks / unit (pull_request) Successful in 3m35s
FCST-03's developer reverted `self.provisional` to `here.provisional`, re-ran, and reported the mutation as a survivor. It was not one. CPython validates a cached `.pyc` on (source size, source mtime truncated to whole seconds), and that edit moved neither number - same length, same second - so the interpreter loaded the bytecode it already had. Disassembly settled it at the time: the source read `here`, the running code loaded `self`. A mutation run had graded code that was not there, with no error and exit status 0. This does not make the results already on the record wrong. It makes them UNVERIFIED, and that distinction is the whole card - so nothing here re-runs a campaign and nothing here quietly assumes the old numbers hold. REPRODUCED, THEN CLOSED. test_bytecode.py builds a real package whose two return values are the same length, imports it in a real subprocess, rewrites it inside the same second (retrying until both writes land in one whole second - milliseconds, no sleep) and reads back which value ran. Part 1 shows the interpreter running SELF off a file that says HERE. AND THE TWO OBVIOUS MITIGATIONS DO NOT WORK. Measured, not read off documentation: `python3 -B` and PYTHONDONTWRITEBYTECODE=1 are write-side only. Neither stops an EXISTING stale .pyc being read, and a mutation run always has one, because the previous run wrote it. Part 2 grades both still running the old code. The advice in test_control.py's coverage audit led with `-B`; it is corrected here - the purge is the half that works, and `-B` earns its place by keeping THIS run from leaving a cache for the NEXT edit to inherit. NO SLEEP ANYWHERE. One-second mtime resolution is the mechanism, not the enemy. Waiting it out would slow every run down and would still only cover the edit somebody remembered to slow down. THE HARNESS. tools/mutation_run.sh purges, prints a sha256/byte fingerprint of every app/*.py it is about to grade - so a mutation report can say WHICH source produced it, which is the reviewer's addition to this card - runs each suite under `python3 -B`, refuses a tree with no test_*.py (CI-01's rule, for CI-01's reason), and fails if a cache appears during the run. It takes the tree as an argument so the infra repo's replay harnesses can call it without being moved. WHERE IT IS SAID (AC 2). MUTATION.md carries the mechanism, the measurement, the reporting rules and the survey, and every one of the 16 suites now points at it from its docstring. That list is DERIVED FROM THE TREE by test_bytecode.py part 5, so a new suite that joins this directory without the pointer is red - the same failure shape CI-01's file glob exists to prevent. CI WAS ALREADY IMMUNE, AND IS DESCRIBED THAT WAY (AC 4). Verified rather than reasoned: with the checkout bind mounted `-v "$WS:/w:ro"` the interpreter writes no __pycache__ into the tree at all, and it fails SILENTLY - no error, exit status 0 - while the same probe on a writable mount produces one. Each job also rm -rf's its workspace and clones afresh, so nothing crosses between jobs on the shared runner. PYTHONDONTWRITEBYTECODE=1 is added as belt and braces only: the `:ro` is one character wide and the next person debugging a write will be tempted by it. Part 6 grades both ON THE DOCKER COMMAND LINE rather than on the file - the first draft searched the whole workflow and stayed green after the flag was deleted, because the comment explaining it still named it. THE SHIPPED ADD-ON IS NOT AFFECTED and the question is closed: goodwe_controller/Dockerfile has set PYTHONDONTWRITEBYTECODE=1 since it was written. There is no deploy-side finding. THE SURVEY (AC 3), in MUTATION.md, scoped BY THE PROPERTY. The defect is one-directional - a mutation that never took leaves the suite green, so stale bytecode manufactures SURVIVORS and cannot manufacture a red - which puts every "all red, no survivors" claim in the window out of scope, along with mutations executed in-process (SAFETY-07's B1/B2/B3, test_peakguard parts 7 and 10, where no source file is edited) and everything run after the purge discipline landed on 2026-08-31 (SAFETY-24, SAFETY-26, OPT-04, FCST-03's re-run). What is left is the recorded SURVIVORS from source-edit runs before that date. Two were re-run here under the harness and both still stand: TARIFF-01's record_block() ignoring its policy argument, and SAFETY-03's abs() on the clamp limit - 15 suites green each, on the tree as it stood. The rest are labelled still-unverified rather than assumed or re-run; each is one harness run to close, and the moment to spend it is when somebody next touches that module. OUT OF SCOPE AND RECORDED: the infra repo's sim/ replay harnesses import the add-on and write bytecode into both trees (goodwe/sim/__pycache__ exists today), so SAFETY-02's headline replay carries the same exposure. mutation_run.sh takes a tree argument for exactly that, but the change belongs in that repo and some of those harnesses want the rig. RED AND GREEN. Every new check was broken and watched fail: the purge commented out of the harness turns part 4 red (and its non-vacuity probe already proves the fixture would mis-grade without it); one docstring pointer removed turns part 5 red naming the file; `:ro` and the `-e` flag removed from the workflow turn part 6 red. Full suite green, 16 files, in the CI image on a read-only mount - the shape the runner uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
869 lines
42 KiB
Python
869 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.
|
|
|
|
⚠️ 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 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")
|