- TariffPlan.arbitrage(now=...) is required (keyword-only, no default); so are homeview.trade_verdict/refusal_key's now. Existing test calls pass one. - test_arbitrage.py: no module-level decide wrapper. Fixtures that probed a one-day plan after its extremes now hold two days (as a site does); the flap day gets a unique cheapest and dearest hour; the replay breaks its ties without direction. The flap bound is <= 4 and the replay check is len(_trades) < 365 again. 228 checks pass on the old and the new code. - test_price02.py part 6: ties (equal quarters, a real 15-minute day, alternating hourly prices) and the required now. - Identity pins: the masked digest is only for the older-tree comparison; this tree's own digests are pinned with nothing hidden. - DECISIONS: the tie analysis with numbers. Co-Authored-By: Claude <noreply@anthropic.com>
5997 lines
310 KiB
Python
5997 lines
310 KiB
Python
"""Runnable check for the price provider framework. `python3 test_price.py`
|
||
|
||
WHAT THIS FILE IS DEFENDING, in the order the ticket names it:
|
||
|
||
1. Every shipped plugin emits the EXACT canonical schema (§9.3), all-in.
|
||
2. Adding a plugin needs no optimiser change - so the framework must contain
|
||
no plugin name, and a plugin invented HERE, in this file, must reach the
|
||
right volatility regime without price.py being touched.
|
||
3. `manual_csv` works with no network at all.
|
||
4. The injection price is a separate scalar (§9.3).
|
||
5. "No price data" is a DIFFERENT OUTCOME from "prices, all of them zero".
|
||
|
||
TWO RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||
|
||
* EVERY VERDICT IS DERIVED. There is no `check("the schema is right", True)`
|
||
anywhere below. Where a check needs an expected number, the number is
|
||
computed here from the same inputs by different arithmetic than price.py
|
||
uses, and where it needs a set, the set is built from the objects rather
|
||
than typed out.
|
||
|
||
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE. That
|
||
is not decoration here, it is the main hazard in a markup test:
|
||
|
||
- markup and taxes must DIFFER and both be NON-ZERO, or "added markup
|
||
twice" and "added markup and taxes once each" produce the same number
|
||
and the double-counting check grades nothing. This file uses 0.031 /
|
||
0.057 for the arithmetic and asserts the non-degeneracy conditions as
|
||
checks in their own right rather than assuming them. ⚠️ Until TARIFF-18
|
||
the shipped pair was 0.03 and 0.06 - taxes EXACTLY twice the markup,
|
||
the one degeneracy that collapses "added the markup twice" into "added
|
||
both" - and that coincidence was the stated reason for the separate
|
||
constants. TARIFF-18 moved taxes to 0.11736 and ENDED it; the constants
|
||
stay, because a fixture that IS the shipped default cannot show a
|
||
reader which of the two a check really consumed.
|
||
- the raw price must be non-zero, or an all-in figure equals markup+taxes
|
||
and a plugin that dropped the energy component entirely still passes.
|
||
- a fixture must carry BOTH a provisional and a non-provisional point, or
|
||
`plan_is_provisional` returns the same answer under any bug.
|
||
- the DST checks run on the 23-hour and 25-hour days, not on a normal
|
||
24-hour day where a fixed-24-slot implementation and a correct one
|
||
agree exactly.
|
||
|
||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||
Read MUTATION.md before you report a survivor.
|
||
"""
|
||
|
||
import ast
|
||
import asyncio
|
||
import hashlib
|
||
import glob
|
||
import inspect
|
||
import io
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import sys
|
||
|
||
from named_absence import first, nth
|
||
import tempfile
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from textwrap import dedent
|
||
|
||
from app.price import (
|
||
ALL_IN, Absence, BANNED_IN_PLUGIN_CODE, Component, FrameworkOption,
|
||
RETIRED_PROVIDERS,
|
||
MAX_PLAUSIBLE_EUR_KWH, code_leaks,
|
||
FLEMISH_TAXES_COMPONENTS_EUR_KWH, PRICE_OPTION_DEFAULTS,
|
||
PROVIDERS, PluginOptions, PricePoint, PriceProduct,
|
||
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH,
|
||
PriceProvider, PriceRead, ProviderCapabilities, RawPoint, SourceUnavailable,
|
||
arbitrage_allowed, assemble, brussels, brussels_date_range,
|
||
brussels_offset, check_egress, dated_feed_url,
|
||
price_option,
|
||
export_formula, export_price_at,
|
||
export_price_eur_kwh, load_prices, plan_is_provisional, register,
|
||
taxes_at, taxes_profile,
|
||
volatility_regime, uncertainty_of, widen_factor, Uncertainty,
|
||
)
|
||
from app.custom_formula import (
|
||
CARD_TOLERANCE, EUR_KWH_PER_EUR_MWH, EXPORT_FORMULA_OPTION, FormulaInvalid,
|
||
INDEXES, INJECTION_OFFSET_MAX_EUR_KWH, PASSTHROUGH_HI, PASSTHROUGH_LO,
|
||
PLAUSIBLE_HI_EUR_KWH,
|
||
PLAUSIBLE_LO_EUR_KWH, RESOLUTIONS, UNITS, card_discrepancy,
|
||
implausible_all_in, implausible_injection, in_card_units,
|
||
parse_export_formula, parse_price_formula, preview_lines,
|
||
price_eur_kwh, stale_card, to_resolution)
|
||
from app import custom_formula as _cf
|
||
from app import price as _price
|
||
from app import arbitrage as _arb
|
||
from app import main as _main
|
||
from app.p1 import make_sample, split_signed
|
||
from app.static_tariff import (
|
||
TariffPlan, parse_taxes_profile, static_status)
|
||
from app.validate import validate_startup_config
|
||
|
||
|
||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||
# point these fixtures build, so no period has ended and the whole series counts.
|
||
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
||
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
||
fails = []
|
||
|
||
|
||
def check(name, cond):
|
||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||
if not cond:
|
||
fails.append(name)
|
||
|
||
|
||
def run(coro):
|
||
return asyncio.run(coro)
|
||
|
||
|
||
def _reason(fn):
|
||
"""The message fn() raised, so a check can assert WHICH rule refused.
|
||
|
||
`raises(...)` only says that something refused. M7 survived because two
|
||
different rules could refuse the same URL and the check could not tell them
|
||
apart - so where a check is named for a specific rule, it asserts on the
|
||
reason instead.
|
||
"""
|
||
try:
|
||
fn()
|
||
except Exception as e: # noqa: BLE001
|
||
return str(e)
|
||
return ""
|
||
|
||
|
||
def raises(fn, exc):
|
||
"""True if fn() raises exactly `exc`. A DIFFERENT exception is False, not
|
||
True - otherwise "it was refused" and "it broke on the way to being
|
||
refused" would grade the same, which is how M7 hid."""
|
||
try:
|
||
fn()
|
||
except exc:
|
||
return True
|
||
except Exception:
|
||
return False
|
||
return False
|
||
|
||
|
||
class _Raised:
|
||
"""A value a check wanted, whose computation raised.
|
||
|
||
Compares unequal to everything, is falsy, and prints what went wrong.
|
||
"""
|
||
|
||
def __init__(self, e):
|
||
self.e = e
|
||
|
||
def __eq__(self, other):
|
||
return False
|
||
|
||
def __hash__(self):
|
||
return id(self)
|
||
|
||
def __bool__(self):
|
||
return False
|
||
|
||
def __repr__(self):
|
||
return f"<raised {type(self.e).__name__}: {self.e}>"
|
||
|
||
|
||
def derived(fn):
|
||
"""Compute a value a check grades. An exception becomes `_Raised`.
|
||
|
||
⚠️ THIS IS NOT A DEFAULT, and the distinction is the one this whole ticket
|
||
is about. `_Raised` is not `[]`, not `None`, not 0; it equals nothing, it is
|
||
falsy, and it renders as the exception that produced it. Its only job is
|
||
that a mutation which makes a derivation IMPOSSIBLE - the reviewer removing
|
||
`_NoData`'s missing `.points`, say - reports as a failed check naming the
|
||
exception, instead of aborting this file before the sections below it run.
|
||
A silent `except: return []` here would be the defect under test, in the
|
||
harness that tests it.
|
||
"""
|
||
try:
|
||
return fn()
|
||
except Exception as e: # noqa: BLE001
|
||
return _Raised(e)
|
||
|
||
|
||
UTC = timezone.utc
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
# ⚠️ NOT the shipped pair, and deliberately so. See this module's docstring.
|
||
# Until TARIFF-18 the shipped pair was 0.03 / 0.06, where taxes are exactly
|
||
# 2 x the markup and several confusions collapse; TARIFF-18 moved taxes to
|
||
# 0.11736 and ended that particular degeneracy. These constants are kept
|
||
# because the reason outlives it: a fixture EQUAL to the shipped default cannot
|
||
# distinguish a check that read the fixture from one that read the default.
|
||
# Section 0's fourth check is what pins that, per figure, and the three
|
||
# degeneracy conditions are asserted below rather than assumed.
|
||
MARKUP = 0.031
|
||
TAXES = 0.057
|
||
|
||
|
||
def opts(**over):
|
||
"""A price configuration. Everything below is this, spoilt."""
|
||
o = {"price_provider": "off",
|
||
"price_markup_eur_kwh": MARKUP,
|
||
"price_taxes_eur_kwh": TAXES,
|
||
"price_timeout_s": 5}
|
||
o.update(over)
|
||
return o
|
||
|
||
|
||
def hours(n, start, step_h=1):
|
||
return [start + timedelta(hours=i * step_h) for i in range(n)]
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print("0. the arithmetic this file grades with must not be degenerate")
|
||
# ⚠️ These four ARE the guard against a check that passes for the wrong reason.
|
||
# If any of them goes red, every markup assertion below is measuring nothing.
|
||
check("markup and taxes are both non-zero", MARKUP != 0 and TAXES != 0)
|
||
check("...and differ, so 'added the wrong one' is visible",
|
||
MARKUP != TAXES)
|
||
check("...and markup twice != markup plus taxes, so double-counting the markup "
|
||
"is distinguishable from correctly adding both",
|
||
abs(2 * MARKUP - (MARKUP + TAXES)) > 1e-9)
|
||
# ⚠️ TARIFF-18 CHANGED THE ARGUMENT THIS CHECK MAKES, AND THE CHANGE IS
|
||
# RECORDED RATHER THAN QUIETLY DROPPED. Until TARIFF-18 the shipped pair was
|
||
# (0.03, 0.06) - taxes EXACTLY twice the markup - which is the one degeneracy
|
||
# that makes "added the markup twice" indistinguishable from "added both", and
|
||
# this check named that coincidence as the reason the file grades with its own
|
||
# constants. Raising the taxes default to 0.11736 ENDS the coincidence, so that
|
||
# sentence is no longer true and is not kept. What the file needs from the
|
||
# shipped pair is weaker and is now asserted directly: each shipped figure must
|
||
# DIFFER from the constant this file grades with, or a check that read the
|
||
# shipped default instead of the fixture would pass by coincidence. Asserted
|
||
# per-figure and not as a tuple - a tuple comparison is satisfied by one of the
|
||
# two matching.
|
||
check(f"...and NEITHER shipped default is what this file grades with: shipped "
|
||
f"({PRICE_OPTION_DEFAULTS['price_markup_eur_kwh']}, "
|
||
f"{PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']}) against this file's "
|
||
f"({MARKUP}, {TAXES})",
|
||
MARKUP != PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"]
|
||
and TAXES != PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("1. the shipped defaults live in config.yaml, and here only as a mirror")
|
||
# ⚠️ CFG-03. The add-on already carries option defaults in three places. This
|
||
# ticket adds ONE in-code mirror (PRICE_OPTION_DEFAULTS) and pins it to
|
||
# config.yaml by reading the file rather than by transcribing it - the same
|
||
# move test_validate.py makes for max_w and meter_freeze_s. If they drift, this
|
||
# goes red and somebody has to decide which is right.
|
||
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||
with open(_CFG, "r", encoding="utf-8") as fh:
|
||
_cfg_lines = fh.read().splitlines()
|
||
|
||
|
||
def shipped(key):
|
||
"""The value config.yaml ships, from the `options:` block, parsed by hand.
|
||
|
||
No yaml module in the shipped image, and this is two lines of parsing
|
||
against a file whose shape is known - the alternative is a transcription,
|
||
which is the thing being guarded against.
|
||
"""
|
||
in_schema = False
|
||
for line in _cfg_lines:
|
||
if line.startswith("schema:"):
|
||
in_schema = True
|
||
if in_schema or not line.startswith(f" {key}:"):
|
||
continue
|
||
raw = line.split(":", 1)[1].split("#", 1)[0].strip().strip('"').strip("'")
|
||
# OPT-04 added the first BOOLEAN price option, and without this the
|
||
# mirror check would compare the string "false" against `False` and go
|
||
# red for a file that is perfectly in step. Parsing a bool as a bool is
|
||
# what the yaml module this image does not carry would have done; it
|
||
# widens nothing, because a key whose shipped value disagrees with the
|
||
# mirror still drifts.
|
||
if raw in ("true", "false"):
|
||
return raw == "true"
|
||
try:
|
||
return int(raw)
|
||
except ValueError:
|
||
pass
|
||
try:
|
||
return float(raw)
|
||
except ValueError:
|
||
return raw
|
||
return KeyError
|
||
|
||
|
||
_found = {k: shipped(k) for k in PRICE_OPTION_DEFAULTS}
|
||
_missing = sorted(k for k, v in _found.items() if v is KeyError)
|
||
check(f"config.yaml ships every price option ({len(PRICE_OPTION_DEFAULTS)} keys)",
|
||
_missing == [])
|
||
_drift = sorted(k for k, v in _found.items()
|
||
if v is not KeyError and v != PRICE_OPTION_DEFAULTS[k])
|
||
check(f"...and each shipped default equals the in-code mirror {_found}",
|
||
_drift == [])
|
||
# The schema block must list them too, or the Supervisor drops the option and
|
||
# the add-on silently runs on the mirror's value instead of the operator's.
|
||
_schema_block = _cfg_lines[_cfg_lines.index("schema:"):]
|
||
_unschemad = sorted(k for k in PRICE_OPTION_DEFAULTS
|
||
if not any(ln.startswith(f" {k}:") for ln in _schema_block))
|
||
check("...and every one of them is in the schema block too", _unschemad == [])
|
||
# ⚠️ Derived from the registry, not typed: the schema's provider list must offer
|
||
# exactly the plugins that exist PLUS the retired ids, or the UI and the code
|
||
# disagree about what is installable.
|
||
#
|
||
# ⚠️ THE RETIRED IDS ARE IN THE SCHEMA ON PURPOSE AND THIS IS THE CHECK THAT
|
||
# KEEPS THEM THERE (TARIFF-07). The Supervisor validates the options file
|
||
# against this line BEFORE the add-on runs. Drop a retired id from it and an
|
||
# EXISTING config naming that id becomes schema-invalid: the operator gets an
|
||
# opaque "expected one of ..." and validate.py's retired-id refusal - the whole
|
||
# point of the withdrawal - is never reached, on exactly the sites that need
|
||
# it. So the schema is deliberately WIDER than the registry, by exactly
|
||
# RETIRED_PROVIDERS and by nothing else. Both halves are graded: an id that is
|
||
# in neither set, and a retired id that has gone missing.
|
||
_line = first((ln for ln in _schema_block if ln.startswith(" price_provider:")),
|
||
"price_provider in the schema")
|
||
_schema_ids = (set(_line.split("list(", 1)[1].rstrip(")").split("|"))
|
||
if "list(" in _line else set())
|
||
_wanted = set(PROVIDERS) | set(RETIRED_PROVIDERS) | {"off"}
|
||
check(f"the schema offers exactly the registered plugins, the retired ids and "
|
||
f"'off' {sorted(_schema_ids)}",
|
||
_schema_ids == _wanted)
|
||
check(f"...and every retired id is STILL offered, so a config already naming "
|
||
f"one reaches validate.py rather than the Supervisor's schema error "
|
||
f"{sorted(set(RETIRED_PROVIDERS) - _schema_ids) or 'none missing'}",
|
||
set(RETIRED_PROVIDERS) <= _schema_ids and set(RETIRED_PROVIDERS) != set())
|
||
check("...and no retired id is ALSO a live plugin, which would silently take "
|
||
"that refusal away",
|
||
set(RETIRED_PROVIDERS) & set(PROVIDERS) == set())
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("1b. TARIFF-18 - the shipped taxes default is a SUM OF NAMED CARD ROWS")
|
||
# ⚠️ THE DEFECT THIS SECTION PREVENTS IS THE ONE THE SHIPPED 0.06 WAS: A NUMBER
|
||
# WITH NO PROVENANCE. 0.06 EUR/kWh is roughly HALF the real Flemish figure, and
|
||
# nobody could argue with it because there was nothing written down to argue
|
||
# against - no rows, no card, no date. Section 1 above pins the default against
|
||
# config.yaml, which catches DRIFT between the two copies and says nothing
|
||
# whatever about whether the number is right. This section grades the number.
|
||
#
|
||
# ⚠️ AND IT GRADES IT FROM TWO INDEPENDENT SIDES, because the module's rows and
|
||
# the module's default are both in app/price.py and an edit that moved BOTH
|
||
# would satisfy a check that only re-added the rows (MUTATION.md: "where did
|
||
# each side of this comparison come from"). So: the default is re-added from
|
||
# the shipped rows, AND the total is compared against the card's own figures
|
||
# TYPED HERE, in the units the card prints them in.
|
||
print(" app/price.py's rows, c EUR/kWh incl. 6 % VAT:")
|
||
for _name, _v in FLEMISH_TAXES_COMPONENTS_EUR_KWH:
|
||
print(f" {_v * 100:>7.3f} {_name}")
|
||
_T18_SUM = sum(v for _n, v in FLEMISH_TAXES_COMPONENTS_EUR_KWH)
|
||
_T18_DEFAULT = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
|
||
print(f" {_T18_SUM * 100:>7.3f} = the shipped default {_T18_DEFAULT}")
|
||
|
||
check(f"the {len(FLEMISH_TAXES_COMPONENTS_EUR_KWH)} rows re-add to the shipped "
|
||
f"default: {round(_T18_SUM, 6)} against {_T18_DEFAULT}",
|
||
abs(_T18_SUM - _T18_DEFAULT) < 1e-12)
|
||
|
||
# Side two: the Eneco residential card for Flanders, September 2026, in the
|
||
# c EUR/kWh it prints. Typed here, so an edit inside app/price.py cannot move
|
||
# both sides of a comparison at once.
|
||
_T18_CARD_C = (5.35 # Fluvius distribution, Antwerpen
|
||
+ 4.876 # bijzondere accijns, "Alle verbruik"
|
||
+ 1.51 # bijdrage groene stroom en WKK Vlaanderen
|
||
+ 0.0) # energiebijdrage, 0 EUR/MWh since 2026-08-01
|
||
check(f"...and the card itself, typed in this file in c EUR/kWh, totals "
|
||
f"{_T18_CARD_C:.3f} c = {_T18_CARD_C / 100:.5f} EUR/kWh, which IS the "
|
||
f"shipped default to the card's own last digit",
|
||
abs(_T18_CARD_C / 100.0 - _T18_DEFAULT) < 1e-9)
|
||
|
||
# ⚠️ THE ZERO ROW IS A TRAP MARKER AND IS ASSERTED AS ONE. "Vanaf 1 augustus
|
||
# 2026 bedraagt de energiebijdrage 0 EUR/MWh" - it was merged into the
|
||
# bijzondere accijns. Anybody re-deriving this total from a guide written
|
||
# before that date finds a separate energiebijdrage line and adds it to an
|
||
# accijns that already contains it. Deleting the row is the edit that lets that
|
||
# happen silently, so the row's PRESENCE is graded and not only the sum.
|
||
_T18_NAMES = [n.lower() for n, _v in FLEMISH_TAXES_COMPONENTS_EUR_KWH]
|
||
check("the energiebijdrage is carried as a row worth ZERO rather than left "
|
||
"out, so the next person to re-derive this total finds it already "
|
||
"accounted for instead of adding it a second time",
|
||
any("energiebijdrage" in n for n in _T18_NAMES)
|
||
and all(v == 0.0 for n, v in FLEMISH_TAXES_COMPONENTS_EUR_KWH
|
||
if "energiebijdrage" in n.lower()))
|
||
check("...and the three rows that are NOT zero are distinct non-zero figures, "
|
||
"so the sum above is not being carried by one row with the others inert",
|
||
len({v for _n, v in FLEMISH_TAXES_COMPONENTS_EUR_KWH if v}) == 3)
|
||
|
||
# ⚠️ THE PER-DSO BAND, AND THE DIRECTION THE DEFAULT ERRS IN. Only the
|
||
# distribution row moves between Fluvius DSOs - 5,28 c EUR/kWh in
|
||
# Midden-Vlaanderen to 6,70 in West - so the all-in Flemish total runs about
|
||
# 0.117 to 0.131 EUR/kWh. The default must be INSIDE that band and in its lower
|
||
# half: this scalar is inert on which hour is cheapest (section 16b) but §17's
|
||
# negative-price guard reads the ABSOLUTE all-in price, and a figure set too
|
||
# high carries a genuinely negative hour above zero. test_arbitrage.py 2c
|
||
# drives that boundary through the shipped decision.
|
||
_T18_LO, _T18_HI = 0.117, 0.131
|
||
check(f"the default {_T18_DEFAULT} is inside the Flemish per-DSO band "
|
||
f"{_T18_LO}-{_T18_HI} EUR/kWh, and in its LOWER HALF, which is the "
|
||
f"direction \u00a717's guard makes safe",
|
||
_T18_LO <= _T18_DEFAULT <= (_T18_LO + _T18_HI) / 2)
|
||
# ⚠️ READ DEFENSIVELY ON PURPOSE. Emptying the superseded tuple must produce a
|
||
# NAMED red, not an IndexError inside this f-string - a crash takes the file
|
||
# red with nothing on the record saying which claim broke, which is MUTATION.md
|
||
# reporting rule 3 in the negative.
|
||
_T18_OLD = (SUPERSEDED_TAXES_DEFAULTS_EUR_KWH[0]
|
||
if SUPERSEDED_TAXES_DEFAULTS_EUR_KWH else None)
|
||
check(f"...and it is no longer the superseded {_T18_OLD}, which sat at "
|
||
f"{(_T18_OLD / _T18_DEFAULT) if _T18_OLD else 0:.0%} of the Flemish "
|
||
f"figure - about half of what the operator actually pays",
|
||
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH == (0.06,)
|
||
and all(abs(_T18_DEFAULT - u) > 1e-9
|
||
for u in SUPERSEDED_TAXES_DEFAULTS_EUR_KWH))
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("2. every shipped plugin emits the exact canonical schema (AC1)")
|
||
|
||
# One recorded fixture per plugin, none of them a real fetch. Prices are
|
||
# deliberately distinct, non-zero, and one of them NEGATIVE - §17 keeps a
|
||
# negative-price regression case, and a windy Sunday is the best charging hour
|
||
# of the year, so a framework that clamped it away would cost real money.
|
||
T0 = datetime(2026, 1, 15, 0, 0, tzinfo=UTC)
|
||
# ⚠️ THE NEGATIVE HOUR IS -95 EUR/MWh, NOT -12.5, AND THE MAGNITUDE IS THE
|
||
# POINT. All-in adds 0.088 EUR/kWh here, so a -12.5 EUR/MWh hour comes out at
|
||
# +0.0755 and the "a negative price survives" check below would be grading a
|
||
# positive number - passing whether or not the framework clamped anything.
|
||
# -95 EUR/MWh is both realistic for a windy Belgian Sunday and still negative
|
||
# after markup and taxes, which is the only version of the stimulus that grades
|
||
# what it claims to.
|
||
EPEX_EUR_MWH = [61.4, -95.0, 183.0] # EUR/MWh, one deeply negative
|
||
RETAIL_EUR_KWH = [0.1341, 0.0872, 0.2610] # EUR/kWh, markup already inside
|
||
|
||
|
||
# ⚠️ THE INVENTED ROW-PER-POINT FIXTURE IS GONE, AND SO IS ITS PARSER
|
||
# (TARIFF-07). Until this card every price fixture here was a
|
||
# `{"data": [{"start": ..., "price_eur_mwh": ...}]}` document that
|
||
# `_points_from_json()` was written to read and that NO FEED ANYBODY REACHED
|
||
# HAS EVER PUBLISHED. The three plugins fed from it - `engie_dynamic`,
|
||
# `totalenergies_dynamic`, `eneco_eddy` - had no captured body because their
|
||
# endpoints do not exist, so the loop was closed twice over: an invented parser
|
||
# graded against an invented body, on behalf of plugins that could never fetch.
|
||
# Both plugins that remain are replayed from something real - `generic_dayahead`
|
||
# from captured bytes (section 2b), `manual_csv` from the CSV a human types.
|
||
#
|
||
# The shape itself survives in ONE place and in one direction only: as the body
|
||
# section 9 feeds `generic_dayahead` to prove that a row-per-point document is a
|
||
# FAILURE for it rather than a second dialect it might sniff. It is named for
|
||
# what it is.
|
||
|
||
|
||
def row_shape_body(key, values):
|
||
"""A `{"data": [...]}` document. ⚠️ EXISTS ONLY TO BE REFUSED.
|
||
|
||
No feed this repository has ever reached publishes this, and the parser
|
||
that read it was withdrawn with the three plugins it served (TARIFF-07).
|
||
It is kept as a NEGATIVE stimulus: if `_points_from_columnar_json()` ever
|
||
grows a sniff for `data`, section 9's "the body is the OTHER shape" row
|
||
turns from `no_data` into a series and a check goes red, rather than
|
||
TARIFF-08's scope rule being widened by a review nobody remembers.
|
||
"""
|
||
return json.dumps({"data": [
|
||
{"start": (T0 + timedelta(hours=i)).isoformat(), key: v,
|
||
"provisional": False}
|
||
for i, v in enumerate(values)]}).encode()
|
||
|
||
|
||
|
||
def columnar_feed(values, provisional_at=None, unit="EUR / MWh",
|
||
step_min=60, start=None, prices=None):
|
||
"""A body in the shape `api.energy-charts.info` really publishes.
|
||
|
||
Parallel arrays and no per-point object; `unix_seconds` are UTC epoch
|
||
seconds. `prices` overrides the price column outright, which is how the
|
||
unequal-length case below is built without hand-writing JSON.
|
||
|
||
⚠️ THERE IS NO PER-POINT `provisional` IN THIS SHAPE, so `provisional_at`
|
||
exists only to be REFUSED - the columnar document cannot carry the flag and
|
||
the plugin supplies it. It is a keyword the callers do not pass; the
|
||
parameter is here so that a future caller who reaches for it gets a
|
||
TypeError rather than a silently ignored argument.
|
||
"""
|
||
if provisional_at is not None:
|
||
raise TypeError(
|
||
"the columnar document has no per-point provisional flag; "
|
||
"`generic_dayahead` declares it in fetch(). See "
|
||
"_points_from_columnar_json().")
|
||
t0 = start if start is not None else T0
|
||
secs = [int((t0 + timedelta(minutes=step_min * i)).timestamp())
|
||
for i in range(len(values))]
|
||
doc = {"unix_seconds": secs,
|
||
"price": list(values) if prices is None else list(prices),
|
||
"unit": unit,
|
||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||
"from Bundesnetzagentur | SMARD.de",
|
||
"deprecated": False}
|
||
return json.dumps(doc).encode()
|
||
|
||
|
||
# TARIFF-15. THE TWO REAL TARIFF CARDS, as the operator would type them.
|
||
# ⚠️ THESE ARE NOT INVENTED. Both suppliers print the formula, the reference
|
||
# index they assumed AND the resulting price on the same card, so section 13
|
||
# can grade the SHIPPED code against a number somebody else published - which
|
||
# is the only kind of expectation this file is allowed to have (2b's rule,
|
||
# applied to arithmetic rather than to bytes).
|
||
ENECO_CARD = {"index": "epex_be", "resolution": "quarter_hourly",
|
||
"factor": 0.102, "offset": 1.00, "units": "c_eur_per_kwh",
|
||
"vat_pct": 6,
|
||
"card": {"month": "2026-09", "index_eur_per_mwh": 133.9905,
|
||
"expect_incl_vat": 15.55}}
|
||
ENGIE_CARD = {"index": "epex_be", "resolution": "quarter_hourly",
|
||
"factor": 0.100, "offset": 0.90, "units": "c_eur_per_kwh",
|
||
"vat_pct": 6,
|
||
"card": {"month": "2026-09", "index_eur_per_mwh": 61.96,
|
||
"expect_incl_vat": 7.522}}
|
||
# The energy+markup component of the shared EPEX fixture under Eneco's card,
|
||
# spelled out here as the card spells it: c EUR/kWh, then /100 to EUR/kWh.
|
||
FORMULA_EUR_KWH = [(0.102 * v + 1.00) * 1.06 / 100.0 for v in EPEX_EUR_MWH]
|
||
|
||
|
||
CSV_BODY = ("start,eur_per_kwh,provisional\n"
|
||
"2026-01-15T00:00:00+01:00,0.1834,false\n"
|
||
"2026-01-15T01:00:00+01:00,-0.0210,true\n"
|
||
"2026-01-15T02:00:00+01:00,0.3115,false\n")
|
||
|
||
# The fixture, the transport that serves it, and - crucially - the ENERGY
|
||
# component of each point, computed here so the all-in expectation below is
|
||
# arithmetic this file did itself.
|
||
# ⚠️ `generic_dayahead` IS FED THE COLUMNAR SHAPE AND THE OTHER THREE ARE NOT,
|
||
# and the asymmetry is the honest one rather than an oversight: the columnar
|
||
# document is what the one endpoint anybody has ever reached actually returns.
|
||
FIXTURES = {
|
||
"generic_dayahead": (columnar_feed(EPEX_EUR_MWH),
|
||
[v / 1000 for v in EPEX_EUR_MWH]),
|
||
"custom_formula": (columnar_feed(EPEX_EUR_MWH), FORMULA_EUR_KWH),
|
||
"manual_csv": (None, [0.1834, -0.0210, 0.3115]),
|
||
# PRICE-01: the owner's price server answers the same columnar layout.
|
||
"price_service": (columnar_feed(EPEX_EUR_MWH),
|
||
[v / 1000 for v in EPEX_EUR_MWH]),
|
||
}
|
||
|
||
check(f"the plugins that ship after TARIFF-07 and TARIFF-15 are registered "
|
||
f"{sorted(PROVIDERS)}",
|
||
set(PROVIDERS) == {"generic_dayahead", "custom_formula", "manual_csv",
|
||
"price_service"})
|
||
check(f"...and the three withdrawn ids resolve to NOTHING "
|
||
f"{sorted(RETIRED_PROVIDERS)}",
|
||
set(RETIRED_PROVIDERS) == {"engie_dynamic", "totalenergies_dynamic",
|
||
"eneco_eddy"}
|
||
and not (set(RETIRED_PROVIDERS) & set(PROVIDERS)))
|
||
def _readd_retired():
|
||
return register(type("_ReAdd", (PriceProvider,), {
|
||
"id": "eneco_eddy",
|
||
"capabilities": classmethod(lambda c: ProviderCapabilities(
|
||
product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY}))),
|
||
"fetch": lambda s, o, a, b, t: None}))
|
||
|
||
|
||
# ⚠️ GRADED ON THE REASON, NOT THE VERDICT. `register()` already refuses a
|
||
# DUPLICATE id, so "it raised ValueError" would pass on a tree where the
|
||
# retired guard is deleted and the id happens to be live again - the masking
|
||
# shape MUTATION.md's TEST-16 section is about.
|
||
_readd_why = _reason(_readd_retired)
|
||
check(f"re-registering a retired id is refused, and refused for the RETIRED "
|
||
f"reason rather than the duplicate one ({_readd_why[:60]!r})",
|
||
"RETIRED_PROVIDERS" in _readd_why and "eneco_eddy" in _readd_why)
|
||
check("...and the refusal did not take effect anyway",
|
||
"eneco_eddy" not in PROVIDERS)
|
||
check("...and the fixture set covers every one of them",
|
||
set(FIXTURES) == set(PROVIDERS))
|
||
|
||
|
||
def canned(body):
|
||
"""A transport that serves recorded bytes and records that it was used."""
|
||
calls = []
|
||
|
||
async def t(url, timeout_s, headers=None):
|
||
calls.append((url, timeout_s))
|
||
return body
|
||
t.calls = calls
|
||
return t
|
||
|
||
|
||
def load(pid, extra=None, transport=None):
|
||
o = opts(price_provider=pid, **(extra or {}))
|
||
return run(load_prices(o, T0, T0 + timedelta(hours=48),
|
||
transport=transport))
|
||
|
||
|
||
import tempfile # noqa: E402
|
||
|
||
_csv_dir = tempfile.mkdtemp(prefix="tariff02-")
|
||
CSV_PATH = os.path.join(_csv_dir, "prices.csv")
|
||
with open(CSV_PATH, "w", encoding="utf-8") as fh:
|
||
fh.write(CSV_BODY)
|
||
|
||
|
||
def load_any(pid):
|
||
body, _ = FIXTURES[pid]
|
||
if pid == "manual_csv":
|
||
return load(pid, {"price_csv_path": CSV_PATH}, transport=refuse_transport)
|
||
if pid == "custom_formula":
|
||
# ⚠️ THE SAME BODY AS generic_dayahead, ON PURPOSE. Both read the same
|
||
# feed, so feeding them different bytes would hide the fact that what
|
||
# differs between them is the ARITHMETIC and the declaration - which
|
||
# is exactly what section 3 grades.
|
||
return load(pid, {"price_formula": json.dumps(ENECO_CARD)},
|
||
transport=canned(body))
|
||
if pid == "price_service":
|
||
return load(pid, {"price_service_token": "test-token-1234"},
|
||
transport=canned(body))
|
||
return load(pid, transport=canned(body))
|
||
|
||
|
||
async def _refuse(url, timeout_s):
|
||
raise AssertionError(f"the offline plugin reached the network: {url}")
|
||
|
||
refuse_transport = _refuse
|
||
|
||
READS = {pid: load_any(pid) for pid in PROVIDERS}
|
||
|
||
_wrong_kind = sorted(p for p, r in READS.items() if r.kind != "series")
|
||
check(f"every plugin returned a series from its fixture", _wrong_kind == [])
|
||
|
||
# THE SCHEMA. Exactly three keys, in every point of every plugin, and the check
|
||
# derives the key set from the objects rather than asserting a typed literal.
|
||
_keys = derived(lambda: {frozenset(p.keys())
|
||
for r in READS.values()
|
||
for p in r.canonical()["prices"]})
|
||
check(f"every canonical point has exactly the three §9.3 keys, and only those "
|
||
f"-> {_keys}",
|
||
_keys == {frozenset({"start", "eur_per_kwh", "provisional"})})
|
||
check("the envelope is exactly {'prices': [...]}",
|
||
derived(lambda: all(set(r.canonical()) == {"prices"}
|
||
for r in READS.values())) is True)
|
||
_badtype = derived(lambda: sorted(
|
||
p for p, r in READS.items()
|
||
if not all(isinstance(pt["eur_per_kwh"], float)
|
||
and isinstance(pt["provisional"], bool)
|
||
and isinstance(pt["start"], str)
|
||
for pt in r.canonical()["prices"])))
|
||
check(f"...with a float price, a bool flag and an ISO string start {_badtype}",
|
||
_badtype == [])
|
||
_naive = derived(lambda: sorted(p for p, r in READS.items()
|
||
if any(pt.start.utcoffset() is None
|
||
for pt in r.points)))
|
||
check(f"every start is timezone-aware (§9.3 shows +01:00) {_naive}",
|
||
_naive == [])
|
||
_offsets = derived(lambda: {pt.start.utcoffset()
|
||
for r in READS.values() for pt in r.points})
|
||
check(f"...and at a Belgian winter offset for a January fixture {_offsets}",
|
||
_offsets == {timedelta(hours=1)})
|
||
check("the whole envelope round-trips through json.dumps",
|
||
derived(lambda: all(json.loads(json.dumps(r.canonical())) == r.canonical()
|
||
for r in READS.values())) is True)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("2b. TARIFF-08 - the SHIPPED parser, replayed against REAL captured "
|
||
"bodies")
|
||
#
|
||
# ⚠️ WHY THIS SECTION EXISTS, WHICH IS THE MORE IMPORTANT HALF OF THE TICKET.
|
||
# Until 2026-09-10 every price fixture in this repository - here, in
|
||
# test_price_history.py and in test_static_tariff.py - was a
|
||
# `{"data": [{"start": ..., "price_eur_mwh": ...}]}` document invented by
|
||
# whoever wrote `_points_from_json()`. `api.energy-charts.info` has never
|
||
# published that shape. So the suite validated the parser against the parser's
|
||
# own assumption: a closed loop that cannot notice the assumption is wrong, and
|
||
# it stayed green while `generic_dayahead` raised `SourceUnavailable` on every
|
||
# live fetch it ever made. §11.4 then made that silent - a parse error becomes
|
||
# `no_data` becomes the static profile with arbitrage off, which is the CORRECT
|
||
# degradation and says nothing at all about the cause.
|
||
#
|
||
# The only thing that breaks the loop is bytes nobody here wrote. Both fixtures
|
||
# below are unmodified response bodies, captured by hand with curl on
|
||
# 2026-09-10; `scenarios/energy-charts-be-price-captures.json` records the URL,
|
||
# the retrieval time, the licence as returned and a sha256 of each, and the
|
||
# checksums are RE-DERIVED here so a fixture edited to make a check pass stops
|
||
# being a capture and starts being a failure.
|
||
#
|
||
# ⚠️ THERE IS NOW EXACTLY ONE NETWORKED PLUGIN, WHICH IS TARIFF-07's DOING.
|
||
# `engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy` had no capture
|
||
# because their endpoints do not exist - verified by hand on 2026-09-13: a 404
|
||
# from a gateway with no route, a TLS hostname mismatch, and a 404 with an empty
|
||
# body. They were withdrawn rather than left fed by an invented fixture. The
|
||
# check below is derived from the shipped `PROVIDERS` registry, so a plugin
|
||
# added with no capture forces the question rather than inheriting silence.
|
||
|
||
_CAPTURES = json.load(open(
|
||
os.path.join(HERE, "scenarios", "energy-charts-be-price-captures.json"),
|
||
encoding="utf-8"))
|
||
|
||
|
||
def _capture(name):
|
||
with open(os.path.join(HERE, "scenarios", name), "rb") as fh:
|
||
return fh.read()
|
||
|
||
|
||
_REAL = {n: _capture(n) for n in _CAPTURES["files"]}
|
||
_bad_sha = sorted(n for n, b in _REAL.items()
|
||
if hashlib.sha256(b).hexdigest()
|
||
!= _CAPTURES["files"][n]["sha256"])
|
||
check(f"all three captured bodies are the bytes the provenance file "
|
||
f"recorded {sorted(_REAL)}",
|
||
len(_REAL) == 3 and _bad_sha == [])
|
||
|
||
# The document really is columnar, derived from the bytes rather than asserted:
|
||
# if it ever grows a `data` key this check says so before any parser does.
|
||
_shapes = {n: sorted(json.loads(b.decode("utf-8"))) for n, b in _REAL.items()}
|
||
check(f"...and every one is COLUMNAR - parallel arrays, no 'data' key "
|
||
f"{sorted({tuple(v) for v in _shapes.values()})}",
|
||
all(v == ["deprecated", "license_info", "price", "unit", "unix_seconds"]
|
||
for v in _shapes.values()))
|
||
check("...which is precisely the shape the shipped parser used to demand a "
|
||
"'data' list from",
|
||
all("data" not in v for v in _shapes.values()))
|
||
|
||
# ⚠️ CC BY 4.0 IS WHY THESE BYTES MAY BE IN THE REPOSITORY AT ALL. DATA-02
|
||
# established that the BE zone is redistributable with attribution; the feed
|
||
# restates the licence per response, so a capture whose own licence line has
|
||
# changed is a capture that must not stay committed.
|
||
_lic = {n: json.loads(b.decode("utf-8"))["license_info"] for n, b in _REAL.items()}
|
||
check(f"...and each carries the CC BY 4.0 line the provenance block cites",
|
||
all("CC BY 4.0" in v for v in _lic.values())
|
||
and all(_lic[n] == _CAPTURES["files"][n]["licence_as_returned"]
|
||
for n in _REAL))
|
||
|
||
_QH = "energy-charts-be-price-2026-09-10-quarterhourly.json"
|
||
_H1 = "energy-charts-be-price-2025-09-10-hourly.json"
|
||
|
||
# ⚠️ THE TWO GRANULARITIES ARE NOT DECORATION. Belgium moved to a 15-minute MTU
|
||
# on 2025-10-01 and the same endpoint answers 96 points at 900 s for a day after
|
||
# it and 24 at 3600 s for a day before it. A fixture at one spacing cannot grade
|
||
# whether anything assumes the other, so both are replayed and the spacings are
|
||
# DERIVED from the bodies rather than typed.
|
||
_spacing = {}
|
||
for _n, _b in _REAL.items():
|
||
_u = json.loads(_b.decode("utf-8"))["unix_seconds"]
|
||
_spacing[_n] = sorted({_u[i + 1] - _u[i] for i in range(len(_u) - 1)})
|
||
check(f"the two captures really differ in granularity - {_spacing[_QH]} s over "
|
||
f"{len(json.loads(_REAL[_QH])['price'])} points and {_spacing[_H1]} s "
|
||
f"over {len(json.loads(_REAL[_H1])['price'])} points - or replaying both "
|
||
f"grades nothing more than replaying one",
|
||
_spacing[_QH] == [900] and _spacing[_H1] == [3600])
|
||
|
||
_REPLAY = {n: load("generic_dayahead", transport=canned(b))
|
||
for n, b in _REAL.items()}
|
||
_not_series = sorted(n for n, r in _REPLAY.items() if r.kind != "series")
|
||
check(f"the SHIPPED generic_dayahead parses both real bodies "
|
||
f"{[r.words for r in _REPLAY.values()]}",
|
||
_not_series == [])
|
||
|
||
# The point count is the source's own interval count, taken from the document.
|
||
_counts = derived(lambda: {n: len(r.points) for n, r in _REPLAY.items()})
|
||
_want_counts = {n: len(json.loads(b.decode("utf-8"))["price"])
|
||
for n, b in _REAL.items()}
|
||
check(f"...emitting one point per published interval, no decimation "
|
||
f"{_counts}", _counts == _want_counts)
|
||
check(f"...which for the quarter-hourly body is 96 points and not 24 - the "
|
||
f"15-minute MTU is the shape of the real feed, not a hypothetical",
|
||
_want_counts[_QH] == 96 and _want_counts[_H1] == 24)
|
||
|
||
# §9.3: unix_seconds are UTC instants, RENDERED at the Brussels offset. Both
|
||
# captured days are inside CEST, so the correct first start is local midnight at
|
||
# +02:00. ⚠️ A NAIVE READING - `datetime.fromtimestamp(t)` with no tzinfo - is
|
||
# what this grades: it would produce the host's local wall clock, and on a UTC
|
||
# host that is 22:00 of the previous day. The expectation is computed here from
|
||
# the raw epoch second by different arithmetic than price.py uses.
|
||
_first_raw = {n: json.loads(b.decode("utf-8"))["unix_seconds"][0]
|
||
for n, b in _REAL.items()}
|
||
_first_utc = {n: datetime(1970, 1, 1, tzinfo=UTC) + timedelta(seconds=t)
|
||
for n, t in _first_raw.items()}
|
||
_first_got = derived(lambda: {n: r.points[0].start
|
||
for n, r in _REPLAY.items()})
|
||
check(f"every start is the UTC instant the feed published "
|
||
f"{ {n: v.isoformat() for n, v in _first_utc.items()} }",
|
||
isinstance(_first_got, dict)
|
||
and all(_first_got[n] == _first_utc[n] for n in _REAL))
|
||
check(f"...rendered at the Brussels offset, so a September day starts at "
|
||
f"00:00+02:00 and NOT at the 22:00 a naive local reading gives "
|
||
f"{ {n: v.isoformat() for n, v in (_first_got if isinstance(_first_got, dict) else {}).items()} }",
|
||
isinstance(_first_got, dict)
|
||
and all(v.utcoffset() == timedelta(hours=2) for v in _first_got.values())
|
||
and all((v.hour, v.minute) == (0, 0) for v in _first_got.values()))
|
||
|
||
# EUR/MWh -> all-in EUR/kWh, recomputed here from the raw column.
|
||
_allin_want = {n: [round(v / 1000 + MARKUP + TAXES, 6)
|
||
for v in json.loads(b.decode("utf-8"))["price"]]
|
||
for n, b in _REAL.items()}
|
||
_allin_got = derived(lambda: {n: [p.eur_per_kwh for p in r.points]
|
||
for n, r in _REPLAY.items()})
|
||
check(f"...and every price is the published EUR/MWh figure over 1000 plus "
|
||
f"markup and taxes (first: {[v[0] for v in _allin_want.values()]})",
|
||
_allin_got == _allin_want)
|
||
# ⚠️ NON-DEGENERACY. If the captured prices happened to be all equal, the
|
||
# arithmetic check above would pass under a parser that emitted the same number
|
||
# for every interval.
|
||
_raw_spread = {n: round(max(v) - min(v), 6)
|
||
for n, v in ((k, json.loads(b.decode("utf-8"))["price"])
|
||
for k, b in _REAL.items())}
|
||
check(f"...on bodies whose prices actually vary, or that check grades a "
|
||
f"constant {_raw_spread}",
|
||
all(v > 1.0 for v in _raw_spread.values()))
|
||
|
||
|
||
# --- the refusals, driven from the real body by breaking one thing ---------- #
|
||
|
||
def _edited(name, **fields):
|
||
"""The captured body with one field replaced. Everything else is real."""
|
||
doc = json.loads(_REAL[name].decode("utf-8"))
|
||
doc.update(fields)
|
||
return json.dumps(doc).encode()
|
||
|
||
|
||
_full = json.loads(_REAL[_QH].decode("utf-8"))
|
||
_short = load("generic_dayahead",
|
||
transport=canned(_edited(_QH, price=_full["price"][:-4])))
|
||
check(f"a `price` array 4 entries short of `unix_seconds` is no_data, NOT a "
|
||
f"series truncated to {len(_full['price']) - 4} points - the missing "
|
||
f"tail of a Belgian day is the evening peak, and a short series is "
|
||
f"traded on where no data at all switches arbitrage off (§11.4)",
|
||
_short.kind == "no_data")
|
||
check("...and the reason names the two lengths, so an operator can see it was "
|
||
"a length mismatch and not an outage",
|
||
_short.kind == "no_data"
|
||
and str(len(_full["unix_seconds"])) in _short.reason
|
||
and str(len(_full["price"]) - 4) in _short.reason)
|
||
# The other direction, so the check is not a one-sided `len(price) < len(time)`.
|
||
_long = load("generic_dayahead",
|
||
transport=canned(_edited(_QH, price=_full["price"] + [1.0])))
|
||
check("...and a `price` array one entry LONGER is refused too",
|
||
_long.kind == "no_data")
|
||
# ⚠️ THE CONTROL. Without it, "refuses everything" passes both checks above.
|
||
check("...while the untouched body is still a series, so the refusal is the "
|
||
"length and not the editing",
|
||
load("generic_dayahead",
|
||
transport=canned(_edited(_QH))).kind == "series")
|
||
|
||
_unit = load("generic_dayahead", transport=canned(_edited(_QH,
|
||
unit="EUR / kWh")))
|
||
check("a feed that changes its unit to EUR/kWh is no_data, not every price "
|
||
"silently divided by 1000 - MAX_PLAUSIBLE_EUR_KWH only bounds prices "
|
||
"that are too LARGE, so nothing downstream could catch it",
|
||
_unit.kind == "no_data")
|
||
|
||
_nulls = _full["price"][:]
|
||
for _i in (3, 17):
|
||
_nulls[_i] = None
|
||
_nulled = load("generic_dayahead", transport=canned(_edited(_QH,
|
||
price=_nulls)))
|
||
check(f"a published null is DROPPED, leaving a hole rather than a zero - a "
|
||
f"missing quarter is not a free quarter",
|
||
_nulled.kind == "series"
|
||
and derived(lambda: len(_nulled.points)) == len(_full["price"]) - 2)
|
||
check("...and the dropped instants are the ones the feed left null, not the "
|
||
"first two",
|
||
derived(lambda: sorted(
|
||
int(p.start.timestamp()) for p in _nulled.points))
|
||
== sorted(t for i, t in enumerate(_full["unix_seconds"])
|
||
if i not in (3, 17)))
|
||
_allnull = load("generic_dayahead",
|
||
transport=canned(_edited(_QH,
|
||
price=[None] * len(_full["price"]))))
|
||
check("...but a body with a price for nothing at all is no_data, not an empty "
|
||
"series", _allnull.kind == "no_data")
|
||
# ⚠️ WHICH RULE REFUSED IT, not merely that something did. `_validate_raw()`
|
||
# would also refuse an empty list, one layer further out and with a sentence
|
||
# that says nothing about the feed - so asserting only `no_data` here would
|
||
# grade the same under a parser that had lost this refusal entirely.
|
||
check(f"...refused by the COLUMNAR parser, naming what arrived, rather than "
|
||
f"reaching the boundary validator as a generic empty list - "
|
||
f"{_allnull.reason!r}",
|
||
_allnull.kind == "no_data"
|
||
and f"{len(_full['price'])} instants" in _allnull.reason
|
||
and "a price for none of them" in _allnull.reason)
|
||
|
||
# ⚠️ THE TWO WARNINGS ARE GRADED, NOT ASSUMED. §11.4 is exactly what makes a
|
||
# price defect quiet - a parse failure becomes no_data becomes the static
|
||
# profile, correctly and silently - so an unverified diagnostic is the first
|
||
# thing here to rot. Both are captured off the shipped logger.
|
||
_wbuf = io.StringIO()
|
||
_wh = logging.StreamHandler(_wbuf)
|
||
logging.getLogger("goodwe.price").addHandler(_wh)
|
||
load("generic_dayahead", transport=canned(_edited(_QH, price=_nulls)))
|
||
_dep = load("generic_dayahead", transport=canned(_edited(_QH,
|
||
deprecated=True)))
|
||
logging.getLogger("goodwe.price").removeHandler(_wh)
|
||
_warn = _wbuf.getvalue()
|
||
_n_null = len([v for v in _nulls if v is None])
|
||
check(f"the dropped nulls are reported with a count ({_n_null} of "
|
||
f"{len(_nulls)}), so a series with holes in it is visible to an operator "
|
||
f"rather than merely short",
|
||
f"{_n_null} of {len(_nulls)}" in _warn)
|
||
check("...and a feed announcing deprecated=true says so WHILE IT STILL WORKS - "
|
||
"the day it stops, §11.4 makes its death look exactly like a quiet one",
|
||
"deprecated" in _warn and _dep.kind == "series")
|
||
|
||
# Which plugins are replayed from real bytes, derived from the registry so a
|
||
# sixth supplier cannot be added without this line noticing.
|
||
_networked = sorted(pid for pid, cls in PROVIDERS.items()
|
||
if cls.capabilities().egress_hosts)
|
||
# ⚠️ TARIFF-15 PUT A SECOND PLUGIN ON THIS LIST AND IT IS REPLAYED FROM THE
|
||
# SAME CAPTURED BYTES (section 13c), because it reads the same endpoint with
|
||
# the same parser. A networked plugin with no capture is what this line exists
|
||
# to forbid.
|
||
#
|
||
# ⚠️ PRICE-01 PUT A THIRD ON IT, AND ITS CAPTURE IS A RESHAPE, SAID PLAINLY. The
|
||
# price server answers only to a token this session does not hold, so no real
|
||
# answer of its own exists to replay. Its answer is the energy-charts layout
|
||
# (design, API section), so test_price_service.py replays the REAL energy-charts
|
||
# two-day capture with the three extra keys the design lists (`covers`,
|
||
# `source`, `fetched_at`). The prices and instants are real; the wrapper is the
|
||
# design's. The first answer captured from the live server on the rig test
|
||
# replaces it - that is the open item, and it is in the PRICE-01 report.
|
||
_replayed = ["custom_formula", "generic_dayahead", "price_service"]
|
||
check(f"the networked plugins are {_networked}, and EVERY one of them is "
|
||
f"replayed from a captured real body ({_replayed}) - no shipped plugin "
|
||
f"reaches a network this file has never seen answer (TARIFF-07)",
|
||
_networked == _replayed)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("2c. TARIFF-09 - the RANGE the caller asked for, on the Brussels "
|
||
"calendar")
|
||
#
|
||
# ⚠️ WHAT WAS WRONG, AND WHY NO CHECK IN THIS FILE COULD SEE IT.
|
||
# `generic_dayahead.fetch()` accepted `since` and `until` and requested the
|
||
# bare `https://api.energy-charts.info/price?bzn=BE`, which answers ONE
|
||
# Brussels day. Everything past today was then filled from yesterday's profile
|
||
# by FCST-04, flagged provisional, and §8.3 dropped all of it from the trade
|
||
# set - so the optimiser never saw tomorrow as firm, at any hour of any day.
|
||
# Measured on the merged tree at 2026-09-10T09:00+02:00 over a 48 h horizon:
|
||
# 15 firm whole hours and 33 carried, both ends of the only permitted cycle
|
||
# inside ONE calendar day; and at 23:00, 1 firm hour and 47 carried, where the
|
||
# firm spread no longer clears the round-trip cost at all.
|
||
#
|
||
# The reason section 2b could not catch it is that section 2b fed the parser
|
||
# BYTES and never looked at the REQUEST. `canned()` has always recorded the URL
|
||
# it was asked for and nothing asserted on it - so the transport knew the
|
||
# plugin was ignoring its arguments and no check ever asked. The checks below
|
||
# assert on the URL, and the two-day body they replay is a capture of what that
|
||
# URL really answers.
|
||
|
||
_TD = "energy-charts-be-price-2026-09-09_2026-09-10-twoday.json"
|
||
|
||
# ⚠️ NON-DEGENERACY FIRST (habit 1): the two-day capture must really cover TWO
|
||
# Brussels days and the one-day capture ONE, or "the range reached the feed"
|
||
# is graded against two fixtures that are the same shape.
|
||
_bdays = {n: sorted({brussels(datetime.fromtimestamp(t, UTC)).date().isoformat()
|
||
for t in json.loads(b.decode("utf-8"))["unix_seconds"]})
|
||
for n, b in _REAL.items()}
|
||
check(f"the captures span the local-day counts this section needs - "
|
||
f"{ {n: len(v) for n, v in _bdays.items()} } - or the range check has "
|
||
f"nothing to distinguish {_bdays[_TD]} from {_bdays[_QH]}",
|
||
len(_bdays[_TD]) == 2 and len(_bdays[_QH]) == 1
|
||
and len(_bdays[_H1]) == 1)
|
||
_npts = {n: len(json.loads(b.decode("utf-8"))["price"])
|
||
for n, b in _REAL.items()}
|
||
check(f"...and the two-day body carries 192 quarter-hours where the one-day "
|
||
f"body carries 96, which is the counterfactual this card is built on "
|
||
f"{_npts}",
|
||
len(json.loads(_REAL[_TD].decode("utf-8"))["price"]) == 192
|
||
and len(json.loads(_REAL[_QH].decode("utf-8"))["price"]) == 96)
|
||
|
||
# --- the URL the SHIPPED plugin asks for ----------------------------------- #
|
||
|
||
|
||
def asked_url(since, until, extra=None):
|
||
"""The URL `generic_dayahead` really requests for a window. Behaviour,
|
||
not source text: it comes back off the injected transport."""
|
||
t = canned(_REAL[_TD])
|
||
o = opts(price_provider="generic_dayahead", **(extra or {}))
|
||
run(load_prices(o, since, until, transport=t))
|
||
return nth(t.calls, 0)[0]
|
||
|
||
|
||
# The expectation is built HERE, from the window, by different arithmetic than
|
||
# price.py uses: a literal string with the two dates spelled out.
|
||
_w0 = datetime.fromisoformat("2026-09-09T09:17:43+02:00")
|
||
check(f"a 48 h horizon from 09:17 local asks the feed for the three Brussels "
|
||
f"days it touches -> {asked_url(_w0, _w0 + timedelta(hours=48))!r}",
|
||
asked_url(_w0, _w0 + timedelta(hours=48))
|
||
== "https://api.energy-charts.info/price?bzn=BE"
|
||
"&start=2026-09-09&end=2026-09-11")
|
||
check("...and the shipped default no longer asks the bare URL that answers a "
|
||
"single day",
|
||
asked_url(_w0, _w0 + timedelta(hours=48))
|
||
!= "https://api.energy-charts.info/price?bzn=BE")
|
||
|
||
# ⚠️ THE BRUSSELS/UTC BOUNDARY, WHICH IS THE TRAP. `since`/`until` are
|
||
# INSTANTS and the API takes DATES. At 22:30 UTC in September Belgium is
|
||
# already on the NEXT calendar day, so a range derived from `since.date()` -
|
||
# or from `since.astimezone(utc).date()`, which is the same thing - asks for
|
||
# the day the controller has just left and drops the day it is standing in.
|
||
# This is `_bdate()`'s lesson one layer up: price_history.py MISFILES an hour
|
||
# when it gets this wrong; here the hours are never fetched at all.
|
||
_edge = datetime.fromisoformat("2026-09-10T22:30:00+00:00")
|
||
check(f"the precondition: {_edge.isoformat()} really is a different day in "
|
||
f"Brussels ({brussels(_edge).date().isoformat()}) than in UTC "
|
||
f"({_edge.date().isoformat()}), or the boundary check grades nothing",
|
||
brussels(_edge).date().isoformat() != _edge.date().isoformat())
|
||
check(f"...so the range starts on the BRUSSELS day, 2026-09-11, and not on "
|
||
f"the UTC day 2026-09-10 -> "
|
||
f"{asked_url(_edge, _edge + timedelta(hours=12))!r}",
|
||
asked_url(_edge, _edge + timedelta(hours=12))
|
||
== "https://api.energy-charts.info/price?bzn=BE"
|
||
"&start=2026-09-11&end=2026-09-11")
|
||
|
||
# ...and the SAME trap on the other end. `until` at 22:30 UTC is already
|
||
# 00:30 of the next Brussels day, so a UTC-derived END stops one day short and
|
||
# the last hours of the horizon are never fetched. Both ends of the range
|
||
# derivation are graded, or a mutation only has to get one of them right.
|
||
_edge2_a = datetime.fromisoformat("2026-09-09T12:00:00+00:00")
|
||
_edge2_b = datetime.fromisoformat("2026-09-10T22:30:00+00:00")
|
||
check(f"the precondition: {_edge2_b.isoformat()} is "
|
||
f"{brussels(_edge2_b).date().isoformat()} in Brussels and "
|
||
f"{_edge2_b.date().isoformat()} in UTC, so the two answers differ",
|
||
brussels(_edge2_b).date().isoformat() != _edge2_b.date().isoformat())
|
||
check(f"...so the range ENDS on the Brussels day 2026-09-11, not on the UTC "
|
||
f"day 2026-09-10 -> {asked_url(_edge2_a, _edge2_b)!r}",
|
||
asked_url(_edge2_a, _edge2_b)
|
||
== "https://api.energy-charts.info/price?bzn=BE"
|
||
"&start=2026-09-09&end=2026-09-11")
|
||
|
||
# ⚠️ `until` IS EXCLUSIVE, so local midnight belongs to the day BEFORE it. A
|
||
# 24 h horizon from local midnight is ONE day, not two.
|
||
_mid = datetime.fromisoformat("2026-09-09T00:00:00+02:00")
|
||
check(f"a horizon ending exactly at local midnight asks for one day, because "
|
||
f"`until` is exclusive -> {asked_url(_mid, _mid + timedelta(hours=24))!r}",
|
||
asked_url(_mid, _mid + timedelta(hours=24))
|
||
== "https://api.energy-charts.info/price?bzn=BE"
|
||
"&start=2026-09-09&end=2026-09-09")
|
||
check(f"...while one second past midnight is two days, so the rule is the "
|
||
f"exclusive END and not a fixed subtraction -> "
|
||
f"{asked_url(_mid, _mid + timedelta(hours=24, seconds=1))!r}",
|
||
asked_url(_mid, _mid + timedelta(hours=24, seconds=1))
|
||
== "https://api.energy-charts.info/price?bzn=BE"
|
||
"&start=2026-09-09&end=2026-09-10")
|
||
check(f"an empty or inverted window collapses to a single day rather than "
|
||
f"asking for a backwards range no feed has a defined answer for -> "
|
||
f"{asked_url(_mid, _mid - timedelta(hours=5))!r}",
|
||
asked_url(_mid, _mid - timedelta(hours=5))
|
||
== "https://api.energy-charts.info/price?bzn=BE"
|
||
"&start=2026-09-09&end=2026-09-09")
|
||
|
||
# The helper on its own, over the DST transitions, where a fixed-offset
|
||
# assumption and a correct one disagree.
|
||
_mar = datetime.fromisoformat("2026-03-29T00:30:00+00:00") # 02:30 CEST
|
||
_bdr = derived(lambda: brussels_date_range(_mar, _mar + timedelta(hours=1)))
|
||
check(f"brussels_date_range() is right across the spring transition, where "
|
||
f"00:30 UTC is already 02:30 local -> {_bdr}",
|
||
_bdr == (date(2026, 3, 29), date(2026, 3, 29)))
|
||
|
||
# --- the two-day body, end to end through the SHIPPED parser --------------- #
|
||
|
||
_two = load("generic_dayahead", transport=canned(_REAL[_TD]))
|
||
check(f"the shipped generic_dayahead parses the real two-day body "
|
||
f"{_two.words}", _two.kind == "series")
|
||
def _series_days(read):
|
||
return derived(lambda: sorted({brussels(p.start).date().isoformat()
|
||
for p in read.points}))
|
||
|
||
|
||
_two_days = _series_days(_two)
|
||
check(f"...and the series it produces spans TWO Brussels local days "
|
||
f"{_two_days}, where the one-day body's spans one "
|
||
f"{_series_days(_REPLAY[_QH])}",
|
||
_two_days == ["2026-09-09", "2026-09-10"]
|
||
and len(_two_days) == 2)
|
||
check(f"...one point per published interval, 192 of them, no decimation and "
|
||
f"no day dropped ({derived(lambda: len(_two.points))})",
|
||
derived(lambda: len(_two.points)) == 192)
|
||
|
||
# --- the SHORT ANSWER, which is normal and must stay a series -------------- #
|
||
#
|
||
# ⚠️ ASKING FOR TOMORROW BEFORE THE ~13:00-14:00 CET AUCTION RETURNS FEWER DAYS
|
||
# THAN REQUESTED, AND THAT IS NOT AN ERROR. Measured: at 2026-09-10T07:46:55Z
|
||
# the request `?bzn=BE&start=2026-09-10&end=2026-09-11` came back BYTE-IDENTICAL
|
||
# to the bare-URL capture - one day, 96 quarter-hours. A `no_data` there would
|
||
# switch a working site's arbitrage off every morning, so the requirement is
|
||
# that the short body stays a `series` of what EXISTS and FCST-04's fill covers
|
||
# the rest with flagged, provisional hours. The identity is re-derived from the
|
||
# bytes rather than restated.
|
||
_prepub = _CAPTURES["files"][_QH]["also_returned_verbatim_by"]
|
||
check(f"the pre-publication range request {_prepub['url']!r} returned the "
|
||
f"SAME BYTES as the bare default URL - one day where two were asked for",
|
||
hashlib.sha256(_REAL[_QH]).hexdigest()
|
||
== _CAPTURES["files"][_QH]["sha256"]
|
||
and "start=2026-09-10&end=2026-09-11" in _prepub["url"])
|
||
check(f"...and the shipped parser answers that short body with a SERIES of "
|
||
f"what exists, never no_data - {_REPLAY[_QH].words}",
|
||
_REPLAY[_QH].kind == "series"
|
||
and derived(lambda: len(_REPLAY[_QH].points)) == 96)
|
||
# ⚠️ THE COUNTS ARE TAKEN THROUGH `derived()` AND COMPARED ONLY WHEN BOTH ARE
|
||
# NUMBERS. A mutation that turns the short answer into `no_data` makes
|
||
# `.points` raise `Absence` by design, and `_Raised < _Raised` is a TypeError -
|
||
# which takes the file down with a traceback instead of reporting a FAIL. A
|
||
# check whose failure mode is a crash has no name attached (habit 5's shape,
|
||
# with an exception in place of a hang).
|
||
_n_short = derived(lambda: len(_REPLAY[_QH].points))
|
||
_n_two = derived(lambda: len(_two.points))
|
||
check(f"...which is strictly fewer points than the two-day body "
|
||
f"({_n_short} < {_n_two}), so 'short' is a real property of this "
|
||
f"stimulus and not a name for it",
|
||
isinstance(_n_short, int) and isinstance(_n_two, int)
|
||
and _n_short < _n_two)
|
||
|
||
# --- price_feed_url: the exact URL, and the cost said out loud ------------- #
|
||
#
|
||
# ⚠️ THE DECISION. An override is used VERBATIM and this plugin appends
|
||
# nothing to it. Merging our `start`/`end` into an operator's query string
|
||
# would mean re-serialising a URL they wrote, and `start`/`end` are
|
||
# energy-charts' spelling - and this provider also DECLARES a SECOND host,
|
||
# `web-api.tp.entsoe.eu`, whose date parameters are not those and are not
|
||
# verified here, so appending our vocabulary there yields two ignored
|
||
# parameters and the short horizon anyway. The cost is
|
||
# that an overriding site silently gets the horizon this card exists to fix,
|
||
# so it is NOT silent: a warning names the consequence at fetch time.
|
||
_ovr = "https://api.energy-charts.info/price?bzn=BE&somethingelse=1"
|
||
check(f"an override is requested EXACTLY as given, with nothing appended -> "
|
||
f"{asked_url(_w0, _w0 + timedelta(hours=48), {'price_feed_url': _ovr})!r}",
|
||
asked_url(_w0, _w0 + timedelta(hours=48),
|
||
{"price_feed_url": _ovr}) == _ovr)
|
||
|
||
_obuf = io.StringIO()
|
||
_oh = logging.StreamHandler(_obuf)
|
||
logging.getLogger("goodwe.price").addHandler(_oh)
|
||
asked_url(_w0, _w0 + timedelta(hours=48), {"price_feed_url": _ovr})
|
||
_ovr_log = _obuf.getvalue()
|
||
_obuf2 = io.StringIO()
|
||
_oh2 = logging.StreamHandler(_obuf2)
|
||
logging.getLogger("goodwe.price").removeHandler(_oh)
|
||
logging.getLogger("goodwe.price").addHandler(_oh2)
|
||
asked_url(_w0, _w0 + timedelta(hours=48))
|
||
_dflt_log = _obuf2.getvalue()
|
||
logging.getLogger("goodwe.price").removeHandler(_oh2)
|
||
check(f"...and the operator is TOLD what it costs them, at fetch time, naming "
|
||
f"the horizon consequence rather than only the substitution",
|
||
"price_feed_url" in _ovr_log and _ovr in _ovr_log
|
||
and "provisional" in _ovr_log and "2026-09-11" in _ovr_log)
|
||
# ⚠️ THE CONTROL. Without it, "warns about everything" passes the line above.
|
||
check("...while the DEFAULT path says nothing, so the warning is about the "
|
||
"override and not about fetching",
|
||
"price_feed_url is set" not in _dflt_log)
|
||
|
||
# The builder refuses to append to a base that already has a query string,
|
||
# which is the "URL nobody intended" this decision is meant to make
|
||
# unreachable. Driven, not read off the source.
|
||
check(f"dated_feed_url() refuses a base that already carries a query string, "
|
||
f"rather than producing a second '?' - "
|
||
f"{_reason(lambda: dated_feed_url('https://h/p?a=1', b='2'))!r}",
|
||
raises(lambda: dated_feed_url("https://h/p?a=1", b="2"),
|
||
SourceUnavailable))
|
||
check("...and a clean base still builds, so the refusal is the existing query "
|
||
"string and not the builder",
|
||
dated_feed_url("https://h/p", b="2") == "https://h/p?b=2")
|
||
|
||
# --- the plugin contract: who consumes the range, pinned ------------------- #
|
||
#
|
||
# ⚠️ THIS IS A SOURCE-SHAPE PIN AND IS NAMED AS ONE (habit 4). The claim is
|
||
# about the CODE - which plugins reference the range arguments they are handed
|
||
# - which is the same kind of claim `code_leaks()` makes, and it is not
|
||
# evidence that any feed parses. `generic_dayahead`'s BEHAVIOUR is graded above
|
||
# by the URL it really requests.
|
||
#
|
||
# It exists because `fetch(self, opts, since, until, transport)` promises every
|
||
# plugin the range and the promise is NOT enforced. Enforcing it would oblige
|
||
# the three plugins whose endpoints nobody has verified (TARIFF-07) to invent a
|
||
# date-parameter vocabulary - TARIFF-08's defect moved from the response to the
|
||
# request. So the split is pinned instead, and a sixth supplier has to change
|
||
# this line rather than inherit the silence.
|
||
def _uses_range(cls):
|
||
src = dedent(inspect.getsource(cls.fetch))
|
||
tree = ast.parse(src)
|
||
names = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
|
||
return bool(names & {"since", "until"})
|
||
|
||
|
||
_uses = sorted(pid for pid, cls in PROVIDERS.items() if _uses_range(cls))
|
||
check(f"the plugins that CONSUME their `since`/`until` are {_uses}; the rest "
|
||
f"take the range and ignore it, which is what TARIFF-09 fixed for "
|
||
f"generic_dayahead - and after TARIFF-07 and TARIFF-15 the only one left "
|
||
f"that does not is the offline plugin, for the honest reason below",
|
||
_uses == ["custom_formula", "generic_dayahead"])
|
||
check("...and `manual_csv` is honestly among the ones that do not: it reads a "
|
||
"whole file and the fill windows it downstream, so a range parameter "
|
||
"would have nowhere to go",
|
||
"manual_csv" in sorted(set(PROVIDERS) - set(_uses)))
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("3. all-in: energy + markup + taxes, added exactly once (AC1, trap 1)")
|
||
# The expected figure is computed HERE, from the fixture's energy component and
|
||
# the plugin's own declaration, by different arithmetic than assemble() uses.
|
||
for pid, read in sorted(READS.items()):
|
||
caps = PROVIDERS[pid].capabilities()
|
||
_, energy = FIXTURES[pid]
|
||
add = (MARKUP if Component.MARKUP in caps.adds else 0.0) + \
|
||
(TAXES if Component.TAXES in caps.adds else 0.0)
|
||
want = [round(e + add, 6) for e in energy]
|
||
got = derived(lambda r=read: [p.eur_per_kwh for p in r.points])
|
||
check(f"{pid}: all-in == published {sorted(c.value for c in caps.sources)} "
|
||
f"+ framework {sorted(c.value for c in caps.adds) or 'nothing'} "
|
||
f"-> {got}",
|
||
got == want)
|
||
|
||
# ⚠️ THE ONE THAT MATTERS. generic_dayahead is the only shipped plugin whose
|
||
# feed is wholesale, so it is the only one that gets both components - and a
|
||
# feed declaring more must get strictly less. Derived by comparing output on the
|
||
# SAME energy figure rather than by restating numbers.
|
||
#
|
||
# ⚠️ THE MIDDLE RUNG - a feed sourcing {ENERGY, MARKUP}, which gets TAXES only -
|
||
# HAS NO SHIPPED DECLARER SINCE TARIFF-07, and is graded through `assemble()`
|
||
# with a hand-built declaration instead of being dropped. It is the only rung
|
||
# that distinguishes "adds the complement" from "adds everything or nothing",
|
||
# so deleting it with the three retail plugins would have left the partial case
|
||
# ungraded on the exact function that must not get it wrong.
|
||
def added(pid):
|
||
return derived(lambda: round(READS[pid].points[0].eur_per_kwh
|
||
- FIXTURES[pid][1][0], 6))
|
||
|
||
|
||
def added_for(sources):
|
||
"""What assemble() adds to ONE point declared as sourcing `sources`."""
|
||
caps = ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset(sources))
|
||
e = RETAIL_EUR_KWH[0]
|
||
return derived(lambda: round(
|
||
assemble(caps, [RawPoint(T0, e, False)], MARKUP, TAXES)[0].eur_per_kwh
|
||
- e, 6))
|
||
|
||
|
||
_generic_add = added("generic_dayahead")
|
||
_csv_add = added("manual_csv")
|
||
_retail_add = added_for({Component.ENERGY, Component.MARKUP})
|
||
check(f"the wholesale feed gets markup+taxes ({_generic_add})",
|
||
isinstance(_generic_add, float)
|
||
and abs(_generic_add - (MARKUP + TAXES)) < 1e-9)
|
||
check(f"a feed that already carries the markup gets taxes ONLY "
|
||
f"({_retail_add}), exactly one markup less than the wholesale one",
|
||
isinstance(_retail_add, float) and isinstance(_generic_add, float)
|
||
and abs(_retail_add - TAXES) < 1e-9
|
||
and abs((_generic_add - _retail_add) - MARKUP) < 1e-9)
|
||
check(f"an all-in feed gets nothing added ({_csv_add})",
|
||
isinstance(_csv_add, float) and abs(_csv_add) < 1e-9)
|
||
|
||
# Negative prices survive. A framework that clamped them would turn the best
|
||
# charging hour of the year into an average one, silently.
|
||
_neg = derived(lambda: [p.eur_per_kwh for p in READS["generic_dayahead"].points
|
||
if p.eur_per_kwh < 0])
|
||
check(f"a negative day-ahead price passes through, all-in {_neg}",
|
||
_neg == [round(EPEX_EUR_MWH[1] / 1000 + MARKUP + TAXES, 6)])
|
||
|
||
# provisional is per point and comes from the feed, not from the series.
|
||
#
|
||
# ⚠️ `generic_dayahead` IS EXCLUDED FROM THE "MIXED" REQUIREMENT, AND THE
|
||
# EXCLUSION IS THE POINT RATHER THAN A GAP. The columnar document the real feed
|
||
# publishes has no per-point provisional field - it is a settled auction result
|
||
# - so the plugin declares `False` for all of them. Demanding a mixed series
|
||
# from it would mean feeding it an invented body carrying an invented flag,
|
||
# which is exactly the closed loop TARIFF-08 exists to remove. It is graded on
|
||
# what its real body can grade instead, immediately below.
|
||
# ⚠️ `custom_formula` IS EXCLUDED FOR THE SAME REASON AND NOT FOR A NEW ONE
|
||
# (TARIFF-15): it reads the SAME columnar document, which has no per-point
|
||
# flag, and applies arithmetic to it - arithmetic cannot invent a provenance
|
||
# the document never carried.
|
||
_SETTLED_FEED = {"generic_dayahead", "custom_formula", "price_service"}
|
||
for pid in sorted(set(READS) - _SETTLED_FEED):
|
||
read = READS[pid]
|
||
flags = derived(lambda r=read: [p.provisional for p in r.points])
|
||
check(f"{pid}: provisional is per point and mixed {flags}",
|
||
isinstance(flags, list) and any(flags) and not all(flags))
|
||
check(f"...so plan_is_provisional is derived from those points",
|
||
derived(lambda r=read: plan_is_provisional(r)) is True)
|
||
for pid in sorted(_SETTLED_FEED):
|
||
_gen_flags = derived(lambda p=pid: [pt.provisional
|
||
for pt in READS[p].points])
|
||
check(f"{pid}'s settled auction comes out settled at every point "
|
||
f"{_gen_flags}",
|
||
isinstance(_gen_flags, list)
|
||
and len(_gen_flags) == len(EPEX_EUR_MWH)
|
||
and not any(_gen_flags))
|
||
check(f"...so plan_is_provisional is False for {pid}, derived from the "
|
||
f"same points",
|
||
derived(lambda p=pid: plan_is_provisional(READS[p])) is False)
|
||
# ...and the same function says False when nothing is provisional. Without this
|
||
# arm, "always True" would pass every check above.
|
||
_settled = PriceRead.series(
|
||
[PricePoint(brussels(T0), 0.2, False)], PriceProduct.DAY_AHEAD, "x", T0)
|
||
check("a fully settled series is NOT provisional",
|
||
plan_is_provisional(_settled) is False)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("4. double-counting the markup is detectable, and unreachable (trap 1)")
|
||
|
||
|
||
class _Rogue(PriceProvider):
|
||
"""A plugin that adds the markup itself while declaring it did not.
|
||
|
||
NOT registered - it exists only here, as the mutation the design is meant to
|
||
survive. It is what a plugin author writes when they copy the wholesale
|
||
plugin, hardcode their supplier's margin, and forget to update `sources`.
|
||
"""
|
||
|
||
id = "_rogue_not_shipped"
|
||
|
||
@classmethod
|
||
def capabilities(cls):
|
||
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY}))
|
||
|
||
async def fetch(self, o, since, until, transport):
|
||
# The margin the author hardcoded, because they could not read the
|
||
# configured one - see the FrameworkOption check below.
|
||
return [RawPoint(T0, RETAIL_EUR_KWH[0], False)]
|
||
|
||
|
||
# The honest counterpart to _Rogue: the SAME declaration _Rogue makes, on a
|
||
# feed that really does publish only energy. No shipped plugin is needed for it
|
||
# and none is used - the comparison is between two declarations, not two
|
||
# suppliers.
|
||
_honest_caps = ProviderCapabilities(
|
||
product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY, Component.MARKUP}))
|
||
_honest = assemble(_honest_caps, [RawPoint(T0, RETAIL_EUR_KWH[0], False)],
|
||
MARKUP, TAXES)[0].eur_per_kwh
|
||
_rogue_raw = run(_Rogue().fetch(PluginOptions({}), T0, T0, None))
|
||
_rogue = assemble(_Rogue.capabilities(), _rogue_raw, MARKUP, TAXES)[0].eur_per_kwh
|
||
_delta = derived(lambda: round(_rogue - _honest, 6))
|
||
check(f"a plugin that pre-adds the markup and declares it did not comes out "
|
||
f"{_delta} high - exactly one markup, so the defect is DETECTABLE and "
|
||
f"attributable rather than merely wrong",
|
||
isinstance(_delta, float) and abs(_delta - MARKUP) < 1e-9)
|
||
check("...and it is not confusable with 'the taxes were added twice'",
|
||
isinstance(_delta, float) and abs(_delta - TAXES) > 1e-9)
|
||
|
||
# ...and now the structural half: the number is not reachable from a plugin, so
|
||
# the honest author cannot write that line even by accident.
|
||
_po = PluginOptions({"price_markup_eur_kwh": MARKUP,
|
||
"price_taxes_eur_kwh": TAXES,
|
||
"export_price_eur_kwh": 0.04,
|
||
"price_csv_path": "/x"})
|
||
_refused = []
|
||
for k in ("price_markup_eur_kwh", "price_taxes_eur_kwh", "export_price_eur_kwh"):
|
||
try:
|
||
_po.get(k)
|
||
except FrameworkOption:
|
||
_refused.append(k)
|
||
check(f"a plugin cannot read the framework's own options {sorted(_refused)}",
|
||
sorted(_refused) == sorted(["price_markup_eur_kwh", "price_taxes_eur_kwh",
|
||
"export_price_eur_kwh"]))
|
||
check("...and `k in opts` is false for them too, so a probe cannot find them",
|
||
not any(k in _po for k in ("price_markup_eur_kwh", "price_taxes_eur_kwh")))
|
||
check("...while the options a plugin legitimately needs still come through",
|
||
_po.get("price_csv_path") == "/x"
|
||
and _po.get("price_timeout_s") == PRICE_OPTION_DEFAULTS["price_timeout_s"])
|
||
|
||
# The prose and the declaration must agree. A docstring that says "the framework
|
||
# adds taxes" over a class declaring it sources them is a 3 c/kWh error with a
|
||
# reassuring comment on top.
|
||
_doc_drift = []
|
||
for pid, cls in sorted(PROVIDERS.items()):
|
||
line = next((ln for ln in (cls.__doc__ or "").splitlines()
|
||
if ln.strip().startswith("SOURCES:")), "")
|
||
named = {w.strip() for w in line.split(":", 1)[-1].split(",") if w.strip()}
|
||
declared = {c.value for c in cls.capabilities().sources}
|
||
if named != declared:
|
||
_doc_drift.append((pid, sorted(named), sorted(declared)))
|
||
check(f"every plugin's docstring names exactly the components it declares "
|
||
f"({len(PROVIDERS)} checked)", _doc_drift == [])
|
||
|
||
# The ignored-markup warning: a plugin whose feed is already all-in must SAY it
|
||
# is ignoring the operator's numbers rather than dropping them silently.
|
||
_buf = io.StringIO()
|
||
_h = logging.StreamHandler(_buf)
|
||
logging.getLogger("goodwe.price").addHandler(_h)
|
||
load_any("manual_csv")
|
||
load_any("generic_dayahead")
|
||
logging.getLogger("goodwe.price").removeHandler(_h)
|
||
_warned = _buf.getvalue()
|
||
check("an all-in plugin logs that the configured markup/taxes are ignored",
|
||
"double-count" in _warned and "manual_csv" in _warned)
|
||
check("...and the wholesale plugin, which really does use them, does not",
|
||
"generic_dayahead" not in _warned)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("4b. a plugin cannot reach the markup BY ANY ROUTE (review of PR #16)")
|
||
# WHY THIS SECTION EXISTS. The first version of this ticket claimed the markup
|
||
# was "not in scope" for a plugin because `PluginOptions` refused it. Review
|
||
# wrote a double-counting plugin three more ways, one of them touching nothing
|
||
# but `PRICE_OPTION_DEFAULTS` - a public dict in the module every plugin must
|
||
# already import for `register`. One closed door in a room with three is not a
|
||
# guarantee. Honest wholesale baseline 0.188; each attack lands 0.219.
|
||
_banned = set(BANNED_IN_PLUGIN_CODE)
|
||
check(f"the banned set covers the framework keys AND the three public routes "
|
||
f"review actually used {sorted(_banned)}",
|
||
_banned == set(_price.FRAMEWORK_KEYS) | {"PRICE_OPTION_DEFAULTS",
|
||
"price_option", "_o"})
|
||
|
||
# code_leaks() fails OPEN when inspect.getsource() cannot find the .py - the
|
||
# right trade for a battery controller, and a silently disarmed guard if nobody
|
||
# checks. THIS is the check that makes CI prove the guard is armed.
|
||
_srclen = derived(lambda: {pid: len(inspect.getsource(cls))
|
||
for pid, cls in PROVIDERS.items()})
|
||
check(f"the scan really read every shipped plugin's source, so its fail-open "
|
||
f"path is not silently in use {_srclen}",
|
||
isinstance(_srclen, dict) and set(_srclen) == set(PROVIDERS)
|
||
and _srclen != {} and all(n > 200 for n in _srclen.values()))
|
||
_dirty = derived(lambda: {pid: code_leaks(cls)
|
||
for pid, cls in PROVIDERS.items()
|
||
if code_leaks(cls)})
|
||
check(f"...and every shipped plugin is clean {_dirty}", _dirty == {})
|
||
|
||
# THE DOCSTRING STRIP IS LOAD-BEARING, AND THIS PROVES IT RATHER THAN ASSERTING
|
||
# IT. Every plugin is REQUIRED to name its sourced components in prose, and a
|
||
# plugin whose feed already carries the markup naturally writes the option key
|
||
# out to say the framework must not add it again - which a RAW text scan reads
|
||
# as a leak. A check that fires on the documentation the same file mandates is
|
||
# a check somebody deletes.
|
||
#
|
||
# ⚠️ THE STIMULUS IS BUILT HERE NOW, AND SAYING SO IS THE POINT. Until
|
||
# TARIFF-07 this asserted on the SHIPPED set, because `engie_dynamic`'s
|
||
# docstring happened to spell `price_markup_eur_kwh` in full. It was withdrawn,
|
||
# no remaining shipped docstring spells a full key, and grading the shipped set
|
||
# would therefore now pass on a tree with the strip DELETED - a check that had
|
||
# quietly stopped being one. So the false-positive is constructed, and the
|
||
# shipped set is asserted to be clean separately, above.
|
||
class _DocumentedPlugin(PriceProvider):
|
||
"""A plugin documenting, correctly, that price_markup_eur_kwh is not added.
|
||
|
||
SOURCES: energy, markup
|
||
The framework ADDS: taxes
|
||
"""
|
||
|
||
id = "_documented_not_shipped"
|
||
|
||
@classmethod
|
||
def capabilities(cls):
|
||
return ProviderCapabilities(
|
||
product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY, Component.MARKUP}))
|
||
|
||
async def fetch(self, o, since, until, transport):
|
||
return [RawPoint(T0, RETAIL_EUR_KWH[0], False)]
|
||
|
||
|
||
_raw_hit = derived(lambda: sorted(
|
||
b for b in _banned if b in inspect.getsource(_DocumentedPlugin)))
|
||
check(f"a RAW text scan false-positives on a mandated docstring {_raw_hit}, "
|
||
f"which is why code_leaks() strips docstrings before searching - and on "
|
||
f"the same class code_leaks() reports {code_leaks(_DocumentedPlugin)}",
|
||
_raw_hit == ["price_markup_eur_kwh"]
|
||
and code_leaks(_DocumentedPlugin) == []
|
||
and _dirty == {})
|
||
|
||
|
||
class _AttackPrivateDict(PriceProvider):
|
||
"""A1: reaches into PluginOptions' private dict.
|
||
|
||
SOURCES: energy
|
||
The framework ADDS: markup, taxes
|
||
"""
|
||
|
||
id = "_attack_private_dict"
|
||
|
||
@classmethod
|
||
def capabilities(cls):
|
||
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY}))
|
||
|
||
async def fetch(self, o, since, until, transport):
|
||
return [RawPoint(T0, 0.10 + o._o["price_markup_eur_kwh"], False)]
|
||
|
||
|
||
class _AttackHelper(PriceProvider):
|
||
"""A2: calls a public framework helper with the raw options dict.
|
||
|
||
SOURCES: energy
|
||
The framework ADDS: markup, taxes
|
||
"""
|
||
|
||
id = "_attack_helper"
|
||
|
||
@classmethod
|
||
def capabilities(cls):
|
||
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY}))
|
||
|
||
async def fetch(self, o, since, until, transport):
|
||
return [RawPoint(T0, 0.10 + price_option(o._o, "price_markup_eur_kwh"),
|
||
False)]
|
||
|
||
|
||
class _AttackPublicConstant(PriceProvider):
|
||
"""A2b: touches nothing but a public module constant. THE DECIDING ONE.
|
||
|
||
SOURCES: energy
|
||
The framework ADDS: markup, taxes
|
||
"""
|
||
|
||
id = "_attack_public_constant"
|
||
|
||
@classmethod
|
||
def capabilities(cls):
|
||
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY}))
|
||
|
||
async def fetch(self, o, since, until, transport):
|
||
return [RawPoint(T0,
|
||
0.10 + PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"],
|
||
False)]
|
||
|
||
|
||
_ATTACKS = {"A1 private dict": _AttackPrivateDict,
|
||
"A2 public helper": _AttackHelper,
|
||
"A2b public constant": _AttackPublicConstant}
|
||
for _name, _cls in _ATTACKS.items():
|
||
_found = derived(lambda c=_cls: code_leaks(c))
|
||
check(f"{_name}: the scan names what it reached for {_found}",
|
||
isinstance(_found, list) and _found != [])
|
||
check(f"...and register() REFUSES it at import, not at fetch time",
|
||
raises(lambda c=_cls: register(c), FrameworkOption))
|
||
check(f"...so it never enters the registry", _cls.id not in PROVIDERS)
|
||
|
||
# THE COST OF THE DEFECT, MEASURED, so the refusal is not grading itself.
|
||
_honest_wholesale = derived(lambda: assemble(
|
||
PROVIDERS["generic_dayahead"].capabilities(),
|
||
[RawPoint(T0, 0.10, False)], MARKUP, TAXES)[0].eur_per_kwh)
|
||
_attacked = derived(lambda: assemble(
|
||
_AttackPublicConstant.capabilities(),
|
||
[RawPoint(T0, 0.10 + MARKUP, False)], MARKUP, TAXES)[0].eur_per_kwh)
|
||
check(f"an unrefused attack would bill {_attacked} against an honest "
|
||
f"{_honest_wholesale} - one markup high, every hour, silently",
|
||
isinstance(_attacked, float) and isinstance(_honest_wholesale, float)
|
||
and abs((_attacked - _honest_wholesale) - MARKUP) < 1e-9)
|
||
|
||
# WHAT THIS DOES *NOT* CLOSE, AND CANNOT. A plugin that declares {ENERGY} while
|
||
# its feed is really all-in comes out one markup high while touching no
|
||
# framework name at all. No code can check that: the declaration is the
|
||
# plugin's unverifiable claim about somebody else's tariff. Stated here so the
|
||
# guarantee is read at its true size - the complement is added exactly once,
|
||
# GIVEN A CORRECT DECLARATION - and so a reviewer who trusts an absolute
|
||
# guarantee does not stop checking. Measured, not asserted:
|
||
_misdeclared = derived(lambda: assemble(
|
||
ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY})),
|
||
[RawPoint(T0, 0.10 + MARKUP + TAXES, False)], MARKUP, TAXES)[0].eur_per_kwh)
|
||
check(f"a MIS-DECLARED all-in feed still doubles ({_misdeclared} vs "
|
||
f"{_honest_wholesale}) with no framework name touched - the limit of the "
|
||
f"guarantee, stated rather than hidden",
|
||
isinstance(_misdeclared, float)
|
||
and abs((_misdeclared - _honest_wholesale) - (MARKUP + TAXES)) < 1e-9
|
||
and code_leaks(_AttackPrivateDict) != [])
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("5. product type is declared, not inferred from the name (AC2, trap 2)")
|
||
# ⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME`, AND THAT IS RECORDED
|
||
# HERE RATHER THAN LEFT TO BE REDISCOVERED (TARIFF-07). `eneco_eddy` was its
|
||
# sole declarer and it is withdrawn, so `_WIDE`, `Uncertainty.REALTIME_PRODUCT`
|
||
# and `WIDEN_FACTOR`'s 2.5 have no shipped caller. They are KEPT - a reviewer
|
||
# finding a mechanism with zero callers would reasonably delete it, and the
|
||
# first real imbalance product would then have to rebuild the AC2 seam - and
|
||
# this pin is what tells that reviewer the emptiness is intentional. See
|
||
# DECISIONS/TARIFF-07-withdrawing-three-plugins.md.
|
||
#
|
||
# ⚠️ IT IS A PIN, NOT A PROHIBITION. If a plugin that really is real-time ever
|
||
# ships, this check goes red and the right fix is to EDIT it - and the decision
|
||
# record with it - not to delete the seam.
|
||
_products = {pid: cls.capabilities().product for pid, cls in PROVIDERS.items()}
|
||
_realtime = sorted(p for p, v in _products.items()
|
||
if v is PriceProduct.REALTIME)
|
||
check(f"no SHIPPED plugin declares real-time/imbalance {_realtime or '[]'} - "
|
||
f"§9.2's row 2 is unoccupied since TARIFF-07 withdrew eneco_eddy, and "
|
||
f"the widening path below is kept anyway, on purpose",
|
||
_realtime == [])
|
||
_regimes = {pid: volatility_regime(v) for pid, v in _products.items()}
|
||
check(f"...so every shipped plugin is in the NORMAL regime {_regimes}",
|
||
set(_regimes.values()) == {"normal"})
|
||
|
||
# ...and the machinery is still LIVE, graded from a declaration this file makes
|
||
# rather than from a plugin. This is the same stimulus AC2 promises a sixth
|
||
# supplier: declare REALTIME, get the wide regime, edit nothing in price.py.
|
||
_rt_caps = ProviderCapabilities(product=PriceProduct.REALTIME,
|
||
sources=frozenset({Component.ENERGY}))
|
||
check(f"the widening path still works from a declaration alone "
|
||
f"({volatility_regime(_rt_caps.product)}), which is why §9.2 row 2 "
|
||
f"having no occupant is not the same as it having no mechanism",
|
||
volatility_regime(_rt_caps.product) == "wide"
|
||
and volatility_regime(PriceProduct.DAY_AHEAD) == "normal")
|
||
check(f"...and it is the PRODUCT that carries it: uncertainty_of() answers "
|
||
f"{sorted(u.value for u in uncertainty_of(PriceProduct.REALTIME))} for "
|
||
f"real-time and nothing for day-ahead, and widen_factor() turns that "
|
||
f"into {widen_factor(uncertainty_of(PriceProduct.REALTIME))}",
|
||
uncertainty_of(PriceProduct.REALTIME)
|
||
== frozenset({Uncertainty.REALTIME_PRODUCT})
|
||
and uncertainty_of(PriceProduct.DAY_AHEAD) == frozenset()
|
||
and widen_factor(uncertainty_of(PriceProduct.REALTIME)) == 2.5
|
||
and widen_factor(uncertainty_of(PriceProduct.DAY_AHEAD)) == 1.0)
|
||
|
||
|
||
def _accepts_name():
|
||
"""Would volatility_regime() take a plugin id? If it would, somebody can
|
||
start passing one and AC2 rots without a single check going red."""
|
||
try:
|
||
volatility_regime("eneco_eddy")
|
||
return True
|
||
except TypeError:
|
||
return False
|
||
|
||
|
||
check("volatility_regime refuses a plugin NAME, which is the AC2 violation "
|
||
"written out",
|
||
not _accepts_name())
|
||
|
||
|
||
# ⚠️ THE MECHANICAL FORM OF AC2. The framework's own code must contain no
|
||
# plugin id at all - if it does, the sixth supplier is an edit to price.py's
|
||
# shared machinery rather than a new class. Derived: build the set of ids that
|
||
# actually occur, and assert it is empty.
|
||
_framework = [_price.assemble, _price.load_prices, _price.volatility_regime,
|
||
_price.arbitrage_allowed, _price.plan_is_provisional,
|
||
_price.check_egress, _price._validate_raw, _price.PriceRead,
|
||
_price._Series, _price._NoData, _price.PluginOptions,
|
||
_price.register, _price.price_option]
|
||
_src = "\n".join(inspect.getsource(o) for o in _framework)
|
||
_leaked = sorted(pid for pid in PROVIDERS if pid in _src)
|
||
check(f"no plugin id appears anywhere in the framework's code {_leaked or '[]'}",
|
||
_leaked == [])
|
||
|
||
# ...and the positive half: a plugin defined HERE, in a file price.py has never
|
||
# heard of, gets the right regime with no edit anywhere.
|
||
@register
|
||
class _NewSupplier(PriceProvider):
|
||
"""A sixth supplier, invented in the test file. AC2 in one class.
|
||
|
||
SOURCES: energy
|
||
The framework ADDS: markup, taxes
|
||
"""
|
||
|
||
id = "_test_sixth_supplier"
|
||
|
||
@classmethod
|
||
def capabilities(cls):
|
||
return ProviderCapabilities(product=PriceProduct.REALTIME,
|
||
sources=frozenset({Component.ENERGY}),
|
||
egress_hosts=("api.example.invalid",))
|
||
|
||
async def fetch(self, o, since, until, transport):
|
||
body = await transport("https://api.example.invalid/p",
|
||
float(o.get("price_timeout_s")))
|
||
return _price._points_from_columnar_json(body, expect_unit="EUR / MWh",
|
||
scale=0.001,
|
||
provisional_default=False)
|
||
|
||
|
||
# ⚠️ THIS CLASS IS ALSO THE ONLY LIVE DECLARER OF `PriceProduct.REALTIME` IN
|
||
# THE TREE (TARIFF-07), which makes the widened-threshold path a thing this
|
||
# suite exercises end to end - registry, series, regime - and not merely a
|
||
# constant section 5 reads. Deleting it would strand the seam twice over.
|
||
_sixth = load("_test_sixth_supplier",
|
||
transport=canned(columnar_feed(EPEX_EUR_MWH)))
|
||
check("a plugin invented outside price.py resolves through the registry",
|
||
_sixth.kind == "series" and _sixth.provider == "_test_sixth_supplier")
|
||
_sixth_regime = derived(lambda: _sixth.regime)
|
||
check(f"...and lands in the wide-threshold regime purely from its declaration "
|
||
f"({_sixth_regime})", _sixth_regime == "wide")
|
||
check("...and the framework applied its markup and taxes correctly too",
|
||
derived(lambda: [p.eur_per_kwh for p in _sixth.points])
|
||
== [round(v / 1000 + MARKUP + TAXES, 6) for v in EPEX_EUR_MWH])
|
||
del PROVIDERS["_test_sixth_supplier"] # do not leak into later sections
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("6. no price data is its own outcome, not an empty list (trap 3, §11.4)")
|
||
|
||
_none = PriceRead.no_data("the feed timed out")
|
||
# ⚠️ THE DISTINCTION THE WHOLE TYPE EXISTS FOR. A day of free electricity and a
|
||
# day with no price data are different instructions: arbitrage is POINTLESS
|
||
# under the first and FORBIDDEN under the second.
|
||
_zero = PriceRead.series([PricePoint(brussels(T0 + timedelta(hours=i)), 0.0,
|
||
False) for i in range(3)],
|
||
PriceProduct.DAY_AHEAD, "x", T0)
|
||
check("a zero-price series and no-price-data are different kinds",
|
||
_zero.kind != _none.kind)
|
||
check("...arbitrage is allowed under zero prices and forbidden under no data",
|
||
arbitrage_allowed(_zero) is True and arbitrage_allowed(_none) is False)
|
||
check("...the plan is provisional under no data and settled under zero prices",
|
||
plan_is_provisional(_none) is True and plan_is_provisional(_zero) is False)
|
||
check("...and the cheapest hour is computable for the zero series",
|
||
derived(lambda: min(p.eur_per_kwh for p in _zero.points)) == 0.0)
|
||
|
||
|
||
check("no_data has no .points, so `min(read.points)` cannot invent a free hour",
|
||
raises(lambda: _none.points, Absence))
|
||
check("...no .product to switch a threshold regime on",
|
||
raises(lambda: _none.product, Absence))
|
||
check("...no .at(), so an hourly lookup cannot silently succeed",
|
||
raises(lambda: _none.at(T0), Absence))
|
||
check("...and .get('points', []) - the idiom the whole rework exists to kill - "
|
||
"raises rather than returning the default",
|
||
raises(lambda: _none.get("points", []), Absence))
|
||
check("...and it is not iterable, so `for p in read:` cannot be an empty loop",
|
||
raises(lambda: list(_none), Absence))
|
||
check("...while its one real field, the reason, is there for an operator",
|
||
_none.reason == "the feed timed out")
|
||
|
||
# M3, INHERITED FROM RIG-01 AND CLOSED HERE. `_NoData.__init__(self,
|
||
# reason="no price data")` used to go green: a required field with a DEFAULT is
|
||
# the exact idiom this whole type exists to forbid, and it survived because
|
||
# every check passed a reason explicitly. It is the identical survivor left
|
||
# open on sim/riglock.py's `_Unread.cause` - the shape was borrowed including
|
||
# its blind spot, which is what borrowing a shape does if nobody re-runs the
|
||
# mutations against the copy.
|
||
#
|
||
# Checked by SIGNATURE rather than by one call, so any default added to any of
|
||
# these constructors later is red without somebody remembering to write a new
|
||
# case.
|
||
def _defaulted(fn):
|
||
"""Parameters of `fn` that carry a default. `self` is skipped."""
|
||
try:
|
||
ps = list(inspect.signature(fn).parameters.values())
|
||
except (TypeError, ValueError) as e: # pragma: no cover
|
||
return _Raised(e)
|
||
return [q.name for q in ps
|
||
if q.name != "self" and q.default is not inspect.Parameter.empty]
|
||
|
||
|
||
_ctors = {"PriceRead.no_data": PriceRead.no_data,
|
||
"PriceRead.series": PriceRead.series,
|
||
"_NoData.__init__": _price._NoData.__init__,
|
||
"_Series.__init__": _price._Series.__init__,
|
||
"PriceRead.match": PriceRead.match}
|
||
_with_defaults = derived(lambda: {n: d for n, d in
|
||
((n, _defaulted(f)) for n, f in _ctors.items())
|
||
if d})
|
||
check(f"no constructor or handler of an outcome type carries a DEFAULT, so a "
|
||
f"reading can never be built out of nothing {_with_defaults}",
|
||
_with_defaults == {})
|
||
check("...so no_data() with no reason at all is a TypeError at the call site",
|
||
raises(lambda: PriceRead.no_data(), TypeError))
|
||
check("...and the variant class itself cannot be built bare either",
|
||
raises(lambda: _price._NoData(), TypeError))
|
||
check("...nor can a series be built without its product or provenance",
|
||
raises(lambda: _price._Series([PricePoint(brussels(T0), 0.2, False)]),
|
||
TypeError))
|
||
# The positive arm, so the four above are not passing because everything raises.
|
||
check("...while a fully specified no_data is still perfectly constructible",
|
||
PriceRead.no_data("the feed timed out").reason == "the feed timed out")
|
||
|
||
# `match` must also refuse POSITIONAL handlers - keyword-only is what makes a
|
||
# forgotten arm a TypeError rather than a silently swapped one.
|
||
check("match() refuses positional handlers, so the two arms cannot be swapped",
|
||
raises(lambda: _none.match(lambda r: 1, lambda r: 2), TypeError))
|
||
|
||
check("an EMPTY series cannot be constructed at all, so 'no prices' can never "
|
||
"wear the shape of an answer",
|
||
raises(lambda: PriceRead.series([], PriceProduct.DAY_AHEAD, "x", T0),
|
||
ValueError))
|
||
check("a series built with a plugin NAME instead of a product enum is refused",
|
||
raises(lambda: PriceRead.series([PricePoint(brussels(T0), 0.2, False)],
|
||
"eneco_eddy", "x", T0), ValueError))
|
||
check("no_data with a blank reason is refused - the reason is its only field",
|
||
raises(lambda: PriceRead.no_data(""), ValueError))
|
||
|
||
|
||
def _forgot():
|
||
return _none.match(series=lambda r: r.points) # no_data omitted
|
||
|
||
|
||
check("a caller who forgets the no_data arm gets a TypeError at the call site, "
|
||
"not a fall-through", raises(_forgot, TypeError))
|
||
|
||
# .at() outside the series raises rather than returning a neighbour or None.
|
||
_s = READS["generic_dayahead"]
|
||
check("series.at() inside the series returns the covering point",
|
||
derived(lambda: _s.at(_s.points[0].start + timedelta(minutes=59))
|
||
is _s.points[0]) is True)
|
||
check("...and raises outside it rather than returning None to be `or`-ed away",
|
||
derived(lambda: (
|
||
raises(lambda: _s.at(_s.points[-1].start + timedelta(days=2)),
|
||
Absence)
|
||
and _s.covers(_s.points[0].start)
|
||
and not _s.covers(_s.points[-1].start + timedelta(days=2)))) is True)
|
||
|
||
# Every failure a site can have must come back as no_data, never as a traceback
|
||
# into the tick. Derived: drive each failure and collect the kinds.
|
||
async def _boom(url, timeout_s):
|
||
raise OSError("connection reset by peer")
|
||
|
||
|
||
_failures = {
|
||
"the source is unreachable": load("generic_dayahead", transport=_boom),
|
||
"the body is not JSON": load("generic_dayahead", transport=canned(b"<html>")),
|
||
"the feed is empty": load("generic_dayahead",
|
||
transport=canned(columnar_feed([]))),
|
||
# ⚠️ THE ROW SHAPE IS A FAILURE FOR THIS PLUGIN, NOT A SECOND DIALECT. If
|
||
# `_points_from_columnar_json()` ever grows a sniff for `data`, this case
|
||
# goes from no_data to a series and TARIFF-08's scope rule is broken by a
|
||
# check rather than by a review.
|
||
"the body is the OTHER shape": load(
|
||
"generic_dayahead",
|
||
transport=canned(row_shape_body("price_eur_mwh", EPEX_EUR_MWH))),
|
||
"the provider id is unknown": load("no_such_supplier"),
|
||
"the provider id is RETIRED": load("eneco_eddy"),
|
||
"no provider is configured": load("off"),
|
||
"the csv is missing": load("manual_csv",
|
||
{"price_csv_path": "/no/such/file.csv"},
|
||
transport=refuse_transport),
|
||
}
|
||
_not_nodata = sorted(k for k, r in _failures.items() if r.kind != "no_data")
|
||
check(f"every site-level failure returns no_data instead of raising "
|
||
f"({len(_failures)} cases)", _not_nodata == [])
|
||
_no_reason = derived(lambda: sorted(k for k, r in _failures.items()
|
||
if len(r.reason) < 10))
|
||
check(f"...each with a reason an operator can act on {_no_reason}",
|
||
_no_reason == [])
|
||
check("...and none of them is mistakable for a series",
|
||
derived(lambda: all(not arbitrage_allowed(r)
|
||
for r in _failures.values())) is True)
|
||
|
||
# ⚠️ The one thing that must NOT degrade to no_data: a plugin reaching for the
|
||
# markup. That is a code defect, and swallowing it would hide exactly what
|
||
# trap 1 exists to catch.
|
||
class _Thief(PriceProvider):
|
||
id = "_thief"
|
||
|
||
@classmethod
|
||
def capabilities(cls):
|
||
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY}))
|
||
|
||
async def fetch(self, o, since, until, transport):
|
||
return [RawPoint(T0, 0.1 + o.get("price_markup_eur_kwh"), False)]
|
||
|
||
|
||
PROVIDERS["_thief"] = _Thief
|
||
check("a plugin that reaches for the markup raises rather than degrading to "
|
||
"no_data - that is a bug, not a site condition",
|
||
raises(lambda: load("_thief"), FrameworkOption))
|
||
del PROVIDERS["_thief"]
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("7. manual_csv works fully offline (AC3)")
|
||
_had_aiohttp = "aiohttp" in sys.modules
|
||
_csv_read = load("manual_csv", {"price_csv_path": CSV_PATH},
|
||
transport=refuse_transport)
|
||
_csv_prices = derived(lambda: [p.eur_per_kwh for p in _csv_read.points])
|
||
check("the offline plugin produced a series without touching the transport - "
|
||
"the transport raises if called at all",
|
||
_csv_read.kind == "series" and _csv_prices == [0.1834, -0.021, 0.3115])
|
||
check(f"...it is all-in already, so the framework added nothing {_csv_prices}",
|
||
_csv_prices == [round(v, 6) for v in
|
||
[float(ln.split(",")[1]) for ln in
|
||
CSV_BODY.strip().splitlines()[1:]]])
|
||
check("...including the negative hour",
|
||
isinstance(_csv_prices, list) and any(v < 0 for v in _csv_prices))
|
||
check("...and the per-point provisional flag came from the file's third column",
|
||
derived(lambda: [p.provisional for p in _csv_read.points])
|
||
== [ln.split(",")[2] == "true"
|
||
for ln in CSV_BODY.strip().splitlines()[1:]])
|
||
check("...and no HTTP stack was imported to do it"
|
||
if not _had_aiohttp else
|
||
"...(aiohttp was already imported before this file ran; not a finding)",
|
||
_had_aiohttp or "aiohttp" not in sys.modules)
|
||
check("manual_csv declares no egress hosts at all",
|
||
PROVIDERS["manual_csv"].capabilities().egress_hosts == ())
|
||
|
||
_ManualCsv = PROVIDERS["manual_csv"]
|
||
check("a CSV with no header is refused rather than parsed as data",
|
||
raises(lambda: _ManualCsv.parse("2026-01-15T00:00:00+01:00,0.18\n"),
|
||
SourceUnavailable))
|
||
check("a CSV start with no offset is refused, naming the fix",
|
||
raises(lambda: _ManualCsv.parse("start,eur_per_kwh\n2026-01-15 00:00,0.18\n"),
|
||
SourceUnavailable))
|
||
check("...and the two-column form (no provisional) is accepted, defaulting to "
|
||
"settled",
|
||
[p.provisional for p in
|
||
_ManualCsv.parse("start,eur_per_kwh\n2026-01-15T00:00:00+01:00,0.18\n")]
|
||
== [False])
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("8. the two 2026 DST transitions, where a 24-slot array is wrong")
|
||
# ⚠️ RUN ON THE 23- AND 25-HOUR DAYS ON PURPOSE. On any normal day a fixed
|
||
# 24-slot implementation, a naive-local-hour key and a correct one all agree
|
||
# exactly, so a check on 15 January grades nothing about DST.
|
||
check("the 2026 transitions are where this repo expects them (last Sundays)",
|
||
_price._last_sunday(2026, 3) == 29 and _price._last_sunday(2026, 10) == 25)
|
||
|
||
|
||
def local_day(day_start_utc, n):
|
||
return [brussels(day_start_utc + timedelta(hours=i)) for i in range(n)]
|
||
|
||
|
||
# Spring forward: local day 2026-03-29 runs 2026-03-28T23:00Z .. 2026-03-29T22:00Z
|
||
_spring = local_day(datetime(2026, 3, 28, 23, tzinfo=UTC), 23)
|
||
_spring_end = brussels(datetime(2026, 3, 29, 22, tzinfo=UTC))
|
||
check(f"the spring day has 23 hours, ending exactly at the next midnight "
|
||
f"({_spring[0].isoformat()} .. {_spring_end.isoformat()})",
|
||
_spring[0].hour == 0 and _spring[0].day == 29
|
||
and _spring_end.hour == 0 and _spring_end.day == 30)
|
||
check(f"...crossing exactly one offset change "
|
||
f"{sorted(str(o) for o in {p.utcoffset() for p in _spring})}",
|
||
{p.utcoffset() for p in _spring} == {timedelta(hours=1),
|
||
timedelta(hours=2)})
|
||
check("...and 02:00 local never happens that day, which a 24-slot array would "
|
||
"have invented", 2 not in {p.hour for p in _spring})
|
||
|
||
# Fall back: local day 2026-10-25 runs 2026-10-24T22:00Z .. 2026-10-25T22:00Z
|
||
_fall = local_day(datetime(2026, 10, 24, 22, tzinfo=UTC), 25)
|
||
_fall_end = brussels(datetime(2026, 10, 25, 23, tzinfo=UTC))
|
||
check(f"the autumn day has 25 hours ({_fall[0].isoformat()} .. "
|
||
f"{_fall_end.isoformat()})",
|
||
_fall[0].hour == 0 and _fall[0].day == 25
|
||
and _fall_end.hour == 0 and _fall_end.day == 26)
|
||
_wall = [p.replace(tzinfo=None) for p in _fall]
|
||
_dups = len(_wall) - len(set(_wall))
|
||
check(f"...and exactly one local wall-clock hour occurs TWICE ({_dups}), which "
|
||
f"a naive-local-hour key would collide into one mispriced hour",
|
||
_dups == 1)
|
||
check("...distinguished by their offsets, which is why the canonical format "
|
||
"carries one",
|
||
len({p.isoformat() for p in _fall}) == 25)
|
||
# A normal day, stated as the control: without it, "23" and "25" prove nothing
|
||
# about whether the code counts at all.
|
||
_normal = local_day(datetime(2026, 1, 14, 23, tzinfo=UTC), 24)
|
||
check("...while an ordinary January day has 24 hours and one offset",
|
||
len({p.replace(tzinfo=None) for p in _normal}) == 24
|
||
and len({p.utcoffset() for p in _normal}) == 1)
|
||
check("brussels() refuses a naive datetime rather than assuming an offset",
|
||
raises(lambda: brussels(datetime(2026, 1, 15, 0, 0)), ValueError))
|
||
check("the offset boundary is 01:00 UTC, not local midnight",
|
||
brussels_offset(datetime(2026, 3, 29, 0, 59, tzinfo=UTC)) ==
|
||
timezone(timedelta(hours=1))
|
||
and brussels_offset(datetime(2026, 3, 29, 1, 0, tzinfo=UTC)) ==
|
||
timezone(timedelta(hours=2)))
|
||
|
||
# A feed that publishes the duplicated hour must survive it. The 25-hour day is
|
||
# 25 strictly increasing instants; a plugin keyed on local hours would emit 24
|
||
# and be rejected by the boundary validator instead of silently mispricing.
|
||
_dst_feed = columnar_feed([50.0 + i for i in range(25)],
|
||
start=datetime(2026, 10, 24, 22, tzinfo=UTC))
|
||
_dst_read = load("generic_dayahead", transport=canned(_dst_feed))
|
||
_dst_n = derived(lambda: len(_dst_read.points))
|
||
check(f"a 25-point autumn feed is accepted whole ({_dst_n} points)",
|
||
_dst_read.kind == "series" and _dst_n == 25)
|
||
check("...and its canonical starts are 25 distinct strings",
|
||
derived(lambda: len({p["start"]
|
||
for p in _dst_read.canonical()["prices"]})) == 25)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("9. the boundary rejects what cannot be a price, and only that")
|
||
# M5. Deleting the `Component.ENERGY not in sources` guard used to go green,
|
||
# and the consequence is not cosmetic: `adds` becomes {ENERGY, TAXES},
|
||
# `assemble()` never adds an energy component it thinks it is adding, and the
|
||
# emitted all-in price silently omits the energy it is supposed to be pricing.
|
||
check("capabilities() refuses a feed that declares no ENERGY component",
|
||
raises(lambda: ProviderCapabilities(
|
||
product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.MARKUP})), ValueError))
|
||
check("...including a completely empty sources set",
|
||
raises(lambda: ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset()), ValueError))
|
||
check("...and an unknown component is refused rather than ignored",
|
||
raises(lambda: ProviderCapabilities(
|
||
product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY, "grid_fees"})), ValueError))
|
||
check("...and a product given as a string is refused at the declaration too",
|
||
raises(lambda: ProviderCapabilities(product="dynamic_dayahead",
|
||
sources=frozenset({Component.ENERGY})),
|
||
ValueError))
|
||
# THE CONSEQUENCE, derived across every shipped plugin rather than asserted on
|
||
# one: the framework must never be in a position to "add" the energy component,
|
||
# because there is nothing to add it from.
|
||
_adds_energy = derived(lambda: sorted(
|
||
pid for pid, cls in PROVIDERS.items()
|
||
if Component.ENERGY in cls.capabilities().adds))
|
||
check(f"so no shipped plugin ever asks the framework to invent an energy "
|
||
f"price {_adds_energy}", _adds_energy == [])
|
||
|
||
_caps = PROVIDERS["generic_dayahead"].capabilities()
|
||
|
||
|
||
def boundary(points):
|
||
return raises(lambda: _price._validate_raw(points, _caps), SourceUnavailable)
|
||
|
||
|
||
check("NaN is refused", boundary([RawPoint(T0, float("nan"), False)]))
|
||
check("infinity is refused", boundary([RawPoint(T0, float("inf"), False)]))
|
||
check(f"a EUR/MWh figure read as EUR/kWh is refused (>{MAX_PLAUSIBLE_EUR_KWH})",
|
||
boundary([RawPoint(T0, 61.4, False)]))
|
||
check("a naive start is refused",
|
||
boundary([RawPoint(datetime(2026, 1, 15), 0.1, False)]))
|
||
check("duplicate starts are refused - the DST collision, caught",
|
||
boundary([RawPoint(T0, 0.1, False), RawPoint(T0, 0.2, False)]))
|
||
check("out-of-order starts are refused",
|
||
boundary([RawPoint(T0 + timedelta(hours=1), 0.1, False),
|
||
RawPoint(T0, 0.2, False)]))
|
||
check("a plugin returning something that is not a RawPoint is refused",
|
||
boundary([PricePoint(brussels(T0), 0.1, False)]))
|
||
check("an empty list is refused", boundary([]))
|
||
# ...and the negative-price control, so the rules above are not just "reject
|
||
# everything". A validator that refused this would cost money on a windy Sunday.
|
||
check("a NEGATIVE price at a plausible magnitude is accepted",
|
||
not boundary([RawPoint(T0, -0.35, False)]))
|
||
check("...and so is a large-but-plausible one at the limit",
|
||
not boundary([RawPoint(T0, MAX_PLAUSIBLE_EUR_KWH - 0.01, False)]))
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("10. egress is declared by the plugin and enforced (§16)")
|
||
_g = PROVIDERS["generic_dayahead"].capabilities()
|
||
check("a declared https host is allowed",
|
||
check_egress("https://api.energy-charts.info/price?bzn=BE", _g)
|
||
== "api.energy-charts.info")
|
||
check("an undeclared host is refused even over TLS",
|
||
raises(lambda: check_egress("https://evil.example/p", _g),
|
||
SourceUnavailable))
|
||
# M7, AND THE ASSERTION IS THE FIX, NOT ONLY THE PARSER. Deleting the TLS rule
|
||
# used to leave this check GREEN: the old parser stripped a fixed
|
||
# len("https://") from the front, so `http://api.energy-charts.info/p` became
|
||
# the host `pi.energy-charts.info`, which failed the ALLOW-LIST instead. The
|
||
# feed was refused, the check named "plain http is refused" passed, and the
|
||
# rule it was named for could be deleted with no consequence - an assertion
|
||
# that does not test what it names, inside a security check.
|
||
#
|
||
# Three checks now, and the first two are what make the third able to fail:
|
||
# prove the host is declared over https, prove it parses identically over
|
||
# http, and only then assert that http is refused AND that the reason is TLS.
|
||
_tls_url = "http://api.energy-charts.info/p"
|
||
check("the same host over https is allowed, so it IS on the allow-list",
|
||
derived(lambda: check_egress("https://api.energy-charts.info/p", _g))
|
||
== "api.energy-charts.info")
|
||
check("...and the http form parses to that same declared host, so nothing but "
|
||
"the TLS rule can refuse it",
|
||
derived(lambda: _tls_url.partition("://")[2].split("/", 1)[0])
|
||
== "api.energy-charts.info")
|
||
_tls_reason = derived(lambda: _reason(lambda: check_egress(_tls_url, _g)))
|
||
check(f"plain http is refused, and refused BY THE TLS RULE - {_tls_reason!r}",
|
||
isinstance(_tls_reason, str) and "non-TLS" in _tls_reason)
|
||
check("a scheme that is neither is refused the same way, by TLS not by host",
|
||
"non-TLS" in str(derived(lambda: _reason(
|
||
lambda: check_egress("ftp://api.energy-charts.info/p", _g)))))
|
||
# ⚠️ M7b, WHICH I FOUND IN MY OWN FIX FOR M7 AND WHICH IS THE SAME DEFECT ONE
|
||
# LAYER DOWN. This check used to read `raises(..., SourceUnavailable)` and
|
||
# survived deleting the `if not sep` guard: with no guard,
|
||
# "api.energy-charts.info/p".partition("://") leaves scheme = the WHOLE string,
|
||
# so the TLS rule refuses it and the check named for the scheme guard passes.
|
||
# Refused either way - but the diagnostic an operator gets degrades from "not an
|
||
# absolute URL" to "uses 'api.energy-charts.info/p'" as a scheme, which is
|
||
# gibberish. Assert the reason, like M7 above.
|
||
_noscheme = derived(lambda: _reason(
|
||
lambda: check_egress("api.energy-charts.info/p", _g)))
|
||
check(f"a URL with no scheme is refused BY THE SCHEME GUARD, with a diagnostic "
|
||
f"that names the real problem - {_noscheme!r}",
|
||
isinstance(_noscheme, str) and "not an absolute URL" in _noscheme)
|
||
check("a userinfo prefix cannot smuggle a declared host past the check",
|
||
raises(lambda: check_egress(
|
||
"https://api.energy-charts.info@evil.example/p", _g),
|
||
SourceUnavailable))
|
||
check("an operator-supplied URL to another supplier's host is refused",
|
||
load("generic_dayahead",
|
||
{"price_feed_url": "https://api.eneco.be/eddy/prices"},
|
||
transport=canned(columnar_feed(EPEX_EUR_MWH))
|
||
).kind == "no_data")
|
||
_declared = {pid: cls.capabilities().egress_hosts
|
||
for pid, cls in PROVIDERS.items()}
|
||
check(f"every networked plugin declares at least one host, and manual_csv "
|
||
f"declares none {_declared}",
|
||
all(bool(h) for p, h in _declared.items() if p != "manual_csv")
|
||
and _declared["manual_csv"] == ())
|
||
check("the timeout the plugin passes to the transport is the configured one, "
|
||
"not a hardcoded default",
|
||
(lambda t: (load("generic_dayahead", {"price_timeout_s": 7},
|
||
transport=t), nth(t.calls, 0)[1] == 7.0)[1])(
|
||
canned(columnar_feed(EPEX_EUR_MWH))))
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("11. injection price is a separate scalar (AC4, §9.3)")
|
||
check(f"the shipped default is config.yaml's "
|
||
f"{PRICE_OPTION_DEFAULTS['export_price_eur_kwh']}",
|
||
export_price_eur_kwh({}) == PRICE_OPTION_DEFAULTS["export_price_eur_kwh"])
|
||
check("an operator's value wins over it",
|
||
export_price_eur_kwh({"export_price_eur_kwh": 0.0}) == 0.0)
|
||
check("...including a negative one - Belgian injection has gone below zero",
|
||
export_price_eur_kwh({"export_price_eur_kwh": -0.02}) == -0.02)
|
||
check("it is NOT folded into the consumption series any plugin emits",
|
||
derived(lambda: all(export_price_eur_kwh({}) not in
|
||
[p.eur_per_kwh for p in r.points]
|
||
for r in READS.values())) is True)
|
||
check("...and no plugin can reach it to fold it in",
|
||
raises(lambda: PluginOptions({}).get("export_price_eur_kwh"),
|
||
FrameworkOption))
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("12. the startup gate (validate.py rule 7)")
|
||
|
||
|
||
def vbase(**over):
|
||
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||
"meter_source": "off",
|
||
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
||
o.update(over)
|
||
return o
|
||
|
||
|
||
def why(problems, needle):
|
||
return any(needle in p for p in problems)
|
||
|
||
|
||
check("the shipped price defaults start (price_provider is 'off')",
|
||
validate_startup_config(vbase(**{k: v for k, v in
|
||
PRICE_OPTION_DEFAULTS.items()}),
|
||
-5000, 5000) == [])
|
||
check("...and so does a configured provider on its own",
|
||
validate_startup_config(vbase(price_provider="generic_dayahead"),
|
||
-5000, 5000) == [])
|
||
check("an unknown provider id is refused rather than defaulted to 'off'",
|
||
why(validate_startup_config(vbase(price_provider="nordpool"), -5000, 5000),
|
||
"which does not exist"))
|
||
|
||
# ⚠️ TARIFF-07: A RETIRED ID IS REFUSED BY A DIFFERENT MESSAGE, AND EVERY CHECK
|
||
# HERE GRADES THE REASON RATHER THAN THE VERDICT. The failure this section
|
||
# exists to catch is NOT "a retired id starts" - it never will, because it is
|
||
# absent from PROVIDERS and the unknown-id branch would catch it. It is the
|
||
# retired branch COLLAPSING INTO the unknown-id one: same refusal, same exit
|
||
# code, and an operator told they mistyped a name they chose from a dropdown
|
||
# this add-on offered them. The two messages are asserted to DIFFER and each
|
||
# needle is asserted to be absent from the other, which is MUTATION.md's
|
||
# "grade the reason, not the verdict" applied to two rungs that answer one
|
||
# stimulus.
|
||
_unknown = validate_startup_config(vbase(price_provider="nordpool"),
|
||
-5000, 5000)
|
||
_retired = {pid: validate_startup_config(vbase(price_provider=pid),
|
||
-5000, 5000)
|
||
for pid in sorted(RETIRED_PROVIDERS)}
|
||
check(f"every retired id is refused at startup {sorted(_retired)}",
|
||
len(_retired) == 3 and all(len(v) == 1 for v in _retired.values()))
|
||
check("...and NOT by the unknown-id message, which would tell an operator they "
|
||
"mistyped an id this add-on offered them and accepted for months",
|
||
all("which does not exist" not in v[0] for v in _retired.values())
|
||
and "was withdrawn" not in _unknown[0])
|
||
check("...and each message NAMES ITS OWN ID, so the operator can tell it is "
|
||
"about their configuration and not a generic notice",
|
||
all(repr(pid) in v[0] for pid, v in _retired.items()))
|
||
# UX-08 2c: the endpoints that never existed are in DOCS.md under "Energy price
|
||
# providers"; the refusal keeps what the operator acts on.
|
||
check("...and (a) says the plugin never fetched a price",
|
||
all("never fetched a price" in v[0] for v in _retired.values()))
|
||
check("...and (b) says what the site has ACTUALLY been running instead - the "
|
||
"half §11.4 made invisible",
|
||
all("This site ran on" in v[0] for v in _retired.values()))
|
||
check("...and (c) says what to do, naming a live plugin",
|
||
all("generic_dayahead" in v[0] and "manual_csv" in v[0]
|
||
for v in _retired.values()))
|
||
|
||
# ⚠️ AND THE SENTENCE CHANGES WITH THE SITE. "You have been on the static
|
||
# profile" is FALSE where no profile was ever set, and that site is in a WORSE
|
||
# state - no tariff at all - so the message must not flatten the two. Graded by
|
||
# asserting the two renderings DIFFER, because a check that only looked for
|
||
# "static_profile" would pass on a message that says the same thing to both.
|
||
_PROF = ('{"periods": [{"from":"00:00","to":"24:00","days":"all",'
|
||
'"eur_per_kwh":0.31}]}')
|
||
_with_profile = validate_startup_config(
|
||
vbase(price_provider="eneco_eddy", static_profile=_PROF), -5000, 5000)[0]
|
||
_without = _retired["eneco_eddy"][0]
|
||
check("a retired id on a site WITH a static profile is told it has been "
|
||
"running that profile",
|
||
"ran on the “Fixed or day/night tariff” (static_profile)"
|
||
in _with_profile)
|
||
check("...and a site WITHOUT one is told it has been running nothing at all, "
|
||
"which is a different and worse state",
|
||
"ran on no tariff at all" in _without
|
||
and "(static_profile)" not in _without)
|
||
check("...so the two renderings are not the same sentence",
|
||
_with_profile != _without)
|
||
|
||
# ⚠️ THE HA OVERLAP, DECIDED. The two entities keep working alone; combining
|
||
# them with a provider is what is refused, because the entities win silently.
|
||
_p = validate_startup_config(
|
||
vbase(price_provider="generic_dayahead", price_now_entity="sensor.now",
|
||
price_avg_entity="sensor.avg"), -5000, 5000)
|
||
check("a provider AND the Home Assistant price entities is refused - two price "
|
||
"sources on one site", len(_p) == 1)
|
||
check("...and the message says which one actually wins",
|
||
why(_p, "would win over it"))
|
||
# TARIFF-03 added a second source with the same silent-loser shape.
|
||
_ps = validate_startup_config(
|
||
vbase(static_profile='{"periods": [{"from":"00:00","to":"24:00",'
|
||
'"days":"all","eur_per_kwh":0.31}]}',
|
||
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
|
||
-5000, 5000)
|
||
check("a static_profile AND the Home Assistant price entities is refused too - "
|
||
"main.py reads one of them for `cheap` and the loser is silent",
|
||
len(_ps) == 1 and why(_ps, "(price_now_entity) and “Average price entity” (price_avg_entity) would win"))
|
||
check("the Home Assistant entities alone still start, so nothing is taken away "
|
||
"from a live install",
|
||
validate_startup_config(vbase(price_now_entity="sensor.now",
|
||
price_avg_entity="sensor.avg"),
|
||
-5000, 5000) == [])
|
||
# ⚠️ THIS TRIPWIRE FIRED, AND THIS IS WHAT IT LOOKS LIKE AFTERWARDS.
|
||
#
|
||
# TARIFF-02 left a check here asserting that NOTHING in app/ consumed
|
||
# `load_prices()`, because rule 7's stated reason - "the supplier you configured
|
||
# would decide nothing" - was true only for as long as that held. The
|
||
# instruction attached to it was: when this goes red, update the message in
|
||
# validate.py in the same commit, do not delete the check.
|
||
#
|
||
# TARIFF-03 landed `app/static_tariff.py`, whose `resolve_tariff()` consumes
|
||
# `load_prices()` and turns a series into a tariff plan, a cheap-window flag and
|
||
# an arbitrage verdict. The check went red, naming that file, and validate.py's
|
||
# message was rewritten in the same commit to say what is true now instead. So
|
||
# the tripwire is KEPT and RE-AIMED rather than removed, at the next thing that
|
||
# can go quietly wrong:
|
||
#
|
||
# * the consumer set is PINNED. A second consumer appearing - OPT-04 wiring a
|
||
# strategy in, most likely - goes red again, and whoever does that has to
|
||
# re-read rule 7's message against what their change makes true.
|
||
# * the EXPIRED PHRASE is asserted GONE. If the old wording is ever restored
|
||
# by a bad merge, this says so.
|
||
# * and the message has to keep naming its consumer, so it cannot decay into
|
||
# a refusal with no stated reason at all.
|
||
_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app")
|
||
# ⚠️ AST, NOT A SUBSTRING, AND THE FIRST DRAFT PROVED WHY. A text scan for
|
||
# "load_prices" matched the COMMENT in validate.py that explains this very
|
||
# tripwire, so the check went red against its own documentation - the same
|
||
# false-positive class as the plugin docstrings in section 4b, hit twice in one
|
||
# ticket. Comments and docstrings are not in an AST, so a name/import walk sees
|
||
# code and only code.
|
||
def _imports_or_calls(path, name):
|
||
try:
|
||
tree = ast.parse(open(path, encoding="utf-8").read())
|
||
except (OSError, SyntaxError):
|
||
return False
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.ImportFrom) and any(a.name == name
|
||
for a in node.names):
|
||
return True
|
||
if isinstance(node, ast.Name) and node.id == name:
|
||
return True
|
||
if isinstance(node, ast.Attribute) and node.attr == name:
|
||
return True
|
||
return False
|
||
|
||
|
||
_consumers = derived(lambda: sorted(
|
||
f for f in os.listdir(_APP)
|
||
if f.endswith(".py") and f != "price.py"
|
||
and _imports_or_calls(os.path.join(_APP, f), "load_prices")))
|
||
_EXPECTED_CONSUMERS = ["static_tariff.py"]
|
||
check(f"load_prices() is consumed by exactly the modules rule 7's message "
|
||
f"names {_consumers}. When a SECOND one appears this goes red, and the "
|
||
f"fix is to re-read that message against what the new consumer makes "
|
||
f"true - not to widen this list without doing so",
|
||
_consumers == _EXPECTED_CONSUMERS)
|
||
_RULE7 = derived(lambda: open(os.path.join(_APP, "validate.py"),
|
||
encoding="utf-8").read())
|
||
check("...and the phrase the tripwire expired - 'would decide nothing' about "
|
||
"the provider - is GONE from validate.py, so a bad merge that restores "
|
||
"the old reason is red rather than quiet",
|
||
isinstance(_RULE7, str)
|
||
and "supplier you configured would decide nothing" not in _RULE7)
|
||
check("...and the refusal still names the consumer that exists, so it cannot "
|
||
"decay into a rule with no stated reason",
|
||
isinstance(_RULE7, str) and "static_tariff.py" in _RULE7)
|
||
# ...and the other half: the registry IS on the startup path, which is what
|
||
# makes an unwired seam anchored rather than dead code.
|
||
check("...while validate.py does import the registry, so the seam is exercised "
|
||
"on every boot",
|
||
derived(lambda: "PROVIDERS" in open(
|
||
os.path.join(_APP, "validate.py"), encoding="utf-8").read()) is True)
|
||
|
||
check("manual_csv with no path is refused - the offline fallback with no file "
|
||
"is §11.4 reached by accident",
|
||
why(validate_startup_config(vbase(price_provider="manual_csv",
|
||
price_csv_path=""), -5000, 5000),
|
||
"price_csv_path"))
|
||
check("...and with a path it starts",
|
||
validate_startup_config(vbase(price_provider="manual_csv",
|
||
price_csv_path=CSV_PATH),
|
||
-5000, 5000) == [])
|
||
# ⚠️ price_provider 'off' must NEVER become a refusal: §9.2 puts a large share
|
||
# of the installed base on a fixed or static tariff, and §11.4 makes no-price
|
||
# data a supported state.
|
||
check("'off' is not refused, on any of the other configurations",
|
||
all(validate_startup_config(vbase(price_provider="off", **extra),
|
||
-5000, 5000) == []
|
||
for extra in ({}, {"price_now_entity": "sensor.now",
|
||
"price_avg_entity": "sensor.avg"},
|
||
{"price_csv_path": ""})))
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("13. TARIFF-10 - a configured provider is FETCHED, and trading is still "
|
||
"its own switch")
|
||
# WHAT SHIPPED, AND WHY THIS SECTION IS NOT A CHECK ON AN `if`. `amain()` read
|
||
#
|
||
# if arbitrage.enabled(opts):
|
||
# tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||
#
|
||
# `run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
|
||
# only caller of `price_history.carry()`, and `arbitrage_enabled` ships FALSE.
|
||
# So on a shipped install with a provider configured NOTHING was fetched, no
|
||
# plan was resolved, /data/price_history.json was never created, the *Price
|
||
# history* row read `empty` for the life of the install, and TARIFF-09's
|
||
# short-horizon warning could not fire because no fetch happened to warn about.
|
||
# Measured over a 40 h two-instance soak: zero feed requests and
|
||
# `present: false` across all 802 samples.
|
||
#
|
||
# ⚠️ A CHECK THAT PINS THE `if` WOULD NOT HAVE CAUGHT IT, because the `if` was
|
||
# EXACTLY WHAT SOMEBODY MEANT TO WRITE. What was wrong is the SET OF
|
||
# CONFIGURATIONS FROM WHICH A FETCH CAN BE REACHED, so that is what is graded:
|
||
# the guards in `amain()` are EVALUATED against real option dicts (whatever they
|
||
# are spelled as), the call graph under `app/` is walked to see whether the
|
||
# tasks those guards start can arrive at `resolve_tariff` (wherever it has been
|
||
# moved to), and then the fetch is DRIVEN and the requests counted, because a
|
||
# reachability graph is an argument and a counted request is an observation.
|
||
|
||
|
||
# -- the predicate the gate is built on, before anything is wired to it -------
|
||
# ⚠️ ONE SPELLING, AND IT NOW HAS THREE READERS. `str(price_option(opts,
|
||
# "price_provider") or "off").strip()` was written out TWICE - in
|
||
# `load_prices()` and in `static_tariff.resolve_tariff()` - and main.py's
|
||
# startup gate would have been a third. "Is a provider configured" is now a
|
||
# DECISION rather than a detail, because it is what starts a background task,
|
||
# so the three readers have to agree about the edge cases and `provider_id()`
|
||
# is the one place they can. (`validate.py` keeps its own reader on purpose: it
|
||
# validates the RAW options blob and must not route through the defaults.)
|
||
check("`provider_configured()` is False on the shipped default, on an explicit "
|
||
"'off', and on a key that is absent, empty or None - `price_option()` "
|
||
"maps the last three onto the default and none of them may read as a "
|
||
"configured feed",
|
||
not any(_price.provider_configured(o) for o in
|
||
({}, {"price_provider": "off"}, {"price_provider": ""},
|
||
{"price_provider": None})))
|
||
check("...and True on each shipped plugin id, so it is a predicate and not a "
|
||
f"constant {sorted(PROVIDERS)}",
|
||
all(_price.provider_configured({"price_provider": pid})
|
||
for pid in PROVIDERS))
|
||
# ⚠️ WHITESPACE IS THE EDGE THE `.strip()` IS FOR, AND NOTHING GRADED IT. The
|
||
# Supervisor's `list(off|...)` schema cannot produce `" off "`, but
|
||
# `validate_startup_config()` is also sim/inject.py's entry point and takes any
|
||
# dict at all - and dropping the `.strip()` left all four price suites green
|
||
# (measured, -8 B) until this pair was written.
|
||
check("...and a padded id is normalised on both sides of the decision: "
|
||
"' off ' is still off, and ' generic_dayahead ' is still that plugin",
|
||
_price.provider_id({"price_provider": " off "}) == "off"
|
||
and not _price.provider_configured({"price_provider": " off "})
|
||
and _price.provider_id({"price_provider": " generic_dayahead "})
|
||
== "generic_dayahead"
|
||
and _price.provider_configured(
|
||
{"price_provider": " generic_dayahead "}))
|
||
check("...and `load_prices()` answers `no_data` for exactly the dicts "
|
||
"`provider_configured()` calls unconfigured, so the startup gate and the "
|
||
"fetch cannot disagree about what 'a provider is set' means",
|
||
all((run(load_prices(o, datetime(2026, 1, 1, tzinfo=UTC),
|
||
datetime(2026, 1, 2, tzinfo=UTC),
|
||
transport=None)).kind == "no_data")
|
||
== (not _price.provider_configured(o))
|
||
for o in ({}, {"price_provider": "off"},
|
||
{"price_provider": " off "},
|
||
{"price_provider": "manual_csv",
|
||
"price_csv_path": CSV_PATH})))
|
||
|
||
|
||
def _coroutine_name(node):
|
||
"""`controller.run_tariff_refresh()` -> `run_tariff_refresh`."""
|
||
if isinstance(node, ast.Call):
|
||
if isinstance(node.func, ast.Attribute):
|
||
return node.func.attr
|
||
if isinstance(node.func, ast.Name):
|
||
return node.func.id
|
||
return ast.unparse(node)
|
||
|
||
|
||
def _task_sites(node, guards=()):
|
||
"""Every `create_task(...)` under `node`, with the `if`s that guard it.
|
||
|
||
The guard is kept as SOURCE and evaluated later against an options dict, so
|
||
this reports what the tree says rather than what any one ticket expected to
|
||
find there. An `else` branch is recorded as `not (<test>)`.
|
||
"""
|
||
out = []
|
||
for child in ast.iter_child_nodes(node):
|
||
if isinstance(child, ast.If):
|
||
test = ast.unparse(child.test)
|
||
for stmt in child.body:
|
||
out += _task_sites(stmt, guards + (test,))
|
||
for stmt in child.orelse:
|
||
out += _task_sites(stmt, guards + (f"not ({test})",))
|
||
continue
|
||
if isinstance(child, ast.Call) and \
|
||
ast.unparse(child.func).endswith("create_task"):
|
||
if child.args:
|
||
out.append((guards, _coroutine_name(child.args[0])))
|
||
continue
|
||
out += _task_sites(child, guards)
|
||
return out
|
||
|
||
|
||
def _call_graph(app_dir):
|
||
"""name -> the names it calls, over every module in `app/`.
|
||
|
||
⚠️ IT IS KEYED BY NAME AND THEREFORE OVER-APPROXIMATES, AND THAT IS THE
|
||
SAFE DIRECTION HERE. Two functions called `run` in two modules are one node;
|
||
a method call on an unrelated object joins the same node. So a `reachable`
|
||
answer is an upper bound and a `NOT reachable` answer is the strong one -
|
||
which is why every positive row below is also DRIVEN, with the requests
|
||
counted, rather than being left to this graph (MUTATION.md habit 13).
|
||
"""
|
||
graph = {}
|
||
for name in sorted(os.listdir(app_dir)):
|
||
if not name.endswith(".py"):
|
||
continue
|
||
tree = ast.parse(open(os.path.join(app_dir, name),
|
||
encoding="utf-8").read())
|
||
for node in ast.walk(tree):
|
||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
continue
|
||
called = set()
|
||
for inner in ast.walk(node):
|
||
if isinstance(inner, ast.Call):
|
||
fn = inner.func
|
||
if isinstance(fn, ast.Attribute):
|
||
called.add(fn.attr)
|
||
elif isinstance(fn, ast.Name):
|
||
called.add(fn.id)
|
||
graph.setdefault(node.name, set()).update(called)
|
||
return graph
|
||
|
||
|
||
def _reaches(graph, start, target):
|
||
"""Can `start` arrive at `target` through the call graph?"""
|
||
seen, stack = set(), [start]
|
||
while stack:
|
||
cur = stack.pop()
|
||
if cur in seen:
|
||
continue
|
||
seen.add(cur)
|
||
for nxt in graph.get(cur, ()):
|
||
if nxt == target:
|
||
return True
|
||
stack.append(nxt)
|
||
return False
|
||
|
||
|
||
def _started(sites, opts):
|
||
"""[(coroutine, guards, verdict)] for `opts`. Verdict True/False/'?'.
|
||
|
||
`'?'` is a guard this cannot decide from the options alone - `p1_source is
|
||
not None` is one - and it is reported rather than assumed either way. A
|
||
fetch-reaching task whose guard is undecidable would make every row below
|
||
vacuous, so that is a check of its own.
|
||
"""
|
||
out = []
|
||
for guards, name in sites:
|
||
verdict = True
|
||
for guard in guards:
|
||
try:
|
||
answer = bool(eval(guard, vars(_main), {"opts": opts}))
|
||
except NameError:
|
||
verdict = "?"
|
||
break
|
||
if not answer:
|
||
verdict = False
|
||
break
|
||
out.append((name, guards, verdict))
|
||
return out
|
||
|
||
|
||
_MAIN_TREE = derived(lambda: ast.parse(open(os.path.join(_APP, "main.py"),
|
||
encoding="utf-8").read()))
|
||
_AMAIN = derived(lambda: next((n for n in ast.walk(_MAIN_TREE)
|
||
if isinstance(n, ast.AsyncFunctionDef)
|
||
and n.name == "amain"), None))
|
||
_SITES = derived(lambda: _task_sites(_AMAIN))
|
||
_GRAPH = derived(lambda: _call_graph(_APP))
|
||
_FETCH_ENTRY = "resolve_tariff"
|
||
|
||
print(f" create_task sites found in amain(): "
|
||
f"{[(n, list(g)) for g, n in _SITES]}")
|
||
print(f" call graph: {len(_GRAPH)} named functions across app/")
|
||
print(" ⚠️ what this instrument CANNOT see, largest hole first: a coroutine "
|
||
"started by anything other than `create_task` (gather, ensure_future, a "
|
||
"thread, a timer); a guard that is not an enclosing `if` in amain() (a "
|
||
"while, a try/except, an early return above the site, or a refusal "
|
||
"INSIDE the coroutine); any module outside app/; and - because the graph "
|
||
"is keyed by NAME - two same-named functions as one node, which makes "
|
||
"every `reachable` answer an UPPER bound.")
|
||
|
||
# -- the instrument is calibrated before anything is graded with it (habit 1) --
|
||
check(f"the scanner finds amain()'s create_task sites at all - it found "
|
||
f"{len(_SITES) if not isinstance(_SITES, _Raised) else _SITES}, and at "
|
||
f"zero every row below would be vacuously true",
|
||
not isinstance(_SITES, _Raised) and len(_SITES) >= 2)
|
||
check(f"...and the call graph can tell reaching from not-reaching: "
|
||
f"`run_tariff_refresh` arrives at {_FETCH_ENTRY!r} and `run_control` - "
|
||
f"the control tick, which must never await a fetch - does not. A graph "
|
||
f"that answered the same for both would pass every row below however the "
|
||
f"gate was wired",
|
||
_reaches(_GRAPH, "run_tariff_refresh", _FETCH_ENTRY)
|
||
and not _reaches(_GRAPH, "run_control", _FETCH_ENTRY))
|
||
|
||
|
||
def _fetchers(opts):
|
||
"""The coroutines amain() STARTS, for `opts`, that can reach the feed."""
|
||
return sorted(n for n, _, v in _started(_SITES, opts)
|
||
if v is True and _reaches(_GRAPH, n, _FETCH_ENTRY))
|
||
|
||
|
||
def _undecidable_fetchers(opts):
|
||
return sorted(n for n, _, v in _started(_SITES, opts)
|
||
if v == "?" and _reaches(_GRAPH, n, _FETCH_ENTRY))
|
||
|
||
|
||
# The four configurations. `_T10_BASE` carries no price keys at all, so the
|
||
# shipped defaults in PRICE_OPTION_DEFAULTS are what answer - which is the
|
||
# install the defect was measured on.
|
||
_T10_BASE = {"setpoint_entity": "number.sp", "max_w": 5000,
|
||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||
"meter_source": "off", "maintenance_charge_w": 2000,
|
||
"maintenance_discharge_w": 2000}
|
||
_T10_PROFILE = json.dumps({"periods": [
|
||
{"name": "night", "days": "all", "from": "22:00", "to": "07:00",
|
||
"eur_per_kwh": 0.20},
|
||
{"name": "day", "days": "all", "from": "07:00", "to": "22:00",
|
||
"eur_per_kwh": 0.284}]})
|
||
_T10_CASES = (
|
||
("shipped default: no provider, no trading", {}, False),
|
||
("PROVIDER configured, trading OFF (the defect)",
|
||
{"price_provider": "generic_dayahead"}, True),
|
||
("PROVIDER configured, trading ON",
|
||
{"price_provider": "generic_dayahead", "arbitrage_enabled": True}, True),
|
||
("no provider, trading ON on a static profile",
|
||
{"arbitrage_enabled": True, "static_profile": _T10_PROFILE}, True),
|
||
)
|
||
print(" startup reachability of resolve_tariff(), by configuration:")
|
||
for _label, _over, _want in _T10_CASES:
|
||
print(f" {'reachable' if _fetchers(dict(_T10_BASE, **_over)) else '- '}"
|
||
f" {_label}")
|
||
|
||
check(f"the shipped defaults really do leave `arbitrage_enabled` off "
|
||
f"({PRICE_OPTION_DEFAULTS['arbitrage_enabled']!r}) and `price_provider` "
|
||
f"off ({PRICE_OPTION_DEFAULTS['price_provider']!r}) - the precondition "
|
||
f"every row below varies from, and if a later card changes either "
|
||
f"default these rows stop meaning what they say. Section 1 is what pins "
|
||
f"this dict to config.yaml",
|
||
PRICE_OPTION_DEFAULTS["price_provider"] == "off"
|
||
and PRICE_OPTION_DEFAULTS["arbitrage_enabled"] is False
|
||
and not _arb.enabled(dict(_T10_BASE))
|
||
and not _price.provider_configured(dict(_T10_BASE)))
|
||
check(f"AC1: with a `price_provider` set and `arbitrage_enabled` at its shipped "
|
||
f"default, the startup path REACHES the feed "
|
||
f"{_fetchers(dict(_T10_BASE, price_provider='generic_dayahead'))}. This "
|
||
f"is the shipped defect: it read `[]`, so nothing was ever fetched",
|
||
_fetchers(dict(_T10_BASE, price_provider="generic_dayahead")) != [])
|
||
check(f"AC2: with `price_provider: off` and trading off - the true shipped "
|
||
f"default - NO task reaches the feed "
|
||
f"{_fetchers(dict(_T10_BASE))}, so an install that asked for no prices "
|
||
f"acquires no periodic work from this card",
|
||
_fetchers(dict(_T10_BASE)) == [])
|
||
check("...and the guard on the fetch-reaching task is decidable from the "
|
||
"OPTIONS alone. A gate moved onto a local variable would make every row "
|
||
"in this section vacuous rather than red",
|
||
_undecidable_fetchers(dict(_T10_BASE, price_provider="generic_dayahead"))
|
||
== [])
|
||
# ⚠️ THE `or` IS LOAD-BEARING AND THIS IS THE ROW THAT SAYS SO. `price_provider:
|
||
# off` + a `static_profile` + `arbitrage_enabled: true` is a configuration
|
||
# validate.py ACCEPTS and that trades (measured: ARBITRAGE_CHARGE -5000 W at
|
||
# `cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile). Gating
|
||
# the refresh on the provider ALONE would leave that site's `tariff_plan` at
|
||
# None forever and silently disarm a strategy the UI says is running.
|
||
_T10_OLD = {lbl: _arb.enabled(dict(_T10_BASE, **over))
|
||
for lbl, over, _ in _T10_CASES}
|
||
_T10_NEW = {lbl: bool(_fetchers(dict(_T10_BASE, **over)))
|
||
for lbl, over, _ in _T10_CASES}
|
||
print(f" old gate (arbitrage.enabled alone) -> {list(_T10_OLD.values())}")
|
||
print(f" shipped gate -> {list(_T10_NEW.values())}")
|
||
check(f"the gate is a STRICT WIDENING: every configuration that started the "
|
||
f"refresh before still starts it, so nothing was taken away from a live "
|
||
f"install {[l for l in _T10_OLD if _T10_OLD[l] and not _T10_NEW[l]]}",
|
||
all(_T10_NEW[l] for l in _T10_OLD if _T10_OLD[l]))
|
||
check("...and it is a widening rather than a no-op: at least one configuration "
|
||
"fetches now that did not before, which is the whole ticket",
|
||
any(_T10_NEW[l] and not _T10_OLD[l] for l in _T10_OLD))
|
||
|
||
|
||
# -- and the fetch is DRIVEN, because a graph is an argument and a counted ----
|
||
# -- request is an observation (habit 4) -------------------------------------
|
||
_CAPTURE = derived(lambda: json.load(open(
|
||
os.path.join(HERE, "scenarios",
|
||
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json"),
|
||
encoding="utf-8")))
|
||
|
||
|
||
def _restamped_body(now):
|
||
"""The COMMITTED capture, with only its instants moved onto `now`.
|
||
|
||
⚠️ THE SHAPE AND THE PRICES ARE THE CAPTURE'S (TARIFF-08, habit 19). What
|
||
is synthetic is the CLOCK and nothing else: `run_tariff_refresh` reads the
|
||
wall clock and there is no seam to inject one, so a 2026-09-09 capture
|
||
would fall outside every horizon it asks for. Section 2b replays the
|
||
capture unmodified against the parser; this one is grading the WIRING.
|
||
"""
|
||
base = int(now.timestamp()) // 3600 * 3600
|
||
doc = dict(_CAPTURE)
|
||
doc["unix_seconds"] = [base + 900 * i
|
||
for i in range(len(_CAPTURE["price"]))]
|
||
return json.dumps(doc).encode()
|
||
|
||
|
||
class _T10Hass:
|
||
async def limits(self, entity):
|
||
return (-5000.0, 5000.0, 1.0)
|
||
|
||
async def number(self, entity, invert=False):
|
||
return {"sensor.grid": 0.0, "sensor.soc": 55.0,
|
||
"sensor.batt": 0.0}.get(entity)
|
||
|
||
async def set_number(self, entity, value):
|
||
return True
|
||
|
||
async def call(self, domain, service, data):
|
||
return True
|
||
|
||
|
||
class _T10Store:
|
||
data = {}
|
||
|
||
def set(self, *a):
|
||
pass
|
||
|
||
def get_time(self, key):
|
||
return None
|
||
|
||
|
||
class _T10Pub:
|
||
enabled = False
|
||
|
||
def publish(self, *a, **k):
|
||
pass
|
||
|
||
def close(self):
|
||
pass
|
||
|
||
|
||
def _drive_startup(over):
|
||
"""Start exactly what amain() would start for `opts`, and watch the wire.
|
||
|
||
⚠️ THE TASK IS STARTED ONLY IF THE SHIPPED GUARD SAYS SO. That is the whole
|
||
point: the coroutine is not called because this file knows its name, it is
|
||
called because `_fetchers()` evaluated amain()'s own guard against these
|
||
options. A gate that goes back to `arbitrage.enabled(opts)` leaves the
|
||
coroutine uncalled and the request count at zero.
|
||
|
||
⚠️ AND IT RUNS UNDER A DEADLINE (habit 5). The refresh loop is infinite by
|
||
design; the failure mode of a mutation that never resolves a plan is a HANG,
|
||
which reads like flaky infrastructure rather than like a named check.
|
||
"""
|
||
opts = dict(_T10_BASE, **over)
|
||
tmp = tempfile.mkdtemp(prefix="tariff10-")
|
||
_tmpdirs_t10.append(tmp)
|
||
store_path = os.path.join(tmp, "price_history.json")
|
||
seen = []
|
||
|
||
async def _counting_get(url, timeout_s):
|
||
seen.append(url)
|
||
return _restamped_body(datetime.now(timezone.utc))
|
||
|
||
real_get = _price.https_get
|
||
_price.https_get = _counting_get
|
||
try:
|
||
async def _go():
|
||
stop = asyncio.Event()
|
||
ctl = _main.Controller(opts, _T10Hass(), _T10Store(), _T10Pub(),
|
||
stop=stop,
|
||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||
price_history_path=store_path)
|
||
names = _fetchers(opts)
|
||
if "run_tariff_refresh" not in names:
|
||
# Nothing amain() starts reaches the feed. Anything ELSE that
|
||
# does is a task this harness cannot drive, and it is a FAILURE
|
||
# rather than a shrug - see the check below.
|
||
return ctl, names
|
||
task = asyncio.create_task(ctl.run_tariff_refresh())
|
||
for _ in range(250): # ~5 s ceiling
|
||
if ctl.tariff_at is not None:
|
||
break
|
||
await asyncio.sleep(0.02)
|
||
ctl.stopping = True
|
||
stop.set()
|
||
await asyncio.wait_for(task, timeout=5)
|
||
return ctl, names
|
||
ctl, names = asyncio.run(_go())
|
||
finally:
|
||
_price.https_get = real_get
|
||
try:
|
||
n_points = len(list(ctl.tariff_plan.read.points))
|
||
except Exception: # noqa: BLE001 - Absence
|
||
n_points = 0
|
||
return {"requests": seen, "started": names,
|
||
"store_present": os.path.exists(store_path),
|
||
"points": n_points,
|
||
"history": ctl.price_history.words}
|
||
|
||
|
||
_tmpdirs_t10 = []
|
||
_on = derived(lambda: _drive_startup({"price_provider": "generic_dayahead"}))
|
||
_off = derived(lambda: _drive_startup({}))
|
||
_static_armed = derived(lambda: _drive_startup(
|
||
{"arbitrage_enabled": True, "static_profile": _T10_PROFILE}))
|
||
print(f" provider + trading OFF -> {len(_on['requests'])} request(s), "
|
||
f"store written {_on['store_present']}, {_on['points']} point plan, "
|
||
f"Price history row: {_on['history'][:70]!r}")
|
||
print(f" no provider -> {len(_off['requests'])} request(s), "
|
||
f"store written {_off['store_present']}, "
|
||
f"Price history row: {_off['history'][:40]!r}")
|
||
|
||
check(f"AC1, DRIVEN: a shipped-default install with a provider configured "
|
||
f"actually ASKS THE FEED - {len(_on['requests'])} request(s), "
|
||
f"{_on['requests'][:1]}. The soak measured ZERO here over 40 hours",
|
||
len(_on["requests"]) >= 1)
|
||
check(f"...and a plan RESOLVES from it: {_on['points']} points, against the "
|
||
f"{len(_CAPTURE['price'])} the capture publishes",
|
||
_on["points"] == len(_CAPTURE["price"]))
|
||
check(f"...and /data/price_history.json is WRITTEN - present={_on['store_present']}. "
|
||
f"The soak measured `present: false` on all 802 samples",
|
||
_on["store_present"] is True)
|
||
check(f"...and the *Price history* status row POPULATES rather than reading "
|
||
f"`empty` forever: {_on['history'][:60]!r}",
|
||
isinstance(_on["history"], str)
|
||
and not _on["history"].startswith("empty")
|
||
and not _on["history"].startswith("unavailable"))
|
||
check(f"AC2, DRIVEN: `price_provider: off` issues NO request and creates no "
|
||
f"store - {len(_off['requests'])} request(s), "
|
||
f"present={_off['store_present']}",
|
||
_off["requests"] == [] and _off["store_present"] is False)
|
||
check(f"...and neither does the one configuration that keeps its task without "
|
||
f"a provider - trading armed on a static profile makes "
|
||
f"{len(_static_armed['requests'])} request(s), because "
|
||
f"`resolve_tariff()` answers a provider-off site out of the static "
|
||
f"profile without opening a socket. NOBODY acquires a periodic HTTPS "
|
||
f"call from this card",
|
||
_static_armed["requests"] == []
|
||
and "run_tariff_refresh" in _static_armed["started"])
|
||
check(f"...and no OTHER startup task can reach the feed - if one appears, this "
|
||
f"harness cannot drive it and every driven row above stops covering the "
|
||
f"startup path {_on['started']}",
|
||
_on["started"] == ["run_tariff_refresh"])
|
||
|
||
|
||
# -- AC3, and it is the property this card is most dangerous to: ARBITRAGE -----
|
||
# -- OFF STILL MEANS NO TRADE, EVER. Graded by DRIVING `Controller.cycle()` ---
|
||
# -- with a provider plan resolved - the state TARIFF-10 newly creates - and ---
|
||
# -- moving exactly one option (habit 7). ------------------------------------
|
||
#
|
||
# ⚠️ THE BASELINE IS THE HALF THAT MAKES THIS A CHECK. "No trade" is satisfied
|
||
# by a fixture in which nothing could have traded anyway - a flat series, a SoC
|
||
# outside the bounds, a peak guard that never ran - and a check like that stays
|
||
# green however the gate is wired. So the SAME plan, the SAME SoC, the SAME P1
|
||
# history and the SAME threshold are run with `arbitrage_enabled: true`, and
|
||
# that run must COMMAND THE BATTERY. If the armed row stops trading, the
|
||
# unarmed row stops being evidence and both go red together.
|
||
|
||
|
||
def _t10_sample(when, net_w):
|
||
"""One P1 telegram through the shipped derivation."""
|
||
imp, exp = split_signed(net_w)
|
||
return make_sample("ha_signed", imp, exp, phases=1,
|
||
ingest_ts=when, ingest_mono=when.timestamp())
|
||
|
||
|
||
class _T10TradeHass(_T10Hass):
|
||
def __init__(self):
|
||
self.writes = []
|
||
|
||
async def set_number(self, entity, value):
|
||
self.writes.append(float(value))
|
||
return True
|
||
|
||
|
||
def _t10_plan(now, cheap_now):
|
||
"""A 24 h FIRM series whose cheapest hour is now, or whose dearest is.
|
||
|
||
Firm, because refusal 3 drops provisional hours from the trade set and a
|
||
plan of carried hours cannot trade whatever the gate says - which would
|
||
make the armed baseline below vacuous exactly as a flat series would.
|
||
"""
|
||
top = now.replace(minute=0, second=0, microsecond=0)
|
||
lo, hi = 0.05, 0.60
|
||
return TariffPlan(
|
||
PriceRead.series(
|
||
[PricePoint(start=top + timedelta(hours=i),
|
||
eur_per_kwh=(lo if (i < 3) == cheap_now else hi),
|
||
provisional=False)
|
||
for i in range(24)],
|
||
PriceProduct.DAY_AHEAD, "generic_dayahead", now),
|
||
None, "provider")
|
||
|
||
|
||
def _t10_trade_cycle(armed, cheap_now):
|
||
"""One real `Controller.cycle()` on a resolved provider plan."""
|
||
tmp = tempfile.mkdtemp(prefix="tariff10t-")
|
||
_tmpdirs_t10.append(tmp)
|
||
opts = dict(_T10_BASE, meter_source="ha_dsmr", meter_entity="sensor.grid",
|
||
heartbeat_s=10, price_provider="generic_dayahead",
|
||
cycle_cost_threshold_eur=0.10, arbitrage_enabled=armed)
|
||
|
||
async def _go():
|
||
hass = _T10TradeHass()
|
||
ctl = _main.Controller(opts, hass, _T10Store(), _T10Pub(),
|
||
stop=asyncio.Event(),
|
||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||
price_history_path=os.path.join(tmp, "ph.json"))
|
||
ctl.auto = True
|
||
ctl.dev_min, ctl.dev_max = -5000.0, 5000.0
|
||
now = datetime.now(timezone.utc)
|
||
# ⚠️ A WHOLE QUARTER OF TELEGRAMS, AND FIVE MINUTES IS NOT ENOUGH.
|
||
# Without any, `p1_enabled` is on, `inputs_ok` is False and cycle()
|
||
# returns before it reaches the strategy at all. With only five
|
||
# minutes, whether the quarter is COVERED depends on where the wall
|
||
# clock happens to sit inside it: run late in a quarter and the guard
|
||
# is blind, engages conservatively, and `decide()` refuses for §9.1's
|
||
# reason instead of trading - which would make the armed baseline
|
||
# below vacuous on some runs and not others. Measured: the same four
|
||
# rows came back `peak guard is holding` at one time of day and
|
||
# `ARBITRAGE_CHARGE` at another. 900 s at the shipped 5 s telegram
|
||
# cadence covers any quarter from any starting phase.
|
||
for k in range(181):
|
||
ctl.p1.submit(_t10_sample(now - timedelta(seconds=5 * (180 - k)),
|
||
300.0))
|
||
ctl.tariff_plan, ctl.tariff_at = _t10_plan(now, cheap_now), now
|
||
await ctl.read_inputs()
|
||
ctl.tariff_plan, ctl.tariff_at = _t10_plan(now, cheap_now), now
|
||
await ctl.cycle()
|
||
return ctl, hass
|
||
ctl, hass = asyncio.run(_go())
|
||
return {"arb": None if ctl.arb is None else ctl.arb.action,
|
||
"target": ctl.target, "reason": ctl.reason,
|
||
"writes": hass.writes,
|
||
"guard": None if ctl.peak_guard is None else ctl.peak_guard.engaged}
|
||
|
||
|
||
_T10_TRADE = derived(lambda: {(a, c): _t10_trade_cycle(a, c)
|
||
for a in (False, True) for c in (True, False)})
|
||
for _key in sorted(_T10_TRADE, key=str):
|
||
_r = _T10_TRADE[_key]
|
||
print(f" arbitrage_enabled={str(_key[0]):5s} cheapest hour is now="
|
||
f"{str(_key[1]):5s} -> arb={str(_r['arb']):16s} "
|
||
f"commanded {_r['target']:+.0f} W, reason {_r['reason'][:40]!r}")
|
||
|
||
# ⚠️ THE PRECONDITION, AND IT IS THE MASKING HAZARD IN THIS FIXTURE (habit 1).
|
||
# `decide()` refuses outright while the peak guard is engaged, §9.1 outranking
|
||
# §10.1 - so a fixture whose guard is holding answers "no trade" in EVERY row,
|
||
# the armed one included, and the AC3 rows below would grade the guard rather
|
||
# than the gate. It is asserted, not assumed.
|
||
check(f"the peak guard is CLEAR in all four rows, so what refuses a trade "
|
||
f"below is the arbitrage gate and not §9.1's interlock "
|
||
f"{ {k: _T10_TRADE[k]['guard'] for k in sorted(_T10_TRADE, key=str)} }",
|
||
all(_T10_TRADE[k]["guard"] is False for k in _T10_TRADE))
|
||
check("the ARMED baseline actually trades on the plan TARIFF-10 now resolves - "
|
||
"it charges in the cheap hour and discharges in the dear one. Without "
|
||
"this row, 'trading off never trades' would be satisfied by a fixture "
|
||
"that could not have traded either way",
|
||
_T10_TRADE[(True, True)]["arb"] == "ARBITRAGE_CHARGE"
|
||
and _T10_TRADE[(True, False)]["arb"] == "ARBITRAGE_DISCHARGE")
|
||
check(f"...and the site is actually COMMANDED there, which is the only "
|
||
f"observable that moves energy: {_T10_TRADE[(True, True)]['target']:+.0f} W "
|
||
f"on the cheap hour against "
|
||
f"{_T10_TRADE[(True, False)]['target']:+.0f} W on the dear one",
|
||
_T10_TRADE[(True, True)]["target"] < -1000.0
|
||
and _T10_TRADE[(True, False)]["target"] > 1000.0)
|
||
check(f"AC3: with `arbitrage_enabled: false` and the SAME resolved provider "
|
||
f"plan, the strategy is never consulted and NOTHING trades - "
|
||
f"arb={_T10_TRADE[(False, True)]['arb']!r} / "
|
||
f"{_T10_TRADE[(False, False)]['arb']!r} in both directions",
|
||
_T10_TRADE[(False, True)]["arb"] is None
|
||
and _T10_TRADE[(False, False)]["arb"] is None)
|
||
check(f"...and the command is the LOOP's, not a trade that merely went "
|
||
f"unnamed: {_T10_TRADE[(False, True)]['target']:+.0f} W / "
|
||
f"{_T10_TRADE[(False, False)]['target']:+.0f} W, reason "
|
||
f"{_T10_TRADE[(False, True)]['reason'][:30]!r} - the same small "
|
||
f"self-consumption figure on both plans, where the armed rows differ by "
|
||
f"10 kW between them",
|
||
all(abs(_T10_TRADE[(False, c)]["target"]) < 1000.0
|
||
and "arbitrage" not in _T10_TRADE[(False, c)]["reason"]
|
||
for c in (True, False)))
|
||
check("...and the trading gate is untouched by this card: it is still "
|
||
"`arbitrage.enabled()` and it is still the only thing the price "
|
||
"strategy is gated on - the four rows above differ in that option and "
|
||
"in nothing else",
|
||
_arb.enabled({"arbitrage_enabled": True})
|
||
and not _arb.enabled({})
|
||
and not _arb.enabled({"price_provider": "generic_dayahead"}))
|
||
|
||
|
||
# -- AC4: the four surfaces that DESCRIBE this, pinned as a PAIR with it ------
|
||
# ⚠️ THIS IS A TEXT CHECK AND IT IS WEAK ON PURPOSE (test_translations.py §3's
|
||
# argument). It cannot know a sentence is TRUE. What it CAN do is refuse to let
|
||
# the code and the prose move independently: the left side of each pairing is
|
||
# DRIVEN above, the right side is read off the file, and the two must agree. It
|
||
# shipped as False against True - the docs described Option A for as long as
|
||
# both existed - and an overclaim in a doc is a defect in this repository.
|
||
_T10_DOCS = derived(lambda: open(os.path.join(HERE, "DOCS.md"),
|
||
encoding="utf-8").read())
|
||
_T10_CFG = derived(lambda: open(os.path.join(HERE, "config.yaml"),
|
||
encoding="utf-8").read())
|
||
_T10_TR = derived(lambda: {lang: open(os.path.join(HERE, "translations",
|
||
f"{lang}.yaml"),
|
||
encoding="utf-8").read()
|
||
for lang in ("en", "nl")})
|
||
_T10_FETCHES = bool(_on["requests"])
|
||
_T10_DOCS_SAYS = "selecting a provider is what starts the fetch" \
|
||
in _T10_DOCS.lower()
|
||
check(f"AC4: the BEHAVIOUR and DOCS.md agree about what a configured provider "
|
||
f"does on a shipped-default install - it fetches = {_T10_FETCHES}, DOCS "
|
||
f"says it fetches = {_T10_DOCS_SAYS}. These two are the pair that has to "
|
||
f"move together",
|
||
_T10_FETCHES == _T10_DOCS_SAYS)
|
||
# The claim that had rotted in the OTHER direction: OPT-04 shipped the
|
||
# consumer, so "nothing consumes the price series yet" had been false for a
|
||
# release before this card made the first half false too.
|
||
_T10_STALE = {"config.yaml": "consumes\n # the series yet" in _T10_CFG
|
||
or "nothing consumes the price series" in _T10_CFG,
|
||
"en.yaml": "nothing consumes the price series yet"
|
||
in _T10_TR["en"],
|
||
"nl.yaml": "er is nog niets dat de prijsreeks gebruikt"
|
||
in _T10_TR["nl"]}
|
||
check(f"...and no surface still says the price series has no consumer - untrue "
|
||
f"since OPT-04, and it sat beside the claim this card is fixing in all "
|
||
f"three files {[k for k, v in _T10_STALE.items() if v]}",
|
||
not any(_T10_STALE.values()))
|
||
check("...and BOTH translations tell an operator which switch does which - "
|
||
"the provider option says it is what starts the fetch, and the trading "
|
||
"option says turning it off does not turn the feed off. One language "
|
||
"updated and not the other is how this surface rots",
|
||
"starts the price fetch" in _T10_TR["en"]
|
||
and "does not turn the price feed off" in _T10_TR["en"]
|
||
and "start het ophalen van prijzen" in _T10_TR["nl"]
|
||
and "zet de prijsfeed niet uit" in _T10_TR["nl"])
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("14. TARIFF-14 - the point whose OWN interval covers the instant")
|
||
# ⚠️ THE RESOLUTION IS THE WHOLE SECTION, AND ITS ABSENCE IS WHY THE DEFECT
|
||
# SHIPPED. Every price fixture in this repository before TARIFF-08 was the
|
||
# invented HOURLY row shape, and on an hourly series `s <= u < s + 1 h` and
|
||
# "until the next point's start" AGREE AT EVERY INSTANT - so the suite had
|
||
# been exercising the one resolution that cannot expose the bug. Section 8
|
||
# counts points on the 23- and 25-hour days and never asks `at()` for a
|
||
# quarter-hour. Belgium moved to a 15-minute MTU on 2025-10-01 and the live
|
||
# day-ahead feed returns 96 points at 900 s.
|
||
|
||
# ⚠️ BRUSSELS MIDNIGHT, NOT UTC MIDNIGHT, so that every wall-clock time in
|
||
# a check name below is the time the series really renders - "00:15" and
|
||
# "23:45" are what `canonical()` writes, not an offset away from it.
|
||
Q0 = datetime(2026, 1, 15, 0, 0, tzinfo=timezone(timedelta(hours=1)))
|
||
HOUR = timedelta(hours=1)
|
||
|
||
|
||
def spaced(gaps, first=0.10, step=0.01, provisional=None, start=Q0):
|
||
"""A series whose consecutive gaps are exactly `gaps`, in SECONDS.
|
||
|
||
`len(points) == len(gaps) + 1`. Prices ascend by `step`, so a point can be
|
||
named by VALUE as well as by identity and a sweep that compared only
|
||
prices could still tell two points apart.
|
||
"""
|
||
n = len(gaps) + 1
|
||
flags = [False] * n if provisional is None else list(provisional)
|
||
pts, t = [], start
|
||
for i in range(n):
|
||
pts.append(PricePoint(brussels(t), round(first + step * i, 6),
|
||
bool(flags[i])))
|
||
if i < len(gaps):
|
||
t += timedelta(seconds=gaps[i])
|
||
return PriceRead.series(pts, PriceProduct.DAY_AHEAD, "tariff14", start)
|
||
|
||
|
||
def gaps_of(s):
|
||
u = [p.start.astimezone(UTC) for p in s.points]
|
||
return [int((b - a).total_seconds()) for a, b in zip(u, u[1:])]
|
||
|
||
|
||
# The three shapes the acceptance criteria name, plus the two degenerate ones.
|
||
Q = spaced([900] * 95) # the shipped 96-point Belgian day
|
||
H = spaced([3600] * 23) # what every older fixture carried
|
||
# ⚠️ FCST-04'S STEADY STATE, NOT AN EDGE CASE. Eight firm quarter-hours, then
|
||
# the carried HOURLY points the fill adds for the hours nobody published. The
|
||
# seam between them is a 900 s gap and the tail is 3600 s.
|
||
MIX = spaced([900] * 8 + [3600] * 3,
|
||
provisional=[False] * 9 + [True] * 3)
|
||
# The same seam made the END of the series - the last point is the FIRST
|
||
# carried hourly one, and the gap behind it is 900 s. This is the fixture the
|
||
# tail decision is graded on.
|
||
MIX_TAIL = spaced([900] * 8, provisional=[False] * 8 + [True])
|
||
ONE = spaced([])
|
||
|
||
print("14a. the fixtures are not degenerate - asserted before anything else")
|
||
check(f"the quarter-hourly fixture is the live shape - 96 points, spacing "
|
||
f"{sorted(set(gaps_of(Q)))} s",
|
||
len(Q.points) == 96 and set(gaps_of(Q)) == {900})
|
||
check(f"the hourly fixture is 24 points at {sorted(set(gaps_of(H)))} s - and "
|
||
f"it is a REGRESSION guard, not a witness: the shipped rule and this "
|
||
f"one agree at every instant of it, which is exactly why nothing caught "
|
||
f"this",
|
||
len(H.points) == 24 and set(gaps_of(H)) == {3600})
|
||
check(f"...while the MIXED fixture really is mixed, spacing "
|
||
f"{sorted(set(gaps_of(MIX)))} s - the pair the FCST-04 soak measured in "
|
||
f"70 % of samples, so a window derived from ONE global spacing would be "
|
||
f"wrong in the NORMAL steady state",
|
||
set(gaps_of(MIX)) == {900, 3600}
|
||
and set(gaps_of(MIX_TAIL)) == {900}
|
||
and gaps_of(MIX)[-1] == 3600 and gaps_of(MIX_TAIL)[-1] == 900)
|
||
check("...and every point of every fixture carries a DISTINCT price, so 'the "
|
||
"covering point' names one point and a wrong answer cannot coincide "
|
||
"with the right one",
|
||
all(len({p.eur_per_kwh for p in s.points}) == len(s.points)
|
||
for s in (Q, H, MIX, MIX_TAIL)))
|
||
check("...and the mixed fixtures carry BOTH firm and carried points, which is "
|
||
"what makes them FCST-04's output rather than a spacing exercise",
|
||
all(any(p.provisional for p in s.points)
|
||
and any(not p.provisional for p in s.points)
|
||
for s in (MIX, MIX_TAIL)))
|
||
|
||
print()
|
||
print("14b. the reading the shipped scan got wrong, stated as literals")
|
||
# ⚠️ ASSERTED AGAINST TYPED-OUT INDICES AND PRICES, not against a window this
|
||
# file recomputed by price.py's own rule. The sweep below is exhaustive and
|
||
# the sweep is derived; these four are the anchors that keep it honest.
|
||
_q15 = derived(lambda: Q.at(Q0 + timedelta(minutes=15)))
|
||
check(f"at(00:15) on the 900 s series is the 00:15 point at 0.11 EUR/kWh - the "
|
||
f"shipped scan answered the 00:00 point at 0.10, because the :00 "
|
||
f"point's HOUR had not run out yet (saw {_q15!r})",
|
||
_q15 is Q.points[1] and derived(lambda: _q15.eur_per_kwh) == 0.11)
|
||
_q195 = derived(lambda: Q.at(Q0 + timedelta(minutes=195)))
|
||
check(f"at(03:15) is the 03:15 point at 0.23 - the shipped scan answered "
|
||
f"02:30 at 0.20 here, and that is the SECOND failure mode: from the "
|
||
f"second hour on the first point still inside its own hour wins, so the "
|
||
f"error COMPOUNDS with depth into the series (saw {_q195!r})",
|
||
_q195 is Q.points[13] and derived(lambda: _q195.eur_per_kwh) == 0.23)
|
||
check("at(00:30) and at(00:45) are their own quarters too, so the answer is "
|
||
"not merely 'one point later'",
|
||
derived(lambda: Q.at(Q0 + timedelta(minutes=30))) is Q.points[2]
|
||
and derived(lambda: Q.at(Q0 + timedelta(minutes=45))) is Q.points[3])
|
||
check("on the MIXED series the quarter-hourly half answers per QUARTER and "
|
||
"the carried hourly half answers per HOUR, in one series - at(00:15) is "
|
||
"the 00:15 point and at(02:30) is the 02:00 one",
|
||
derived(lambda: MIX.at(Q0 + timedelta(minutes=15))) is MIX.points[1]
|
||
and derived(lambda: MIX.at(Q0 + timedelta(minutes=150))) is MIX.points[8]
|
||
and derived(lambda: MIX.points[8].start.astimezone(UTC)) ==
|
||
Q0 + timedelta(hours=2))
|
||
|
||
print()
|
||
print("14c. the exhaustive sweep, at 900 s, 3600 s and mixed")
|
||
|
||
|
||
def windows(s):
|
||
"""`(start_utc, width)` per point, computed HERE from the gaps.
|
||
|
||
Deliberately NOT read off `_Series._cover`: a check built from the table it
|
||
grades compares a thing against itself. The rule is restated - a point runs
|
||
to the next start, capped at an hour; the last point copies the width in
|
||
front of it; a lone point gets an hour.
|
||
"""
|
||
u = [p.start.astimezone(UTC) for p in s.points]
|
||
out = []
|
||
for i, t in enumerate(u):
|
||
if i + 1 < len(u):
|
||
g = u[i + 1] - t
|
||
elif i:
|
||
g = t - u[i - 1]
|
||
else:
|
||
g = HOUR
|
||
out.append((t, g if g < HOUR else HOUR))
|
||
return out
|
||
|
||
|
||
def sweep(s):
|
||
"""Every probe whose covering point is unambiguous, and what `at()` said."""
|
||
wrong = []
|
||
for i, (t, w) in enumerate(windows(s)):
|
||
for off in (timedelta(0), w / 2, w - timedelta(seconds=1)):
|
||
got = derived(lambda: s.at(t + off))
|
||
if got is not s.points[i]:
|
||
wrong.append(f"{(t + off).isoformat()} -> {got!r}, "
|
||
f"wanted {s.points[i].eur_per_kwh}")
|
||
return wrong
|
||
|
||
|
||
for _name, _s in (("900 s", Q), ("3600 s", H), ("mixed 900/3600", MIX),
|
||
("mixed, 900 s tail", MIX_TAIL)):
|
||
_w = derived(lambda s=_s: sweep(s))
|
||
check(f"{_name}: all {3 * len(_s.points)} probes inside the series return "
|
||
f"the point whose OWN interval covers them "
|
||
f"({_w if _w != [] else 'no misses'})", _w == [])
|
||
|
||
print()
|
||
print("14d. the one-hour cap, which is what keeps FCST-04's holes holes")
|
||
# ⚠️ NOT DECORATION. FCST-04 leaves an hour OUT when nothing published it and
|
||
# yesterday has no value either, and price_forecast.py's module note names
|
||
# `at()` raising Absence for it as the behaviour that sends the caller to the
|
||
# static profile (§11.4 for that hour). Uncapped, "until the next point's
|
||
# start" would stretch the point before a three-hour hole across the whole
|
||
# hole - filling holes with a stale price, which is the defect this module
|
||
# exists to refuse, introduced by the fix for a different one.
|
||
HOLE = PriceRead.series(
|
||
[PricePoint(brussels(Q0 + timedelta(hours=i)), 0.10 + i / 100.0, False)
|
||
for i in (0, 1, 4, 5)],
|
||
PriceProduct.DAY_AHEAD, "tariff14", Q0)
|
||
_in_hole = [Q0 + timedelta(hours=2), Q0 + timedelta(hours=2, minutes=30),
|
||
Q0 + timedelta(hours=3), Q0 + timedelta(hours=3, minutes=59)]
|
||
check(f"a three-hour hole stays a hole: all {len(_in_hole)} instants inside it "
|
||
f"raise Absence, while the point BEFORE it still covers its own hour and "
|
||
f"the point after it answers from its own start",
|
||
all(raises(lambda u=u: HOLE.at(u), Absence) for u in _in_hole)
|
||
and derived(lambda: HOLE.at(Q0 + timedelta(hours=1, minutes=59)))
|
||
is HOLE.points[1]
|
||
and derived(lambda: HOLE.at(Q0 + timedelta(hours=4))) is HOLE.points[2])
|
||
check(f"...and the cap is stated once, as MAX_POINT_SPAN = "
|
||
f"{_price.MAX_POINT_SPAN}, rather than spelled into the lookup",
|
||
_price.MAX_POINT_SPAN == HOUR)
|
||
# ⚠️ AND THE OTHER HALF OF THE HOLE, WHICH IS THE ONE §8.3 READS. FCST-04
|
||
# fills a whole hour nobody published with ONE carried point, so a
|
||
# quarter-hourly day can carry an hourly carried point in the middle of it.
|
||
# The instant inside that hour is covered by the CARRIED point - and the
|
||
# shipped scan answered the FIRM quarter 52 minutes earlier, because that was
|
||
# the first point still inside its own hour. `_act()`'s `here.provisional`
|
||
# guard and `price_forecast._walk()`'s `series.at(u).provisional` both read the
|
||
# flag off whatever that lookup returned, so the flag was FIRM on an hour
|
||
# nobody published.
|
||
SANDWICH = PriceRead.series(
|
||
[PricePoint(brussels(Q0 + timedelta(minutes=15 * i)),
|
||
round(0.10 + i / 1000.0, 6), False) for i in range(44)]
|
||
+ [PricePoint(brussels(Q0 + timedelta(hours=11)), 0.500, True)]
|
||
+ [PricePoint(brussels(Q0 + timedelta(hours=12, minutes=15 * i)),
|
||
round(0.20 + i / 1000.0, 6), False) for i in range(4)],
|
||
PriceProduct.DAY_AHEAD, "tariff14", Q0)
|
||
_sand_t = Q0 + timedelta(hours=11, minutes=37)
|
||
|
||
|
||
def shipped_at(s, u):
|
||
"""The rule TARIFF-14 replaced, as the COUNTERFACTUAL only - nothing is
|
||
graded against it. It is here so the fixture can be SHOWN to separate the
|
||
two rules; a check whose data cannot reach the failure it names grades
|
||
nothing, and on an hourly series these two agree at every instant."""
|
||
uu = u.astimezone(UTC)
|
||
for p in s.points:
|
||
st = p.start.astimezone(UTC)
|
||
if st <= uu < st + HOUR:
|
||
return p
|
||
return None
|
||
|
||
|
||
check("the sandwich fixture is FCST-04's real output - 44 firm quarters, ONE "
|
||
"carried hourly point filling the hour nobody published, then firm "
|
||
"quarters again",
|
||
len(SANDWICH.points) == 49
|
||
and SANDWICH.points[44].provisional is True
|
||
and sum(1 for p in SANDWICH.points if p.provisional) == 1)
|
||
_sand_now = derived(lambda: SANDWICH.at(_sand_t))
|
||
_sand_old = derived(lambda: shipped_at(SANDWICH, _sand_t))
|
||
check(f"an instant inside the carried hour is covered by the CARRIED point, "
|
||
f"so §8.3's flag is read off the hour it belongs to. The shipped scan "
|
||
f"answered the FIRM quarter 52 minutes earlier "
|
||
f"({derived(lambda: _sand_old.start.isoformat())}, provisional="
|
||
f"{derived(lambda: _sand_old.provisional)}), which is a carried hour "
|
||
f"wearing a published hour's flag",
|
||
_sand_now is SANDWICH.points[44]
|
||
and derived(lambda: _sand_now.provisional) is True
|
||
and _sand_old is SANDWICH.points[43]
|
||
and derived(lambda: _sand_old.provisional) is False)
|
||
|
||
print()
|
||
print("14e. the LAST point's window - decided and documented, not inherited")
|
||
# There is no next start and §9.3 says nothing about how wide the final
|
||
# interval is, so THE LAST POINT IS GIVEN THE SAME SPAN AS THE POINT BEFORE IT
|
||
# (one hour when it is the only point). The alternative - a flat one-hour tail
|
||
# - is defensible for an hourly series and wrong for the live one: it answers
|
||
# 00:30 TOMORROW with today's last price, which is reaching for a neighbour
|
||
# across a day boundary.
|
||
_ql = Q.points[-1].start.astimezone(UTC)
|
||
_hl = H.points[-1].start.astimezone(UTC)
|
||
_ml = MIX_TAIL.points[-1].start.astimezone(UTC)
|
||
check("the 900 s day's last point covers its own QUARTER: at(+14:59) answers "
|
||
"and at(+15:00) raises, so today's 23:45 price cannot be returned for "
|
||
"00:30 tomorrow",
|
||
derived(lambda: Q.at(_ql + timedelta(minutes=14, seconds=59)))
|
||
is Q.points[-1]
|
||
and raises(lambda: Q.at(_ql + timedelta(minutes=15)), Absence))
|
||
check("the hourly series' last point still covers an HOUR, exactly as it "
|
||
"shipped - no static or hourly site changes behaviour",
|
||
derived(lambda: H.at(_hl + timedelta(minutes=59))) is H.points[-1]
|
||
and raises(lambda: H.at(_hl + HOUR), Absence))
|
||
check("the MIXED series ending at the firm/carried seam takes the 900 s in "
|
||
"front of it, so its last 45 minutes raise Absence. That is the SAFE "
|
||
"direction and it is CHOSEN: §11.4 sends the caller to the static "
|
||
"profile rather than trading on a span the series never demonstrated, "
|
||
"and the point is carried, so §8.3 forbids irreversible action on it "
|
||
"anyway",
|
||
derived(lambda: MIX_TAIL.at(_ml + timedelta(minutes=14, seconds=59)))
|
||
is MIX_TAIL.points[-1]
|
||
and raises(lambda: MIX_TAIL.at(_ml + timedelta(minutes=15)), Absence)
|
||
and MIX_TAIL.points[-1].provisional)
|
||
# ⚠️ HABIT 1. Without this the three checks above would all pass under a
|
||
# single global tail rule of 15 minutes, or of an hour, depending which two
|
||
# you read - so the claim that the tail FOLLOWS THE SERIES is asserted as its
|
||
# own check, at one instant where the three fixtures must disagree.
|
||
_t30 = [derived(lambda s=s, l=l: s.covers(l + timedelta(minutes=30)))
|
||
for s, l in ((Q, _ql), (H, _hl), (MIX_TAIL, _ml))]
|
||
check(f"...and those tails are genuinely three readings of one rule and not "
|
||
f"one constant: 30 minutes past its own last point the hourly series "
|
||
f"answers and both 900 s ones do not {_t30}",
|
||
_t30 == [False, True, False])
|
||
check("a series of ONE point has no gap in front of it to copy, so it gets an "
|
||
"hour - the shipped behaviour, kept deliberately for the degenerate case",
|
||
derived(lambda: ONE.at(Q0 + timedelta(minutes=59, seconds=59)))
|
||
is ONE.points[0]
|
||
and raises(lambda: ONE.at(Q0 + HOUR), Absence))
|
||
|
||
print()
|
||
print("14f. order is established by the lookup, not assumed of the caller")
|
||
# ⚠️ `at()` now reads a point's NEIGHBOURS, so it WOULD depend on sortedness.
|
||
# The index is sorted at construction so that it cannot. `_validate_raw()`
|
||
# refuses out-of-order and duplicate starts on the `load_prices()` path
|
||
# (section 9 above), `price_history._parse_points()` re-sorts what it reads off
|
||
# disk and FCST-04's fill emits ascending - but `PriceRead.series()` itself has
|
||
# never enforced any of it, and an assumption that holds today and is unstated
|
||
# is how this class of defect recurs.
|
||
_REV = PriceRead.series(tuple(reversed(Q.points)), PriceProduct.DAY_AHEAD,
|
||
"tariff14", Q0)
|
||
check("the reversed fixture really is reversed, so the check below is not "
|
||
"grading two identical series",
|
||
_REV.points[0] is Q.points[-1] and _REV.points[-1] is Q.points[0]
|
||
and _REV.points != Q.points)
|
||
_rev_bad = derived(lambda: [p.start.isoformat() for p in Q.points
|
||
if _REV.at(p.start) is not p])
|
||
check(f"...and a series handed its 96 points in REVERSE order answers at() "
|
||
f"identically at every one of them "
|
||
f"({_rev_bad if _rev_bad != [] else 'no misses'})",
|
||
_rev_bad == [])
|
||
check("...while the load path refuses an unsorted feed outright, so the two "
|
||
"defences are stated rather than one being quietly relied on",
|
||
raises(lambda: _price._validate_raw(
|
||
[RawPoint(T0 + timedelta(minutes=15), 0.1, False),
|
||
RawPoint(T0, 0.2, False)], _caps), SourceUnavailable))
|
||
check("a DUPLICATED start does not raise anywhere - not at construction and "
|
||
"not on the 30 s control path. The later list entry takes the real span "
|
||
"and the earlier one becomes unreachable through at(); load_prices() "
|
||
"refuses the shape outright, and this says what happens to anyone who "
|
||
"builds one by hand",
|
||
derived(lambda: PriceRead.series(
|
||
[PricePoint(brussels(Q0), 0.10, False),
|
||
PricePoint(brussels(Q0), 0.20, False),
|
||
PricePoint(brussels(Q0 + timedelta(minutes=15)), 0.30, False)],
|
||
PriceProduct.DAY_AHEAD, "tariff14", Q0).at(Q0).eur_per_kwh) == 0.20)
|
||
|
||
print()
|
||
print("14g. covers() shared it; window() did not")
|
||
check("covers() shared the defect EXACTLY, because it has no window rule of "
|
||
"its own - it asks at() - so the one change fixes both. On the 900 s "
|
||
"day it is now True 14:59 past the last point and False 15:00 past it, "
|
||
"where an hour-wide window said True for another 45 minutes",
|
||
derived(lambda: Q.covers(_ql + timedelta(minutes=14, seconds=59))) is True
|
||
and derived(lambda: Q.covers(_ql + timedelta(minutes=15))) is False
|
||
and derived(lambda: Q.covers(_ql + timedelta(minutes=30))) is False
|
||
and derived(lambda: Q.covers(Q0 + timedelta(minutes=15))) is True)
|
||
check("window() did NOT share it, and the reason is worth a check rather than "
|
||
"a rediscovery: it grades a point's START against a half-open range and "
|
||
"never asks how long a point lasts, so it carries no resolution "
|
||
"assumption at all. 00:07:30 .. 00:22:30 holds exactly the 00:15 point "
|
||
"- while at(00:07:30) is the 00:00 one, which is what makes it a "
|
||
"DIFFERENT question and not a coverage query",
|
||
derived(lambda: Q.window(Q0 + timedelta(minutes=7, seconds=30),
|
||
Q0 + timedelta(minutes=22, seconds=30)))
|
||
== (Q.points[1],)
|
||
and derived(lambda: Q.at(Q0 + timedelta(minutes=7, seconds=30)))
|
||
is Q.points[0])
|
||
|
||
print()
|
||
print("14h. the 'no data is not free' contract, unchanged")
|
||
check("at() answers with a PricePoint or raises Absence - never None, at any "
|
||
"resolution, so `series.at(t) or fallback` cannot become a price of zero",
|
||
all(isinstance(derived(lambda s=s: s.at(s.points[0].start)), PricePoint)
|
||
for s in (Q, H, MIX, MIX_TAIL, ONE))
|
||
and raises(lambda: Q.at(Q0 - timedelta(seconds=1)), Absence)
|
||
and raises(lambda: Q.at(Q0 + timedelta(days=2)), Absence))
|
||
_absence_words = _reason(lambda: Q.at(Q0 + timedelta(days=2)))
|
||
check(f"...and the refusal still names the range the series runs, so an "
|
||
f"operator can tell 'outside the horizon' from 'into a hole'",
|
||
Q.points[0].start.isoformat() in _absence_words
|
||
and Q.points[-1].start.isoformat() in _absence_words)
|
||
check("...and it never invents a neighbour: the instant one second before the "
|
||
"series starts is refused, not answered with the first point",
|
||
raises(lambda: Q.at(Q0 - timedelta(seconds=1)), Absence)
|
||
and raises(lambda: MIX.at(Q0 - timedelta(seconds=1)), Absence)
|
||
and raises(lambda: H.at(Q0 - timedelta(seconds=1)), Absence))
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
for _d in _tmpdirs_t10:
|
||
shutil.rmtree(_d, ignore_errors=True)
|
||
|
||
|
||
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("15. TARIFF-15 - the operator's own tariff card, as two coefficients")
|
||
#
|
||
# WHAT THIS SECTION IS DEFENDING. `custom_formula` lets an operator type the
|
||
# `B x index + A` line off their own supplier's tariff card, because there is
|
||
# no machine-readable source of Belgian tariff coefficients anywhere and the
|
||
# customer is the only party who can see their own. That makes the card the
|
||
# ONLY artefact that can grade them - which is exactly what the startup check
|
||
# does, and what the checks below grade it doing.
|
||
#
|
||
# ⚠️ EVERY EXPECTATION HERE IS SOMEBODY ELSE'S PUBLISHED NUMBER. The two cards
|
||
# print the formula, the reference index they assumed AND the resulting price,
|
||
# so this file compares the SHIPPED code against 15,55 and 7,522 rather than
|
||
# against its own restatement of the same arithmetic. That is section 2b's
|
||
# rule - grade against something nobody here wrote - applied to arithmetic.
|
||
|
||
print()
|
||
print("15a. the two REAL cards reproduce their PRINTED prices, through the "
|
||
"shipped code")
|
||
|
||
# ⚠️ NON-DEGENERACY FIRST (habit 1). Two cards that agreed on every field
|
||
# would be one card counted twice, and the pair is what says the arithmetic is
|
||
# general rather than tuned to one supplier.
|
||
check(f"the two cards really are two - factor {ENECO_CARD['factor']} vs "
|
||
f"{ENGIE_CARD['factor']}, offset {ENECO_CARD['offset']} vs "
|
||
f"{ENGIE_CARD['offset']}, index "
|
||
f"{ENECO_CARD['card']['index_eur_per_mwh']} vs "
|
||
f"{ENGIE_CARD['card']['index_eur_per_mwh']}",
|
||
ENECO_CARD["factor"] != ENGIE_CARD["factor"]
|
||
and ENECO_CARD["offset"] != ENGIE_CARD["offset"]
|
||
and ENECO_CARD["card"]["index_eur_per_mwh"]
|
||
!= ENGIE_CARD["card"]["index_eur_per_mwh"]
|
||
and ENECO_CARD["card"]["expect_incl_vat"]
|
||
!= ENGIE_CARD["card"]["expect_incl_vat"])
|
||
|
||
# The suppliers' own printed prices, transcribed from the cards and from
|
||
# nowhere in app/.
|
||
PRINTED = {"Eneco (0.102 x 133.9905 + 1) x 1.06": (ENECO_CARD, 15.55),
|
||
"ENGIE (0.1 x 61.96 + 0.9) x 1.06": (ENGIE_CARD, 7.522)}
|
||
for _what, (_card, _printed) in sorted(PRINTED.items()):
|
||
_spec = derived(lambda c=_card: parse_price_formula(json.dumps(c)))
|
||
_got = derived(lambda sp=_spec: in_card_units(
|
||
sp, sp.card_index_eur_per_mwh))
|
||
check(f"{_what} = {_got} c EUR/kWh through the shipped module, which "
|
||
f"rounds to the {_printed} the card prints",
|
||
isinstance(_got, float)
|
||
and round(_got, len(str(_printed).split('.')[-1])) == _printed)
|
||
|
||
# ...and the same two cards end to end through `load_prices()`, i.e. through
|
||
# the registered plugin, the columnar parser and `assemble()` - not through a
|
||
# module function this file called directly. The feed body carries the card's
|
||
# OWN index value, so the point that comes out must be the card's own printed
|
||
# price in EUR/kWh plus the framework's taxes and nothing else.
|
||
for _what, (_card, _printed) in sorted(PRINTED.items()):
|
||
_idx = _card["card"]["index_eur_per_mwh"]
|
||
_read = derived(lambda c=_card, i=_idx: load(
|
||
"custom_formula", {"price_formula": json.dumps(c)},
|
||
transport=canned(columnar_feed([i]))))
|
||
_all_in = derived(lambda r=_read: r.points[0].eur_per_kwh)
|
||
_want = round(_printed / 100.0 + TAXES, 6)
|
||
check(f"{_what}: end to end through load_prices() the hour comes out at "
|
||
f"{_all_in} EUR/kWh all-in, which is the card's printed "
|
||
f"{_printed} c EUR/kWh plus price_taxes_eur_kwh and nothing else",
|
||
isinstance(_all_in, float) and abs(_all_in - _want) < 5e-5)
|
||
|
||
print()
|
||
print("15b. the card self-check REFUSES at startup, naming the discrepancy")
|
||
# ⚠️ THIS IS THE WHOLE SAFETY ARGUMENT AND IT IS GRADED ON THE REASON, NOT THE
|
||
# VERDICT (MUTATION.md, TEST-16). Several rules here can refuse the same blob -
|
||
# a wrong `units`, a missing key, a bad number - so a check that only asked
|
||
# "was it refused" would pass on a tree where the card comparison is deleted.
|
||
|
||
|
||
def spoilt(**over):
|
||
"""Eneco's card with one field replaced. Everything else is the real one."""
|
||
c = json.loads(json.dumps(ENECO_CARD))
|
||
for k, v in over.items():
|
||
if k.startswith("card_"):
|
||
c["card"][k[5:]] = v
|
||
else:
|
||
c[k] = v
|
||
return c
|
||
|
||
|
||
def refusal(blob):
|
||
return _reason(lambda: parse_price_formula(json.dumps(blob)))
|
||
|
||
|
||
_honest = refusal(ENECO_CARD)
|
||
check("the CONTROL first: the real card is NOT refused, or every refusal "
|
||
"below would be graded against a rule that refuses everything",
|
||
_honest == "")
|
||
|
||
# ⚠️ THE EXPENSIVE ONE, AND THE REASON THIS PROVIDER IS SAFE TO SHIP. A
|
||
# Belgian card's Tariefformule line is the ENERGY component alone; the add-on
|
||
# adds price_taxes_eur_kwh on top. An operator who folds ~12 c EUR/kWh of
|
||
# network charge into `offset` is billed for it twice, and `code_leaks()`
|
||
# cannot see it because it is CONFIG, not code. The card is what catches it:
|
||
# the printed estimate is the energy component, so the inflated offset cannot
|
||
# reproduce it.
|
||
_NETWORK_C_KWH = 12.0
|
||
_double = refusal(spoilt(offset=ENECO_CARD["offset"] + _NETWORK_C_KWH))
|
||
_double_price = derived(lambda: in_card_units(
|
||
_cf.FormulaSpec(index="epex_be", resolution="hourly", factor=0.102,
|
||
offset=ENECO_CARD["offset"] + _NETWORK_C_KWH,
|
||
units="c_eur_per_kwh", vat_pct=6, card_month="2026-09",
|
||
card_index_eur_per_mwh=133.9905,
|
||
card_expect_incl_vat=15.55), 133.9905))
|
||
check(f"network charges folded into `offset` move the card's own price to "
|
||
f"{_double_price} c EUR/kWh against a printed 15.55, so it CANNOT pass "
|
||
f"- the counterfactual first, or the refusal below grades nothing",
|
||
isinstance(_double_price, float) and abs(_double_price - 15.55) > 10)
|
||
check(f"...and it is refused, by the CARD rule, naming the discrepancy and "
|
||
f"the network-charge cause ({_double[:60]!r})",
|
||
"the card says" in _double
|
||
and "network charges are not in the offset" in _double)
|
||
|
||
_CORRUPTIONS = {
|
||
"VAT left out (vat_pct 0)": spoilt(vat_pct=0),
|
||
"VAT applied twice (the card's price already carries it)":
|
||
spoilt(card_expect_incl_vat=round(15.55 * 1.06, 4)),
|
||
"the coefficients in c EUR and the expected price in EUR":
|
||
spoilt(card_expect_incl_vat=0.1555),
|
||
"the index typed in EUR/kWh instead of EUR/MWh":
|
||
spoilt(card_index_eur_per_mwh=0.1339905),
|
||
"a transposed digit in the expected price (15.05 for 15.55)":
|
||
spoilt(card_expect_incl_vat=15.05),
|
||
"a transposed digit in the factor (0.120 for 0.102)":
|
||
spoilt(factor=0.120),
|
||
}
|
||
for _name, _blob in sorted(_CORRUPTIONS.items()):
|
||
_why = refusal(_blob)
|
||
check(f"{_name}: refused, and by the CARD rule rather than by another one "
|
||
f"({_why[:48]!r})",
|
||
"the card says" in _why)
|
||
|
||
# ⚠️ THE TOLERANCE IS A REAL EDGE AND IT IS GRADED FROM BOTH SIDES, or
|
||
# "within 0.01" and "within anything" would be the same check.
|
||
_inside = refusal(spoilt(card_expect_incl_vat=round(
|
||
in_card_units(parse_price_formula(json.dumps(ENECO_CARD)), 133.9905)
|
||
+ CARD_TOLERANCE * 0.5, 6)))
|
||
_outside = refusal(spoilt(card_expect_incl_vat=round(
|
||
in_card_units(parse_price_formula(json.dumps(ENECO_CARD)), 133.9905)
|
||
+ CARD_TOLERANCE * 2.0, 6)))
|
||
check(f"half a tolerance out passes and twice a tolerance out does not, so "
|
||
f"{CARD_TOLERANCE} is the number being enforced rather than a shape",
|
||
_inside == "" and "the card says" in _outside)
|
||
|
||
# A card that is absent is refused, and NOT by the generic unknown-field rule.
|
||
check(f"a formula with NO card block is refused, and the message says why the "
|
||
f"card is required rather than merely that a field is missing",
|
||
"no \"card\" block" in refusal({k: v for k, v in ENECO_CARD.items()
|
||
if k != "card"}))
|
||
|
||
print()
|
||
print("15c. the index path is generic_dayahead's, replayed from the SAME "
|
||
"captured bytes")
|
||
# A networked plugin with no capture is what section 2b's roster forbids. This
|
||
# one reads the same endpoint with the same parser, so it is replayed from the
|
||
# same real bodies rather than from a fixture written here.
|
||
_CF_OPTS = {"price_formula": json.dumps(ENECO_CARD)}
|
||
_cf_replay = {n: load("custom_formula", _CF_OPTS, transport=canned(b))
|
||
for n, b in _REAL.items()}
|
||
check(f"the SHIPPED custom_formula parses all three captured real bodies "
|
||
f"{[r.words for r in _cf_replay.values()]}",
|
||
all(r.kind == "series" for r in _cf_replay.values()))
|
||
_cf_counts = derived(lambda: {n: len(r.points)
|
||
for n, r in _cf_replay.items()})
|
||
check(f"...and emits one point per published instant at quarter_hourly "
|
||
f"{_cf_counts}",
|
||
_cf_counts == {n: len(json.loads(b.decode("utf-8"))["price"])
|
||
for n, b in _REAL.items()})
|
||
# The URL is the same one, derived from the same helper. Behaviour, off the
|
||
# injected transport, not source text.
|
||
_cf_t = canned(_REAL[_TD])
|
||
run(load_prices(opts(price_provider="custom_formula", **_CF_OPTS),
|
||
_w0, _w0 + timedelta(hours=48), transport=_cf_t))
|
||
check(f"...and asks for the same Brussels range generic_dayahead asks for "
|
||
f"{nth(_cf_t.calls, 0)[0]!r}",
|
||
nth(_cf_t.calls, 0)[0] == asked_url(_w0, _w0 + timedelta(hours=48)))
|
||
|
||
# The price at each instant is the document's own index through the card's
|
||
# arithmetic - computed HERE from the captured bytes, by different arithmetic.
|
||
_doc = json.loads(_REAL[_QH].decode("utf-8"))
|
||
_want_cf = [round((0.102 * v + 1.00) * 1.06 / 100.0 + TAXES, 6)
|
||
for v in _doc["price"]]
|
||
_got_cf = derived(lambda: [p.eur_per_kwh
|
||
for p in _cf_replay[_QH].points])
|
||
check(f"every one of the {len(_want_cf)} points is (0.102 x index + 1) x 1.06 "
|
||
f"c EUR/kWh plus taxes, against the captured document's own index "
|
||
f"column (first three {_got_cf[:3] if isinstance(_got_cf, list) else _got_cf})",
|
||
_got_cf == _want_cf)
|
||
# ...and the index really varies over that day, or one constant would grade the
|
||
# whole column.
|
||
check(f"...and the captured index really varies across the day "
|
||
f"({min(_doc['price'])} .. {max(_doc['price'])} EUR/MWh), so that "
|
||
f"comparison is 96 different numbers and not one repeated",
|
||
len(set(_doc["price"])) > 50)
|
||
|
||
print()
|
||
print("15d. resolution: the contract's series, not the feed's")
|
||
# ⚠️ THIS IS NOT TARIFF-14's HOURLY ASSUMPTION COMING BACK. It decides how many
|
||
# points are EMITTED; the window each one covers is still derived from its
|
||
# neighbours by _cover_table(). Both halves are graded.
|
||
_hourly_card = dict(ENECO_CARD, resolution="hourly")
|
||
_h_read = load("custom_formula", {"price_formula": json.dumps(_hourly_card)},
|
||
transport=canned(_REAL[_QH]))
|
||
_q_read = _cf_replay[_QH]
|
||
check(f"the same 96-point body is 24 points at `hourly` and 96 at "
|
||
f"`quarter_hourly` ({len(_h_read.points)} vs {len(_q_read.points)}), so "
|
||
f"the option is doing something",
|
||
len(_h_read.points) == 24 and len(_q_read.points) == 96)
|
||
# The hourly value is the MEAN of that hour's quarters, computed here from the
|
||
# captured document rather than from the module.
|
||
_by_hour = {}
|
||
for _t, _v in zip(_doc["unix_seconds"], _doc["price"]):
|
||
_by_hour.setdefault(
|
||
datetime.fromtimestamp(_t, UTC).replace(minute=0), []).append(_v)
|
||
_want_h = [round((0.102 * (sum(v) / len(v)) + 1.00) * 1.06 / 100.0 + TAXES, 6)
|
||
for _k, v in sorted(_by_hour.items())]
|
||
_got_h = derived(lambda: [p.eur_per_kwh for p in _h_read.points])
|
||
check(f"...and each hourly point is the MEAN of its four quarters through the "
|
||
f"card's arithmetic (first three {_got_h[:3] if isinstance(_got_h, list) else _got_h})",
|
||
_got_h == _want_h)
|
||
# ⚠️ NON-DEGENERACY: an hour whose four quarters were equal would make "the
|
||
# mean" and "the first quarter" the same number, and the check above would
|
||
# grade nothing.
|
||
_differs = sum(1 for _k, v in _by_hour.items() if len(set(v)) > 1)
|
||
check(f"...and {_differs} of the {len(_by_hour)} hours really have quarters "
|
||
f"that DIFFER, so the mean is distinguishable from taking the first one",
|
||
_differs >= 20)
|
||
# An already-hourly feed averages one member per hour - the identity - so the
|
||
# option cannot silently assume the granularity it was handed.
|
||
_h1_pairs = [(datetime.fromtimestamp(t, UTC), v) for t, v in
|
||
zip(json.loads(_REAL[_H1].decode("utf-8"))["unix_seconds"],
|
||
json.loads(_REAL[_H1].decode("utf-8"))["price"])]
|
||
check("an ALREADY hourly feed is the identity under `hourly`, so the option "
|
||
"assumes nothing about the granularity it was handed",
|
||
derived(lambda: to_resolution(_h1_pairs, "hourly")) == _h1_pairs)
|
||
# and TARIFF-14: the window is still the neighbours', not an assumed hour.
|
||
_h_pts = _h_read.points
|
||
check("...and the hourly series' coverage is still DERIVED from the "
|
||
"neighbours - at() answers 59 minutes into a point and the next point "
|
||
"at 60, which is _cover_table()'s rule and not a rule this card added",
|
||
derived(lambda: _h_read.at(_h_pts[0].start + timedelta(minutes=59))
|
||
is _h_pts[0]) is True
|
||
and derived(lambda: _h_read.at(_h_pts[0].start + timedelta(minutes=60))
|
||
is _h_pts[1]) is True)
|
||
|
||
print()
|
||
print("15e. every refusal in the table, and the two that are WARNINGS")
|
||
_TABLE = {
|
||
"units absent": (spoilt(units=""), "units", "Choose one of"),
|
||
"units not one of the enumerated ones":
|
||
(spoilt(units="cents"), "units", "Choose one of"),
|
||
"factor == 0 (every spread becomes 0, arbitrage never fires)":
|
||
(spoilt(factor=0), "factor", "static_profile"),
|
||
"factor < 0 (the dearest hour becomes the cheapest)":
|
||
(spoilt(factor=-0.102), "factor", "turns the market upside down"),
|
||
"vat_pct above 30 (the 6 / 1.06 / 106 confusion)":
|
||
(spoilt(vat_pct=106), "vat_pct", "between 0 and 30"),
|
||
"vat_pct below 0": (spoilt(vat_pct=-6), "vat_pct", "between 0 and 30"),
|
||
"a non-numeric coefficient": (spoilt(factor="0.102"), "factor",
|
||
"not a number"),
|
||
"a coefficient that is an EXPRESSION rather than a number":
|
||
(spoilt(factor="0.1*x"), "factor", "Write a plain number"),
|
||
"a non-finite coefficient": (spoilt(offset=float("inf")), "offset",
|
||
"not finite"),
|
||
"an unknown index": (spoilt(index="belpex_h"), "index", "can only fetch"),
|
||
"an unknown resolution": (spoilt(resolution="daily"), "resolution",
|
||
"Choose one of"),
|
||
"an unknown top-level field (which is how 'expr' is answered)":
|
||
(spoilt(expr="0.102*spot+1"), "expr", "It takes only"),
|
||
"an unknown field inside the card": ({**ENECO_CARD,
|
||
"card": dict(ENECO_CARD["card"],
|
||
vat="6")},
|
||
"vat", "unknown field(s)"),
|
||
}
|
||
for _name, (_blob, _names, _needle) in sorted(_TABLE.items()):
|
||
_why = refusal(_blob)
|
||
check(f"{_name}: refused, naming the field and the consequence "
|
||
f"({_why[:48]!r})",
|
||
_names in _why and _needle in _why)
|
||
|
||
# ⚠️ AND THE ARM THAT MAKES THOSE MEAN SOMETHING: a NaN is not merely refused
|
||
# by "not a number", and `True` is an int in Python.
|
||
check("NaN is refused as NOT FINITE rather than as not a number, so the two "
|
||
"rungs are distinguishable",
|
||
"not finite" in refusal(spoilt(factor=float("nan"))))
|
||
check("...and a bool is refused as not a number, because `True` is an int "
|
||
"here and would price the whole day at the index",
|
||
"not a number" in refusal(spoilt(factor=True)))
|
||
|
||
# The plausibility band. ⚠️ IT IS A SEPARATE QUANTITY FROM THE CARD CHECK: a
|
||
# unit error applied to BOTH sides cancels out of the card comparison and
|
||
# cannot cancel out of this one.
|
||
_both_sides = spoilt(factor=0.00102, offset=0.01,
|
||
card_expect_incl_vat=0.1555)
|
||
_bs_spec = derived(lambda: parse_price_formula(json.dumps(_both_sides)))
|
||
check("a unit error applied to BOTH sides passes the card self-check - stated "
|
||
"as a check, because it is the limit of that guard and not a bug in it",
|
||
not isinstance(_bs_spec, _Raised)
|
||
and abs(card_discrepancy(_bs_spec)) <= CARD_TOLERANCE)
|
||
_band = derived(lambda: implausible_all_in(_bs_spec, 0.0))
|
||
check(f"...and the all-in band is what catches it, naming the band "
|
||
f"({str(_band)[:48]!r})",
|
||
isinstance(_band, str) and "plausible band" in _band)
|
||
check("...and the REAL card is inside the band, so the band is not simply "
|
||
"refusing everything",
|
||
implausible_all_in(parse_price_formula(json.dumps(ENECO_CARD)),
|
||
0.06) is None)
|
||
check(f"...and the band really is {PLAUSIBLE_LO_EUR_KWH}-"
|
||
f"{PLAUSIBLE_HI_EUR_KWH} EUR/kWh at both ends, graded from inside and "
|
||
f"outside each",
|
||
implausible_all_in(parse_price_formula(json.dumps(ENECO_CARD)),
|
||
PLAUSIBLE_HI_EUR_KWH) is not None
|
||
and implausible_all_in(parse_price_formula(json.dumps(
|
||
spoilt(factor=0.0001, offset=0.0009,
|
||
card_expect_incl_vat=round((0.0001 * 133.9905 + 0.0009)
|
||
* 1.06, 6)))), 0.0) is not None)
|
||
|
||
# ⚠️ A STALE CARD IS A WARNING AND MUST NEVER BECOME A REFUSAL: ENGIE freezes
|
||
# a contract's coefficients for its duration, so a two-year-old card can be
|
||
# exactly right and a site refused over one is a site that cannot start.
|
||
_old_card = spoilt(card_month="2019-01")
|
||
check("an OLD card is not refused - the parse succeeds",
|
||
refusal(_old_card) == "")
|
||
check("...and it is warned about, naming the month and saying why it is not "
|
||
"refused",
|
||
"2019-01" in (stale_card(parse_price_formula(json.dumps(_old_card)))
|
||
or "")
|
||
and "still starts" in (stale_card(
|
||
parse_price_formula(json.dumps(_old_card))) or ""))
|
||
check("...while a card from this month draws no warning, so the rule is the "
|
||
"AGE and not the presence of the field",
|
||
stale_card(parse_price_formula(json.dumps(ENECO_CARD)),
|
||
today=date(2026, 9, 13)) is None)
|
||
|
||
print()
|
||
print("15f. NO EXPRESSION EVALUATION OF ANY KIND - and the check that says so")
|
||
# WHY THIS IS A CHECK AND NOT A PROMISE. This string is settable over HTTP: the
|
||
# add-on serves `POST config/save` -> `POST /addons/self/options` on its own
|
||
# ingress page, so `eval` on it is remote code execution in a root container
|
||
# holding SUPERVISOR_TOKEN, with homeassistant_api and hassio_api, driving an
|
||
# inverter over RS485. And the deciding argument is not security: two
|
||
# coefficients can be GRADED against the card's printed price and an arbitrary
|
||
# expression cannot be graded against anything, so an expression would make
|
||
# the invisible-wrong-coefficient problem strictly worse.
|
||
_EVAL_NAMES = ("eval", "exec", "compile", "__import__", "literal_eval",
|
||
"eval_expr", "evaluate", "Interpreter", "simple_eval")
|
||
|
||
|
||
def eval_calls(source):
|
||
"""Every call in `source` whose target NAME is an evaluator. AST, not grep.
|
||
|
||
⚠️ IT WALKS THE TREE RATHER THAN MATCHING TEXT, so `eval` inside a comment
|
||
or a docstring - and this file's own prose about eval - is not a hit, and
|
||
`ast.literal_eval(...)` written as `literal_eval(...)` is.
|
||
"""
|
||
found = []
|
||
for node in ast.walk(ast.parse(source)):
|
||
if not isinstance(node, ast.Call):
|
||
continue
|
||
fn = node.func
|
||
name = (fn.id if isinstance(fn, ast.Name)
|
||
else fn.attr if isinstance(fn, ast.Attribute) else "")
|
||
if name in _EVAL_NAMES:
|
||
found.append(name)
|
||
return sorted(set(found))
|
||
|
||
|
||
_CF_SRC = open(os.path.join(HERE, "app", "custom_formula.py"),
|
||
encoding="utf-8").read()
|
||
check(f"app/custom_formula.py evaluates NOTHING: {eval_calls(_CF_SRC) or '[]'}",
|
||
eval_calls(_CF_SRC) == [])
|
||
check("...and neither does the plugin class that reads it",
|
||
eval_calls(dedent(inspect.getsource(PROVIDERS["custom_formula"]))) == [])
|
||
# ⚠️ THE RED DIRECTION, EXERCISED ON EVERY RUN. A scanner that found nothing
|
||
# because it scans nothing is the third failure mode in MUTATION.md, and this
|
||
# is the cheap way to keep it honest.
|
||
check("...and the scanner CAN go red: planted `eval` and `literal_eval` calls "
|
||
"are both found",
|
||
eval_calls("def f(s):\n return eval(s)\n") == ["eval"]
|
||
and eval_calls("import ast\ndef f(s):\n"
|
||
" return ast.literal_eval(s)\n") == ["literal_eval"])
|
||
# and the structural half: the coefficients are NUMBERS, so there is nothing
|
||
# for an evaluator to be reached from even if one appeared.
|
||
# ⚠️ TARIFF-16 ADDED A FIFTH STRING FIELD (`option`) AND THE COUNT ALONE WOULD
|
||
# HAVE ABSORBED IT SILENTLY, so the field NAMES are asserted beside the count
|
||
# and the new one is pinned to a CLOSED SET rather than to "it is a str". A
|
||
# string field that took free text would be the grammar this module refuses to
|
||
# have, wearing a different key.
|
||
_T15_FIELDS = derived(lambda: {
|
||
k: type(v).__name__ for k, v in
|
||
vars(parse_price_formula(json.dumps(ENECO_CARD))).items()})
|
||
print(f" FormulaSpec fields: {_T15_FIELDS}")
|
||
check("the spec carries only fixed scalars - no field holds a string that is "
|
||
"later interpreted",
|
||
derived(lambda: sorted(_T15_FIELDS.values())) ==
|
||
["float"] * 5 + ["str"] * 5)
|
||
check(f"...and the five strings are named, not counted: four enumerations and "
|
||
f"a card month, plus TARIFF-16's `option`, which is a KEY OF `_LEGS` "
|
||
f"and not free text {sorted(_cf._LEGS)}",
|
||
derived(lambda: sorted(k for k, t in _T15_FIELDS.items()
|
||
if t == "str"))
|
||
== ["card_month", "index", "option", "resolution", "units"]
|
||
and derived(lambda: _T15_FIELDS and parse_price_formula(
|
||
json.dumps(ENECO_CARD)).option) in _cf._LEGS)
|
||
check("...and a field that LOOKS like an expression is refused as not a "
|
||
"number rather than parsed as one",
|
||
"not a number" in refusal(spoilt(factor="0.102 * spot")))
|
||
|
||
print()
|
||
print("15g. it declares {ENERGY, MARKUP}, so the framework adds taxes ONCE")
|
||
_cf_caps = PROVIDERS["custom_formula"].capabilities()
|
||
check(f"custom_formula sources {sorted(c.value for c in _cf_caps.sources)} - "
|
||
f"a Belgian card's Tariefformule line is the ENERGY component and the "
|
||
f"contract's own markup, and nothing else",
|
||
_cf_caps.sources == frozenset({Component.ENERGY, Component.MARKUP}))
|
||
check(f"...so the framework adds exactly "
|
||
f"{sorted(c.value for c in _cf_caps.adds)}",
|
||
_cf_caps.adds == frozenset({Component.TAXES}))
|
||
# THE COST OF THE ALTERNATIVE, MEASURED, so the declaration is not grading
|
||
# itself. Declaring ALL_IN would drop the taxes silently - on every hour, on
|
||
# behalf of an operator who typed exactly what their card says.
|
||
_as_declared = derived(lambda: assemble(
|
||
_cf_caps, [RawPoint(T0, 0.1555, False)], MARKUP, TAXES)[0].eur_per_kwh)
|
||
_as_all_in = derived(lambda: assemble(
|
||
ProviderCapabilities(product=PriceProduct.DAY_AHEAD, sources=ALL_IN),
|
||
[RawPoint(T0, 0.1555, False)], MARKUP, TAXES)[0].eur_per_kwh)
|
||
check(f"declaring ALL_IN would price every hour {_as_declared} -> "
|
||
f"{_as_all_in} EUR/kWh, exactly one taxes component low, on behalf of "
|
||
f"an operator who typed what their card said",
|
||
isinstance(_as_declared, float) and isinstance(_as_all_in, float)
|
||
and abs((_as_declared - _as_all_in) - TAXES) < 1e-9)
|
||
check("and the configured markup is reported as IGNORED under it, because the "
|
||
"contract's formula already IS the markup",
|
||
(lambda: (lambda b, h: (
|
||
logging.getLogger("goodwe.price").addHandler(h),
|
||
load_any("custom_formula"),
|
||
logging.getLogger("goodwe.price").removeHandler(h),
|
||
"custom_formula" in b.getvalue()
|
||
and "double-count" in b.getvalue())[-1])(
|
||
*(lambda b: (b, logging.StreamHandler(b)))(io.StringIO())))())
|
||
|
||
print()
|
||
print("15h. the startup preview - a factor of 1000 visible in one line")
|
||
_prev_buf = io.StringIO()
|
||
_prev_h = logging.StreamHandler(_prev_buf)
|
||
_prev_log = logging.getLogger("goodwe.price")
|
||
_prev_level = _prev_log.level
|
||
# ⚠️ THE PREVIEW IS AT INFO AND THE DEFAULT ROOT LEVEL IS WARNING, so a check
|
||
# that forgot this line would grade an empty buffer and read as "the preview
|
||
# is missing". main.py runs the add-on at INFO - that is where `tariff
|
||
# refreshed: %s` is emitted from - so this makes the check's logger match the
|
||
# shipped one rather than relaxing anything.
|
||
_prev_log.setLevel(logging.INFO)
|
||
_prev_log.addHandler(_prev_h)
|
||
_price._PREVIEWED.discard("custom_formula")
|
||
load("custom_formula", _CF_OPTS, transport=canned(_REAL[_QH]))
|
||
_first_run = _prev_buf.getvalue()
|
||
load("custom_formula", _CF_OPTS, transport=canned(_REAL[_QH]))
|
||
_second_run = _prev_buf.getvalue()[len(_first_run):]
|
||
_prev_log.removeHandler(_prev_h)
|
||
_prev_log.setLevel(_prev_level)
|
||
check("the preview says it prints the index in EUR/MWh beside the all-in "
|
||
"EUR/kWh",
|
||
"index EUR/MWh -> all-in EUR/kWh" in _first_run)
|
||
# ⚠️ AND THE ROW, NOT ONLY THE HEADING. The check above was the whole of this
|
||
# for one draft, and deleting the index column from the ROW was a -13 B
|
||
# SURVIVOR of the entire suite: the sentence stayed true while the thing it
|
||
# describes was gone. MUTATION.md's third failure mode, in the check written
|
||
# to close it. The expectation is built here from the captured document.
|
||
_row_index = _doc["price"][0]
|
||
_row_price = (0.102 * _row_index + 1.00) * 1.06 / 100.0 + TAXES
|
||
_want_row = f"{_row_index:9.3f} -> {_row_price:9.5f}"
|
||
check(f"...and each ROW really carries the index BESIDE its own price "
|
||
f"({_want_row!r}), which is the pair a heading alone does not print",
|
||
_want_row in _first_run)
|
||
check("...and the min/median/max and the day's spread, in one line",
|
||
"min " in _first_run and "median " in _first_run
|
||
and "max " in _first_run and "spread " in _first_run)
|
||
_want_min = round(min((0.102 * v + 1.00) * 1.06 / 100.0 + TAXES
|
||
for v in _doc["price"][:96]), 5)
|
||
check(f"...and the figures are the SERIES' own, not a restatement: the "
|
||
f"minimum printed is {_want_min:.5f}, computed here from the captured "
|
||
f"document",
|
||
f"min {_want_min:.5f}" in _first_run)
|
||
check("...and it is emitted ONCE per process, not on every half-hourly "
|
||
"refresh - a log line must not become a log flood on the one path "
|
||
"§11.4 already makes hard to read",
|
||
"index EUR/MWh -> all-in EUR/kWh" not in _second_run)
|
||
# ⚠️ AND IT CANNOT STOP A BATTERY. A preview that raises is a log line that
|
||
# failed, not a fetch that failed.
|
||
class _BoomPreview(PriceProvider):
|
||
"""A plugin whose preview raises. AC: prices are unaffected.
|
||
|
||
SOURCES: energy
|
||
The framework ADDS: markup, taxes
|
||
"""
|
||
|
||
id = "_preview_boom"
|
||
|
||
@classmethod
|
||
def capabilities(cls):
|
||
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY}))
|
||
|
||
async def fetch(self, opts, since, until, transport):
|
||
return [RawPoint(T0, 0.1, False)]
|
||
|
||
def startup_preview(self, points):
|
||
raise RuntimeError("the preview is broken")
|
||
|
||
|
||
PROVIDERS["_preview_boom"] = _BoomPreview
|
||
_boom_read = derived(lambda: load("_preview_boom", transport=refuse_transport))
|
||
del PROVIDERS["_preview_boom"]
|
||
check("a preview that RAISES does not cost the site its prices - the series "
|
||
"still comes back",
|
||
not isinstance(_boom_read, _Raised) and _boom_read.kind == "series")
|
||
|
||
print()
|
||
print("15i. the startup gate refuses it, so nothing is discovered at 03:00")
|
||
check("custom_formula with a correct card starts",
|
||
validate_startup_config(vbase(price_provider="custom_formula",
|
||
price_formula=json.dumps(ENECO_CARD)),
|
||
-5000, 5000) == [])
|
||
check("custom_formula with NO formula is refused, naming the option and "
|
||
"showing the shape",
|
||
why(validate_startup_config(vbase(price_provider="custom_formula"),
|
||
-5000, 5000),
|
||
"(price_formula) is empty"))
|
||
check("a formula that does not reproduce its card is refused AT STARTUP, "
|
||
"with the discrepancy named",
|
||
why(validate_startup_config(
|
||
vbase(price_provider="custom_formula",
|
||
price_formula=json.dumps(spoilt(offset=13.0))),
|
||
-5000, 5000), "the card says"))
|
||
check("...and a formula that is not JSON at all is refused with the shape it "
|
||
"wanted, rather than with a traceback",
|
||
why(validate_startup_config(
|
||
vbase(price_provider="custom_formula", price_formula="0.102*spot+1"),
|
||
-5000, 5000), "price_formula is not valid near character"))
|
||
check(f"...and a card outside the plausible band is refused too, WITH THE "
|
||
f"SHIPPED taxes in place - the flat "
|
||
f"{PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']} must not be able to "
|
||
f"carry a hundredfold error over the floor",
|
||
why(validate_startup_config(
|
||
vbase(price_provider="custom_formula",
|
||
price_formula=json.dumps(_both_sides)),
|
||
-5000, 5000), "plausible band"))
|
||
_masked = implausible_all_in(_bs_spec,
|
||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||
check(f"...and that is a REAL trap, measured: the same card plus the shipped "
|
||
f"{PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']} of taxes totals "
|
||
f"{price_eur_kwh(_bs_spec, _bs_spec.card_index_eur_per_mwh) + PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']:.5f} "
|
||
f"EUR/kWh, which is INSIDE the band - a floor applied to the all-in "
|
||
f"figure would have passed it",
|
||
isinstance(_masked, str)
|
||
and PLAUSIBLE_LO_EUR_KWH <= (
|
||
price_eur_kwh(_bs_spec, _bs_spec.card_index_eur_per_mwh)
|
||
+ PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||
<= PLAUSIBLE_HI_EUR_KWH)
|
||
# ⚠️ AND THE ONE ROW OF THE TABLE THAT IS A WARNING RATHER THAN A REFUSAL,
|
||
# graded AT THE GATE and not only at the parser - which is where the two could
|
||
# quietly become the same thing.
|
||
check("...while a card older than six months still STARTS: ENGIE freezes a "
|
||
"contract's coefficients for its duration, so an old card is often "
|
||
"exactly right and a site refused over one is a site that cannot run",
|
||
validate_startup_config(
|
||
vbase(price_provider="custom_formula",
|
||
price_formula=json.dumps(_old_card)), -5000, 5000) == [])
|
||
|
||
# ⚠️ THE ID IS IN THE SCHEMA, or the Supervisor refuses the options blob before
|
||
# the add-on runs and none of the sentences above is ever reached.
|
||
check("the id is offered by config.yaml's schema, so a site configured on it "
|
||
"reaches these messages rather than a Supervisor schema error",
|
||
"custom_formula" in _schema_ids)
|
||
|
||
|
||
# =========================================================================== #
|
||
# 12. TARIFF-11 - THE RENDERED SURFACE, GRADED AS A SURFACE
|
||
# =========================================================================== #
|
||
#
|
||
# WHAT THIS SECTION IS FOR, and it is the only thing on the card that is not a
|
||
# one-line fix. Four defects shipped together and every one of them survived 29
|
||
# green suites for the SAME reason: **nothing in this repository graded the
|
||
# strings a human is shown.** Every existing tariff check inspects the
|
||
# `TariffPlan` OBJECT - `plan.read.points`, `plan.firm_points()`, `verdict.kind`
|
||
# - and all four defects were in the SENTENCES built out of that object:
|
||
#
|
||
# 1. `TariffPlan.words` counted POINTS and called them HOURS, so a 33-hour
|
||
# plan logged `dynamic_dayahead from generic_dayahead, 132 h`;
|
||
# 2. §8.3's and the flat rung's refusals did the same, and those reach an
|
||
# operator through main.py's *Arbitrage* row, not only the log;
|
||
# 3. the plan reached NO status row at all - `rows["Tariff"]` was
|
||
# `static_status()`'s own sentence and read `'no static profile'` at all
|
||
# 802 samples of a 40-hour soak on a site whose feed was answering;
|
||
# 4. `rows["Arbitrage"]` rendered "trading is off" and "no decision was
|
||
# reached this cycle" as the SAME string, naming an option that was on.
|
||
#
|
||
# ⚠️ SO THIS SECTION GRADES `status_payload()["rows"]` AND THE REFUSAL
|
||
# SENTENCES, NOT THE OBJECTS BEHIND THEM. It renders through the shipped
|
||
# `Controller`, parses each number OUT OF THE ROW BY ITS OWN SLOT, and compares
|
||
# it against a figure this file computed from the FIXTURE'S CONSTRUCTION
|
||
# PARAMETERS - never from the property the row was built out of. That is
|
||
# MUTATION.md habit 4: if both sides of a comparison come from the same
|
||
# expression, the check is decoration.
|
||
#
|
||
# ⚠️ AND IT IS DELIBERATELY NOT A SUBSTRING TEST OVER A SENTENCE THIS FILE ALSO
|
||
# WROTE. Three checks in this chain turned out to be graded by something they
|
||
# did not own - a docstring, a deleted plugin's text, the HEADING above a table
|
||
# whose ROWS had been emptied (MUTATION.md, TARIFF-15 M13). `"132 points" in
|
||
# row` is that mistake again: it is satisfied by the number appearing anywhere,
|
||
# including in the other number's slot, which habit 14 shows is a ZERO-BYTE
|
||
# edit away. Every NUMBER below is asserted as a parsed slot against an integer
|
||
# derived here; every other claim is an INEQUALITY between two renderings that
|
||
# must not be equal. The ONE substring test is the rung-attribution needle
|
||
# (`"the tariff is flat"`, `"carry a published price"`) that TEST-16 already
|
||
# owns and grades on - it asks WHICH RUNG spoke, not what this file wrote.
|
||
|
||
_T11_NOW = datetime(2026, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||
_T11_BASE = dict(_T10_BASE, meter_entity="sensor.grid")
|
||
|
||
|
||
class _T11Hass(_T10Hass):
|
||
"""`_T10Hass`, with ONE reading switchable to `None`.
|
||
|
||
⚠️ THE `None` IS THE WHOLE STIMULUS FOR DEFECT 4. `inputs_ok` is False the
|
||
moment any pack's SoC does not answer, and `cycle()` then returns at the
|
||
input hold (`bad_for < stale_input_s`, 15 s by default) BEFORE it reaches
|
||
`self.arb = None`. A fixture whose sensors always answer cannot reach the
|
||
row this section is about.
|
||
"""
|
||
|
||
def __init__(self, soc=55.0):
|
||
self.soc = soc
|
||
|
||
async def number(self, entity, invert=False):
|
||
return {"sensor.grid": 0.0, "sensor.soc": self.soc,
|
||
"sensor.batt": 0.0}.get(entity)
|
||
|
||
|
||
def _t11_points(firm_h: int, carried_h: int, base=None) -> list:
|
||
"""`firm_h` QUARTER-HOURLY firm hours, then `carried_h` HOURLY carried ones.
|
||
|
||
FCST-04's real mixed shape, and the reason a single spacing is the wrong
|
||
model: the feed prints four points an hour and §8.3's carry-forward emits
|
||
exactly ONE point per unpublished hour, so a normal weekday-morning plan is
|
||
900 s at the front and 3600 s at the back. The soak measured that mix in
|
||
70 % of samples.
|
||
|
||
⚠️ THE PRICES ASCEND AND THE TWO BANDS DO NOT OVERLAP, so no check below
|
||
can pass by reading the wrong point: a mutation that grades a carried point
|
||
as firm moves a price as well as a count.
|
||
"""
|
||
t0 = base or _T11_NOW
|
||
pts = [PricePoint(t0 + timedelta(hours=h, minutes=15 * q),
|
||
round(0.100 + 0.001 * (4 * h + q), 5), False)
|
||
for h in range(firm_h) for q in range(4)]
|
||
pts += [PricePoint(t0 + timedelta(hours=firm_h + h),
|
||
round(0.500 + 0.001 * h, 5), True)
|
||
for h in range(carried_h)]
|
||
return pts
|
||
|
||
|
||
def _t11_plan(firm_h, carried_h, *, origin="provider",
|
||
provider="generic_dayahead", product=PriceProduct.DAY_AHEAD):
|
||
return TariffPlan(
|
||
PriceRead.series(_t11_points(firm_h, carried_h), product, provider,
|
||
_T11_NOW), None, origin)
|
||
|
||
|
||
def _t11_want(firm_h: int, carried_h: int) -> dict:
|
||
"""What a row about `_t11_points(firm_h, carried_h)` MUST say.
|
||
|
||
⚠️ COMPUTED FROM THE FIXTURE'S TWO CONSTRUCTION PARAMETERS AND FROM
|
||
NOTHING ELSE (habit 4). Not `len(plan.read.points)`, not `plan.hours`, not
|
||
`len(plan.firm_points())` - those are the expressions the row is built out
|
||
of, and a check that compares a rendered number against the property that
|
||
rendered it compares a thing against itself. `4 * firm_h` and
|
||
`firm_h + carried_h` are this file's own arithmetic over the shape it
|
||
asked for.
|
||
|
||
The spacing rule, stated rather than re-counted: consecutive quarters are
|
||
900 s apart, the last quarter of the last firm hour is 900 s before the
|
||
first carried hour, and consecutive carried hours are 3600 s apart - so
|
||
3600 appears only when there are at least TWO carried hours.
|
||
"""
|
||
gaps = ([900] if firm_h else []) + ([3600] if carried_h >= 2 else [])
|
||
return {"points": 4 * firm_h + carried_h, "hours": firm_h + carried_h,
|
||
"firm": 4 * firm_h, "carried": carried_h,
|
||
"spacings": gaps or [3600]}
|
||
|
||
|
||
class _NoRows(dict):
|
||
"""What a failed render hands back: a dict of `_Raised`, never a crash.
|
||
|
||
⚠️ FCST-06's LESSON, IN THE OPERAND RATHER THAN IN THE CONDITION. A
|
||
mutation that makes a render raise must report as ONE named red per check,
|
||
not as a traceback that takes the rest of the section with it and grades
|
||
none of it - a red COUNT is only a lower bound until the run is seen to
|
||
reach the end of its section.
|
||
"""
|
||
|
||
def __init__(self, e):
|
||
super().__init__()
|
||
self.e = e
|
||
|
||
def __missing__(self, key):
|
||
return _Raised(self.e)
|
||
|
||
|
||
class _NoCtl:
|
||
"""A controller a failed render never built. Every attribute is `_Raised`."""
|
||
|
||
def __init__(self, e):
|
||
self._e = e
|
||
|
||
def __getattr__(self, name):
|
||
return _Raised(self._e)
|
||
|
||
|
||
def _t11_render(over=None, *, plan=None, plan_age_s=0.0, soc=55.0, cycles=1,
|
||
age_arb_by_s=None, then_soc_none=False):
|
||
"""Drive the SHIPPED controller and hand back what a human would read.
|
||
|
||
Returns `(rows, ctl)` - `rows` is `dict(status_payload()["rows"])`, i.e.
|
||
the actual HTTP surface, and `ctl` is there so a check can ask the loop for
|
||
a figure to grade the row AGAINST (its own `reason`, its own `arb_at`).
|
||
|
||
⚠️ `read_inputs()` THEN `cycle()`, IN THAT ORDER, because that is what
|
||
`run_control()` does. A harness that called only `cycle()` would leave
|
||
every reading at `None` and grade an input-hold row it had manufactured
|
||
itself.
|
||
"""
|
||
opts = dict(_T11_BASE, **(over or {}))
|
||
tmp = tempfile.mkdtemp(prefix="tariff11-")
|
||
_tmpdirs_t10.append(tmp)
|
||
|
||
async def _go():
|
||
stop = asyncio.Event()
|
||
ctl = _main.Controller(opts, _T11Hass(soc), _T10Store(), _T10Pub(),
|
||
stop=stop,
|
||
peak_db_path=os.path.join(tmp, "t11.db"),
|
||
price_history_path=os.path.join(tmp, "ph.json"))
|
||
if plan is not None:
|
||
ctl.tariff_plan = plan
|
||
ctl.tariff_at = (datetime.now(timezone.utc)
|
||
- timedelta(seconds=plan_age_s))
|
||
for _ in range(cycles):
|
||
await ctl.read_inputs()
|
||
await ctl.cycle()
|
||
if then_soc_none:
|
||
# ⚠️ THE REAL STIMULUS FOR THE SECOND HALF OF DEFECT 4, and it is
|
||
# a control-flow one: a healthy cycle takes a decision, the SoC
|
||
# then stops answering, and the NEXT cycle returns at the input
|
||
# hold before it reaches the arbitrage step at all. `self.arb`
|
||
# still holds the previous decision - the row rendered it as a
|
||
# current one, with nothing saying the site had stopped deciding.
|
||
ctl.hass.soc = None
|
||
await ctl.read_inputs()
|
||
await ctl.cycle()
|
||
if age_arb_by_s is not None and ctl.arb_at is not None:
|
||
# ⚠️ THE CLOCK IS MOVED, NOT SLEPT THROUGH. The property under test
|
||
# is "the row reports the age of the decision", and a real 15 s
|
||
# wait would put a quarter of a minute into every run for a number
|
||
# the check can set exactly.
|
||
ctl.arb_at -= timedelta(seconds=age_arb_by_s)
|
||
return dict(ctl.status_payload()["rows"]), ctl
|
||
|
||
try:
|
||
return asyncio.run(_go())
|
||
except Exception as e: # noqa: BLE001
|
||
return _NoRows(e), _NoCtl(e)
|
||
|
||
|
||
# -- 12a. the instrument first: a parser that can tell right from wrong ------
|
||
# ⚠️ CALIBRATED BEFORE ANYTHING IS GRADED WITH IT (habit 1, and MUTATION.md's
|
||
# eleventh instance of habit 4 was in a measuring instrument rather than in a
|
||
# check). A slot parser that returns `None` for everything makes every row
|
||
# below vacuous; one that matches a number wherever it appears reads a
|
||
# transposition as a pass. So it is driven against a row that is RIGHT and a
|
||
# row that is WRONG IN EXACTLY THE WAY THE DEFECT WAS, and both answers are
|
||
# asserted.
|
||
_T11_SLOTS = {
|
||
# UX-08 2d: the row is in plain words; every figure keeps its own slot.
|
||
"points": r"(\d+) prices over \d+ hours",
|
||
"hours": r"\d+ prices over (\d+) hours",
|
||
"firm": r"(\d+) published and \d+ estimated",
|
||
"carried": r"\d+ published and (\d+) estimated",
|
||
"horizon": r"for (\d+) hours ahead",
|
||
"age_s": r"Price plan from (\d+) s ago",
|
||
# UX-10: the spacing is in words ("one every 15 minutes or every hour").
|
||
"spacings": r"one (every [a-z0-9 ]+?)(?:,|\.|$)",
|
||
# ⚠️ THE *Arbitrage* ROW'S OWN AGE SLOT, and it is deliberately the same
|
||
# pattern for "decided N s ago" and "the last one was taken N s ago" -
|
||
# WHICH of the two sentences was rendered is graded by inequality below,
|
||
# and this only has to read the number out of whichever one it is.
|
||
"arb_age_s": r"(\d+) s ago",
|
||
# ⚠️ THE REFUSAL SENTENCES GET SLOTS TOO, for the rows' reason: `"1 of 96"
|
||
# in reason` is satisfied by that pair standing in the HOUR clause, which
|
||
# is the transposition the defect actually was.
|
||
"r83_firm_pts": r"only (\d+) of \d+ points carry a published price",
|
||
"r83_all_pts": r"only \d+ of (\d+) points carry a published price",
|
||
"r83_firm_h": r"published price - (\d+) of \d+ hours",
|
||
"r83_all_h": r"published price - \d+ of (\d+) hours",
|
||
"flat_pts": r"every one of the (\d+) firm points, spanning \d+ hours",
|
||
"flat_h": r"every one of the \d+ firm points, spanning (\d+) hours",
|
||
}
|
||
|
||
|
||
def _spacing_s(raw):
|
||
"""`every 15 minutes or every hour` -> `[900, 3600]`; raises otherwise."""
|
||
unit = {"second": 1, "minute": 60, "hour": 3600}
|
||
out = []
|
||
for part in raw.split(" or "):
|
||
m = re.fullmatch(r"every (?:(\d+) )?(second|minute|hour)s?", part.strip())
|
||
if m is None:
|
||
raise ValueError(f"no spacing in {part!r}")
|
||
out.append(int(m.group(1) or 1) * unit[m.group(2)])
|
||
return out
|
||
|
||
|
||
def _slot(row, name):
|
||
"""The number in ONE NAMED SLOT of a rendered row, or `_Raised`.
|
||
|
||
⚠️ BY SLOT, NEVER BY PRESENCE. `"33" in row` is true of a row that prints
|
||
33 in the POINT slot, which is a zero-byte transposition away and is
|
||
exactly MUTATION.md habit 14's finding. A slot that does not match is a
|
||
FAILED derivation and reports as one; it is not a miss that passes.
|
||
"""
|
||
def _get():
|
||
if not isinstance(row, str):
|
||
raise TypeError(f"no row to read: {row!r}")
|
||
m = re.search(_T11_SLOTS[name], row)
|
||
if m is None:
|
||
raise ValueError(f"no {name!r} slot in {row[:90]!r}")
|
||
raw = m.group(1)
|
||
return (_spacing_s(raw) if name == "spacings" else int(raw))
|
||
return derived(_get)
|
||
|
||
|
||
_T11_GOOD = ("x. Price plan from 7 s ago, for 48 hours ahead: day-ahead "
|
||
"prices from the price feed, 132 prices over 33 hours, "
|
||
"36 published and 96 estimated, one every 15 minutes or every "
|
||
"hour")
|
||
_T11_SHIPPED_DEFECT = ("x. Price plan from 7 s ago, for 48 hours ahead: "
|
||
"day-ahead prices from the price feed, 132 prices over "
|
||
"132 hours, 96 published and 36 estimated, one every "
|
||
"15 minutes or every hour")
|
||
# The slots divide into the ones a *Tariff*/*Arbitrage* ROW carries and the
|
||
# ones a REFUSAL sentence carries; each group is calibrated against its own
|
||
# well-formed example, because a slot asked of the wrong kind of sentence is
|
||
# correctly absent and would make this check red for the wrong reason.
|
||
_T11_ROW_SLOTS = ("points", "hours", "firm", "carried", "horizon", "age_s",
|
||
"spacings", "arb_age_s")
|
||
_T11_REF_SLOTS = ("r83_firm_pts", "r83_all_pts", "r83_firm_h", "r83_all_h",
|
||
"flat_pts", "flat_h")
|
||
_T11_GOOD_REF = ("§8.3: only 1 of 96 points carry a published price - 1 of 24 "
|
||
"hours - and the rest are provisional; the tariff is flat - "
|
||
"every one of the 96 firm points, spanning 24 hours, costs")
|
||
print(f" slot parser on a correct row -> "
|
||
f"{ {k: _slot(_T11_GOOD, k) for k in _T11_ROW_SLOTS} }")
|
||
print(f" slot parser on a refusal -> "
|
||
f"{ {k: _slot(_T11_GOOD_REF, k) for k in _T11_REF_SLOTS} }")
|
||
check("the slot parser reads every field of a well-formed row and of a "
|
||
"well-formed refusal - at a single unreadable slot every check below "
|
||
"this line would be graded on a failed derivation rather than on the "
|
||
"sentence",
|
||
all(not isinstance(_slot(_T11_GOOD, k), _Raised) for k in _T11_ROW_SLOTS)
|
||
and all(not isinstance(_slot(_T11_GOOD_REF, k), _Raised)
|
||
for k in _T11_REF_SLOTS)
|
||
and sorted(_T11_ROW_SLOTS + _T11_REF_SLOTS) == sorted(_T11_SLOTS)
|
||
and _slot(_T11_GOOD, "points") == 132
|
||
and _slot(_T11_GOOD, "hours") == 33)
|
||
check("...and it TELLS THE TWO DEFECTS APART from the correct row: the shipped "
|
||
"point<->hour conflation (132 h for 33 h) and a firm/carried "
|
||
"transposition both move a slot, so the parser is not merely finding "
|
||
"numbers somewhere in a sentence",
|
||
_slot(_T11_SHIPPED_DEFECT, "hours") == 132
|
||
and _slot(_T11_SHIPPED_DEFECT, "firm") == 96
|
||
and _slot(_T11_SHIPPED_DEFECT, "points")
|
||
== _slot(_T11_GOOD, "points"))
|
||
check("...and a missing slot is a FAILED derivation, not a silent pass - the "
|
||
"difference between 'the row does not say that' and 'the row says "
|
||
"nothing', which is why `derived()` exists in this file",
|
||
isinstance(_slot("nothing useful here", "points"), _Raised))
|
||
|
||
|
||
# -- 12b. the fixture SET, and the degeneracy it is built to avoid ------------
|
||
# ⚠️ ONE ARRANGEMENT CANNOT SEPARATE FIVE NUMBERS (habit 10). On the shipped
|
||
# healthy plan - an all-firm quarter-hourly day - `points` and `firm` are the
|
||
# SAME INTEGER and `carried` is 0, so a row that printed the point count in the
|
||
# firm slot would pass. On an hourly static profile `points` and `hours` are
|
||
# the same integer, which is precisely why the defect shipped: every fixture
|
||
# that existed before TARIFF-08 was hourly. So the fixtures are a SET, and the
|
||
# table below is COMPUTED and PRINTED - a pair of fields no row separates shows
|
||
# up as a named survivor and takes this check red, rather than being absent
|
||
# from a list nobody re-derived.
|
||
_T11_SHAPES = (
|
||
("all-firm quarter-hourly, the live healthy plan", 33, 0),
|
||
("mixed: 9 published hours, 24 carried - the weekday morning", 9, 24),
|
||
("§11.4 fallback: nothing published, 12 hourly carried", 0, 12),
|
||
("late morning: 6 published hours, 3 carried", 6, 3),
|
||
)
|
||
_T11_FIELDS = ("points", "hours", "firm", "carried", "horizon")
|
||
_T11_TABLE = [(lbl, dict(_t11_want(f, c), horizon=48))
|
||
for lbl, f, c in _T11_SHAPES]
|
||
print(" fixture set (points / hours / firm / carried / horizon):")
|
||
for _lbl, _w in _T11_TABLE:
|
||
print(f" {_w['points']:>4} {_w['hours']:>4} {_w['firm']:>4} "
|
||
f"{_w['carried']:>4} {_w['horizon']:>4} {_lbl}")
|
||
_T11_UNSEP = sorted(
|
||
f"{a}=={b}" for i, a in enumerate(_T11_FIELDS) for b in _T11_FIELDS[i + 1:]
|
||
if not any(w[a] != w[b] for _lbl, w in _T11_TABLE))
|
||
print(f" field pairs NO fixture separates: {_T11_UNSEP or 'none'}")
|
||
check(f"every pair of the five numbers the *Tariff* row prints is told apart "
|
||
f"by at least one fixture - unseparated pairs {_T11_UNSEP}. A pair that "
|
||
f"is equal in every fixture is one the checks below cannot tell "
|
||
f"transposed, which is a ZERO-BYTE edit (habit 14)",
|
||
_T11_UNSEP == [])
|
||
check("...and the set contains BOTH the degenerate hourly arrangement (points "
|
||
"== hours, where the defect was invisible for three cards) and the "
|
||
"quarter-hourly one where they differ 4x, so the row is pinned on the "
|
||
"shape that hid it as well as on the shape that exposes it",
|
||
any(w["points"] == w["hours"] for _l, w in _T11_TABLE)
|
||
and any(w["points"] == 4 * w["hours"] for _l, w in _T11_TABLE))
|
||
|
||
|
||
# -- 12c. THE *Tariff* ROW ITSELF ---------------------------------------------
|
||
_T11_ROWS = [(lbl, f, c,
|
||
derived(lambda f=f, c=c: _t11_render(
|
||
{"price_provider": "generic_dayahead"},
|
||
plan=_t11_plan(f, c))[0]["Tariff"]))
|
||
for lbl, f, c in _T11_SHAPES]
|
||
for _lbl, _f, _c, _row in _T11_ROWS:
|
||
print(f" Tariff row | {_lbl}")
|
||
print(f" {_row}")
|
||
|
||
_T11_BAD = [f"{lbl}: {k} slot reads {_slot(row, k)!r}, fixture says "
|
||
f"{_t11_want(f, c)[k]!r}"
|
||
for lbl, f, c, row in _T11_ROWS
|
||
for k in ("points", "hours", "firm", "carried", "spacings")
|
||
if _slot(row, k) != _t11_want(f, c)[k]]
|
||
check(f"AC: EVERY number the rendered *Tariff* row prints matches what this "
|
||
f"file computed from the fixture's own two parameters, in its OWN slot, "
|
||
f"across all four shapes - disagreements {_T11_BAD}. This is the check "
|
||
f"that did not exist: `words` said `132 h` about 33 hours for three "
|
||
f"cards and no suite could see it, because no suite read the sentence",
|
||
_T11_BAD == [])
|
||
check(f"...and the HORIZON reaches the row as hours, from the option rather "
|
||
f"than from the plan - "
|
||
f"{[_slot(r, 'horizon') for _l, _f, _c, r in _T11_ROWS]} against "
|
||
f"`price_horizon_h` = "
|
||
f"{price_option(dict(_T11_BASE), 'price_horizon_h')}. The soak measured "
|
||
f"48.0 at every sample and `words` never said 48",
|
||
all(_slot(r, "horizon")
|
||
== int(float(price_option(dict(_T11_BASE), "price_horizon_h")))
|
||
for _l, _f, _c, r in _T11_ROWS))
|
||
|
||
# ⚠️ ORIGIN AND PRODUCT ARE GRADED BY DISAGREEMENT, NOT BY A SUBSTRING. Both
|
||
# are enum-ish labels, so `"provider" in row` is satisfied by the word
|
||
# "provider" this row already contains twice for other reasons. Two plans over
|
||
# the IDENTICAL point list, differing in exactly one of the two fields, must
|
||
# render differently - which is false of any row that drops the field.
|
||
# ⚠️ AND THE FIXTURE VARIES `origin` **ALONE**. The first draft of this pair
|
||
# gave the fallback plan `provider="static_profile"` as well - which is what a
|
||
# real §11.4 plan carries - so the two rows differed for TWO reasons and the
|
||
# mutation that drops `origin` from the sentence entirely (tag <- read.provider,
|
||
# -190 B) was a SURVIVOR of the whole suite. That is MUTATION.md habit 4 in the
|
||
# check written to close this card: a difference-claim whose two sides differ in
|
||
# more than the thing it names cannot grade that thing. The provider string is
|
||
# held EQUAL below, as its own precondition check.
|
||
_T11_ID_PTS = (9, 24)
|
||
_T11_PLAN_PROV = _t11_plan(*_T11_ID_PTS, origin="provider")
|
||
_T11_PLAN_FALL = _t11_plan(*_T11_ID_PTS, origin="fallback")
|
||
_T11_AS_PROVIDER = derived(lambda: _t11_render(
|
||
{"price_provider": "generic_dayahead"},
|
||
plan=_T11_PLAN_PROV)[0]["Tariff"])
|
||
_T11_AS_FALLBACK = derived(lambda: _t11_render(
|
||
{"price_provider": "generic_dayahead"},
|
||
plan=_T11_PLAN_FALL)[0]["Tariff"])
|
||
check(f"the origin pair differs in `origin` AND IN NOTHING ELSE (habit 1): "
|
||
f"provider strings {_T11_PLAN_PROV.read.provider!r} == "
|
||
f"{_T11_PLAN_FALL.read.provider!r}, products equal, point lists equal, "
|
||
f"origins {_T11_PLAN_PROV.origin!r} != {_T11_PLAN_FALL.origin!r}. Give "
|
||
f"the fallback its own provider string and the row below differs for a "
|
||
f"second reason, which is how the -190 B mutation survived the first "
|
||
f"draft",
|
||
_T11_PLAN_PROV.read.provider == _T11_PLAN_FALL.read.provider
|
||
and _T11_PLAN_PROV.product == _T11_PLAN_FALL.product
|
||
and _T11_PLAN_PROV.origin != _T11_PLAN_FALL.origin
|
||
and [p.canonical() for p in _T11_PLAN_PROV.read.points]
|
||
== [p.canonical() for p in _T11_PLAN_FALL.read.points])
|
||
_T11_AS_REALTIME = derived(lambda: _t11_render(
|
||
{"price_provider": "generic_dayahead"},
|
||
plan=_t11_plan(*_T11_ID_PTS, product=PriceProduct.REALTIME))[0]["Tariff"])
|
||
print(f" origin='provider' -> {_T11_AS_PROVIDER[-88:]!r}")
|
||
print(f" origin='fallback' -> {_T11_AS_FALLBACK[-88:]!r}")
|
||
print(f" product=REALTIME -> {_T11_AS_REALTIME[-88:]!r}")
|
||
check("AC: the plan's ORIGIN reaches the row - two plans over the IDENTICAL "
|
||
"point list, one observed from the feed and one §11.4 standing in for a "
|
||
"dead one, render DIFFERENTLY. Every count in them is equal, so a row "
|
||
"that dropped `origin` could not tell an operator that the prices on "
|
||
"screen are the static profile rather than the market",
|
||
_T11_AS_PROVIDER != _T11_AS_FALLBACK
|
||
and all(_slot(_T11_AS_PROVIDER, k) == _slot(_T11_AS_FALLBACK, k)
|
||
for k in ("points", "hours", "firm", "carried")))
|
||
check("...and so does the PRODUCT, on the same terms - §9.2's product is what "
|
||
"the optimiser widens its thresholds on, and it was on no surface",
|
||
_T11_AS_REALTIME != _T11_AS_PROVIDER
|
||
and all(_slot(_T11_AS_REALTIME, k) == _slot(_T11_AS_PROVIDER, k)
|
||
for k in ("points", "hours", "firm", "carried")))
|
||
|
||
# ⚠️ THE TWO TARIFF SOURCES ARE NOT COMPUTED AT THE SAME MOMENT AND THE ROW
|
||
# MAY NOT IMPLY THEY WERE. `static_status()` runs on the 30 s I/O-free tick;
|
||
# the provider plan comes off the 1800 s refresh and is served for up to
|
||
# TARIFF_MAX_AGE_S - 5400 s since TARIFF-12. So the plan half carries its own
|
||
# age, and the age is graded against a number this file SET rather than
|
||
# against one the row supplied.
|
||
_T11_FRESH = derived(lambda: _t11_render(
|
||
{"price_provider": "generic_dayahead"},
|
||
plan=_t11_plan(9, 24), plan_age_s=0.0)[0]["Tariff"])
|
||
_T11_OLD = derived(lambda: _t11_render(
|
||
{"price_provider": "generic_dayahead"},
|
||
plan=_t11_plan(9, 24), plan_age_s=4000.0)[0]["Tariff"])
|
||
print(f" plan 0 s old -> age slot {_slot(_T11_FRESH, 'age_s')!r}")
|
||
print(f" plan 4000 s old -> age slot {_slot(_T11_OLD, 'age_s')!r}")
|
||
check(f"AC/trap: the row does NOT present the 30 s tick's sentence and a "
|
||
f"4000-second-old plan as one measurement - the plan half is stamped "
|
||
f"with its own age, and the age MOVES with `tariff_at`: "
|
||
f"{_slot(_T11_FRESH, 'age_s')} s against {_slot(_T11_OLD, 'age_s')} s "
|
||
f"for the SAME plan object. A constant, or an age taken from the render "
|
||
f"clock, gives the same number twice",
|
||
isinstance(_slot(_T11_OLD, "age_s"), int)
|
||
and 4000 <= _slot(_T11_OLD, "age_s") <= 4010
|
||
and _slot(_T11_FRESH, "age_s") <= 5)
|
||
check(f"...and the two halves are still BOTH there, in order: the tick's "
|
||
f"static sentence for this provider-only site - "
|
||
f"{static_status(dict(_T11_BASE), _T11_NOW)[1]!r} - leads, and the plan "
|
||
f"follows it. Replacing the row wholesale would lose the cheap-window "
|
||
f"evidence TARIFF-03 put there",
|
||
_T11_FRESH.startswith(static_status(dict(_T11_BASE), _T11_NOW)[1]))
|
||
|
||
# ⚠️ AND THE STATE THE SOAK ACTUALLY MEASURED: a provider configured, and no
|
||
# plan. 802 samples of `'no static profile'`, which is the row a site with no
|
||
# provider prints too - so the surface could not tell "trading on a live feed"
|
||
# from "no tariff machinery at all".
|
||
_T11_NOPLAN = derived(lambda: _t11_render(
|
||
{"price_provider": "generic_dayahead"}, plan=None)[0]["Tariff"])
|
||
_T11_NOPROV = derived(lambda: _t11_render({}, plan=None)[0]["Tariff"])
|
||
print(f" provider set, no plan -> {_T11_NOPLAN!r}")
|
||
print(f" no provider at all -> {_T11_NOPROV!r}")
|
||
check(f"AC: a site with a `price_provider` and NO usable plan says so, and "
|
||
f"reads differently from a site that configured no provider at all. "
|
||
f"Both printed {_T11_NOPROV!r} before this card - all 802 soak samples, "
|
||
f"on an install whose feed was answering",
|
||
_T11_NOPLAN != _T11_NOPROV
|
||
and str(int(_main.TARIFF_MAX_AGE_S)) in _T11_NOPLAN)
|
||
|
||
|
||
# -- 12d. THE *Arbitrage* ROW: THREE FACTS THAT RENDERED AS TWO ---------------
|
||
# ⚠️ THE STIMULUS IS A DRIVEN CYCLE WITH A SENSOR ANSWERING `None`, not a
|
||
# hand-set attribute. The defect is a control-flow one - `cycle()` returns at
|
||
# the input hold BEFORE `self.arb = None` - and a fixture that assigned
|
||
# `ctl.arb = None` itself would have reproduced the symptom without ever
|
||
# touching the path that causes it.
|
||
_T11_OFF = derived(lambda: _t11_render({}, soc=None))
|
||
_T11_ON_HELD = derived(lambda: _t11_render({"arbitrage_enabled": True},
|
||
soc=None))
|
||
_T11_ON_OK = derived(lambda: _t11_render({"arbitrage_enabled": True},
|
||
soc=55.0))
|
||
_T11_ON_STALE = derived(lambda: _t11_render({"arbitrage_enabled": True},
|
||
soc=55.0, age_arb_by_s=13))
|
||
_T11_ON_DROPPED = derived(lambda: _t11_render({"arbitrage_enabled": True},
|
||
soc=55.0, then_soc_none=True))
|
||
print(f" trading off, inputs missing -> {_T11_OFF[0]['Arbitrage']!r}")
|
||
print(f" trading ON, inputs missing -> {_T11_ON_HELD[0]['Arbitrage']!r}")
|
||
print(f" trading ON, inputs fine -> {_T11_ON_OK[0]['Arbitrage']!r}")
|
||
print(f" trading ON, decision 13 s old-> {_T11_ON_STALE[0]['Arbitrage']!r}")
|
||
print(f" trading ON, SoC then dropped -> {_T11_ON_DROPPED[0]['Arbitrage']!r}")
|
||
|
||
check(f"the stimulus is the one the card names, and it is asserted rather "
|
||
f"than assumed (habit 1): `arbitrage.enabled(opts)` is "
|
||
f"{_arb.enabled(_T11_ON_HELD[1].o)} while `controller.arb` is "
|
||
f"{_T11_ON_HELD[1].arb!r}. If a later edit makes `cycle()` reach the "
|
||
f"arbitrage step under an input hold, this goes red rather than the "
|
||
f"rows below going quietly vacuous",
|
||
_arb.enabled(_T11_ON_HELD[1].o) is True
|
||
and _T11_ON_HELD[1].arb is None
|
||
and not _T11_ON_HELD[1].inputs_ok)
|
||
check(f"AC, AND THE WHOLE OF DEFECT 4: 'trading is off' and 'trading is on "
|
||
f"and no decision was reached this cycle' RENDER DIFFERENTLY. They are "
|
||
f"different facts with different remedies and this row printed "
|
||
f"`off (arbitrage_enabled)` for both - naming an option that is `true` "
|
||
f"in the options blob",
|
||
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
|
||
# ⚠️ THE "Why" ROW OF THE SAME RENDER, NOT A SECOND READ OF `why_words`. Since
|
||
# UX-10 the waiting sentence counts its seconds when it is read, so reading the
|
||
# property again here, after the other renders above, could cross a second
|
||
# ("1 second" in the row, "2 seconds" now) - CI run 953 on PR #141 did.
|
||
check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON "
|
||
f"({_T11_ON_HELD[0]['Why']!r}), so the operator is told which input is "
|
||
f"missing rather than being sent to look at a switch that is already on. "
|
||
f"The reason comes from main.py, not from this file",
|
||
_T11_ON_HELD[0]["Why"].startswith("A reading is missing")
|
||
and _T11_ON_HELD[0]["Why"] in _T11_ON_HELD[0]["Arbitrage"])
|
||
check(f"...and a cycle that DID reach the step renders the strategy's own "
|
||
f"sentence - {_T11_ON_OK[1].arb.explain()[:50]!r} - rather than a "
|
||
f"re-derivation of it, which is the guarantee OPT-04 put on this row and "
|
||
f"the one thing about it that was already right",
|
||
_T11_ON_OK[1].arb is not None
|
||
and _T11_ON_OK[1].arb.explain() in _T11_ON_OK[0]["Arbitrage"]
|
||
and _T11_ON_OK[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
|
||
check(f"...and all THREE states are pairwise distinct, so no pair of them can "
|
||
f"be collapsed by a later edit without a named red here",
|
||
len({_T11_OFF[0]["Arbitrage"], _T11_ON_HELD[0]["Arbitrage"],
|
||
_T11_ON_OK[0]["Arbitrage"]}) == 3)
|
||
_T11_AGE = _slot(_T11_ON_STALE[0]["Arbitrage"], "arb_age_s")
|
||
check(f"AC: a decision the CURRENT cycle did not take is rendered as one - "
|
||
f"age slot {_T11_AGE} s against the 13 s this file moved `arb_at` back "
|
||
f"by. The card's second half: during an input hold the row went on "
|
||
f"showing the last decision, up to `stale_input_s` (15 s) stale, with "
|
||
f"nothing on the page saying so",
|
||
isinstance(_T11_AGE, int) and 13 <= _T11_AGE <= 18
|
||
and _T11_ON_STALE[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"])
|
||
# ⚠️ AND THE SAME CLAIM ON THE STIMULUS THAT ACTUALLY PRODUCES IT. The row
|
||
# above moves `arb_at` by hand so the AGE can be graded against an exact
|
||
# number; this one drives the control-flow path itself - one healthy cycle,
|
||
# then a sensor that stops answering - and asserts that the shipped controller
|
||
# reaches the state the card describes, rather than that this file can simulate
|
||
# it. Without this row a mutation that never sets `cycle_at` would still pass,
|
||
# because the hand-aged fixture would go on rendering `stale`.
|
||
# ⚠️ `derived()`, NOT A BARE COMPARISON, AND THE BATTERY IS WHY. The mutation
|
||
# that never stamps `cycle_at` leaves it at `__init__`'s None, and
|
||
# `datetime < None` is a TypeError - in the f-string OPERAND as much as in the
|
||
# condition. Driven, the first draft of this check produced a TRACEBACK and
|
||
# NO NAMED RED, grading none of the checks after it: FCST-06's finding, in the
|
||
# check written for this card. "It answered nothing" and "it broke on the way
|
||
# to answering" must not grade the same.
|
||
_T11_DROP_STALE = derived(
|
||
lambda: _T11_ON_DROPPED[1].arb_at < _T11_ON_DROPPED[1].cycle_at)
|
||
check(f"DRIVEN, no clock touched: a decision stands "
|
||
f"({_T11_ON_DROPPED[1].arb is not None}) while the LATEST cycle returned "
|
||
f"at the input hold without reaching the arbitrage step "
|
||
f"(arb_at < cycle_at = {_T11_DROP_STALE!r}), and the row says so instead "
|
||
f"of presenting the standing decision as this cycle's",
|
||
_T11_ON_DROPPED[1].arb is not None
|
||
and not _T11_ON_DROPPED[1].inputs_ok
|
||
and _T11_DROP_STALE is True
|
||
and _T11_ON_DROPPED[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"])
|
||
check(f"...and it names the reason the cycle stopped "
|
||
f"({_T11_ON_DROPPED[1].why_words!r}) beside the age of the decision it is "
|
||
f"still showing, so 'the strategy decided this' and 'the strategy last "
|
||
f"decided this' are not one sentence",
|
||
_T11_ON_DROPPED[1].why_words in _T11_ON_DROPPED[0]["Arbitrage"]
|
||
and isinstance(_slot(_T11_ON_DROPPED[0]["Arbitrage"], "arb_age_s"), int))
|
||
|
||
check("...and `main.py`'s 'if and only if' is graded as BEHAVIOUR rather than "
|
||
"as prose: a comment is not a check, and three findings in this chain "
|
||
"were graded by a sentence their author also wrote. What makes the "
|
||
"comment true is that the row reads the OPTION - so with the option on "
|
||
"and no decision taken, the row does not equal the option-off row, which "
|
||
"is the row above",
|
||
_arb.enabled(_T11_ON_HELD[1].o)
|
||
and _T11_ON_HELD[1].arb is None
|
||
and _T11_ON_HELD[0]["Arbitrage"] != _T11_OFF[0]["Arbitrage"])
|
||
|
||
|
||
# -- 12e. the two REFUSAL sentences, which reach this same row ----------------
|
||
# ⚠️ THESE ARE OPERATOR-FACING THROUGH `self.arb.explain()`, WHICH IS WHY THEY
|
||
# ARE HERE AND NOT ONLY IN test_static_tariff.py. `§8.3: only 1 of 96 hours
|
||
# carry a published price` is 96 POINTS, which is 24 hours - the same
|
||
# conflation TARIFF-14 removed from `at()`, left standing in the prose that
|
||
# describes it.
|
||
_T11_A9 = {"peak_guard_active": False, "min_spread_eur_kwh": 0.0,
|
||
"uncertainty": frozenset()}
|
||
_T11_ONEFIRM = TariffPlan(
|
||
PriceRead.series(
|
||
[PricePoint(_T11_NOW + timedelta(minutes=15 * i),
|
||
round(0.10 + 0.001 * i, 5), i != 0) for i in range(96)],
|
||
PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider")
|
||
_T11_FLAT = TariffPlan(
|
||
PriceRead.series(
|
||
[PricePoint(_T11_NOW + timedelta(minutes=15 * i), 0.10, False)
|
||
for i in range(96)],
|
||
PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider")
|
||
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(now=LONG_AGO, **_T11_A9).reason)
|
||
_rflat = derived(lambda: _T11_FLAT.arbitrage(now=LONG_AGO, **_T11_A9).reason)
|
||
print(f" §8.3 refusal : {_r83[:118]}")
|
||
print(f" flat refusal : {_rflat[:118]}")
|
||
# 96 quarter-hourly points span 24 hours; ONE firm point spans one of them.
|
||
# Both figures are this file's arithmetic over the fixture it built.
|
||
# The fixture is 96 points at 15-minute spacing, built four lines above, so it
|
||
# spans 96 // 4 = 24 hours and its single firm point occupies ONE of them.
|
||
# Both expectations are this file's arithmetic over the shape it asked for.
|
||
_T11_R83 = {k: _slot(_r83, k) for k in
|
||
("r83_firm_pts", "r83_all_pts", "r83_firm_h", "r83_all_h")}
|
||
print(f" §8.3 slots : {_T11_R83}")
|
||
print(f" flat slots : "
|
||
f"{ {k: _slot(_rflat, k) for k in ('flat_pts', 'flat_h')} }")
|
||
check(f"AC: §8.3's refusal reports POINTS and HOURS as two quantities, each in "
|
||
f"its OWN slot - {_T11_R83} over a fixture of 96 quarter-hourly points "
|
||
f"spanning 24 hours, of which one is firm. It read `only 1 of 96 hours` "
|
||
f"about a 24-hour day, and this sentence reaches the status page",
|
||
_T11_R83["r83_firm_pts"] == 1 and _T11_R83["r83_all_pts"] == 96
|
||
and _T11_R83["r83_firm_h"] == 1 and _T11_R83["r83_all_h"] == 96 // 4)
|
||
check(f"...and the two clauses are not the same pair twice: the POINT clause "
|
||
f"and the HOUR clause disagree, which is exactly what the shipped "
|
||
f"sentence could not express",
|
||
_T11_R83["r83_all_pts"] != _T11_R83["r83_all_h"])
|
||
check(f"...and the flat-tariff refusal does the same - it counted the same 96 "
|
||
f"points and called them `firm hours`: slots "
|
||
f"{_slot(_rflat, 'flat_pts')} points, {_slot(_rflat, 'flat_h')} hours",
|
||
_slot(_rflat, "flat_pts") == 96
|
||
and _slot(_rflat, "flat_h") == 96 // 4)
|
||
check("...and neither sentence lost what TEST-16 put in it: §8.3's refusal is "
|
||
"still attributable to the FIRM-PAIR rung by its own words, and does not "
|
||
"degrade into the flat rung's sentence about a series whose prices "
|
||
"differ by 0.095 EUR/kWh",
|
||
"§8.3" in _r83 and "carry a published price" in _r83
|
||
and "the tariff is flat" not in _r83
|
||
and "the tariff is flat" in _rflat)
|
||
|
||
|
||
# -- 12f. END TO END, against the COMMITTED capture --------------------------
|
||
# ⚠️ EVERY FIXTURE ABOVE IS SYNTHETIC, AND habit 19 IS WHY THIS ROW EXISTS.
|
||
# The shapes above are this file's own arithmetic; they cannot tell whether the
|
||
# SHIPPED refresh puts a real plan on the row. This drives `run_tariff_refresh`
|
||
# against `scenarios/energy-charts-be-price-*.json` - bytes captured from
|
||
# api.energy-charts.info - and grades the row against the CAPTURE'S OWN point
|
||
# count, which neither main.py nor static_tariff.py shaped.
|
||
_T11_N_CAP = len(_CAPTURE["price"])
|
||
|
||
|
||
def _t11_e2e():
|
||
tmp = tempfile.mkdtemp(prefix="tariff11-e2e-")
|
||
_tmpdirs_t10.append(tmp)
|
||
opts = dict(_T11_BASE, price_provider="generic_dayahead")
|
||
|
||
async def _get(url, timeout_s):
|
||
return _restamped_body(datetime.now(timezone.utc))
|
||
|
||
real = _price.https_get
|
||
_price.https_get = _get
|
||
try:
|
||
async def _go():
|
||
stop = asyncio.Event()
|
||
ctl = _main.Controller(opts, _T11Hass(), _T10Store(), _T10Pub(),
|
||
stop=stop,
|
||
peak_db_path=os.path.join(tmp, "e.db"),
|
||
price_history_path=os.path.join(tmp, "p.json"))
|
||
task = asyncio.create_task(ctl.run_tariff_refresh())
|
||
for _ in range(250): # ~5 s ceiling, habit 5
|
||
if ctl.tariff_at is not None:
|
||
break
|
||
await asyncio.sleep(0.02)
|
||
ctl.stopping = True
|
||
stop.set()
|
||
await asyncio.wait_for(task, timeout=5)
|
||
await ctl.read_inputs()
|
||
await ctl.cycle()
|
||
return dict(ctl.status_payload()["rows"])
|
||
return asyncio.run(_go())
|
||
finally:
|
||
_price.https_get = real
|
||
|
||
|
||
_T11_E2E = derived(_t11_e2e)
|
||
_T11_E2E_ROW = "" if isinstance(_T11_E2E, _Raised) else _T11_E2E["Tariff"]
|
||
print(f" end to end, {_T11_N_CAP} captured prices at 900 s:")
|
||
print(f" {_T11_E2E_ROW}")
|
||
check(f"AC, DRIVEN THROUGH THE SHIPPED REFRESH: the *Tariff* row reports the "
|
||
f"{_T11_N_CAP} points the COMMITTED capture publishes, over "
|
||
f"{_T11_N_CAP // 4} hours, on a quarter-hourly feed. Both numbers come "
|
||
f"from the captured document's own length - not from the plan the code "
|
||
f"under test built out of it (habit 19)",
|
||
_slot(_T11_E2E_ROW, "points") == _T11_N_CAP
|
||
and _slot(_T11_E2E_ROW, "hours") == _T11_N_CAP // 4
|
||
and _slot(_T11_E2E_ROW, "points") != _slot(_T11_E2E_ROW, "hours"))
|
||
check(f"...and the row an operator reads names the SPACING that makes those "
|
||
f"two numbers differ - {_slot(_T11_E2E_ROW, 'spacings')!r} s. The plan "
|
||
f"carried mixed 900/3600 s for 70 % of a 40-hour soak and no surface "
|
||
f"said so",
|
||
_slot(_T11_E2E_ROW, "spacings") == [900])
|
||
check("...and this is the same wording FCST-04's *Price history* row already "
|
||
"used, so the two rows describing the same feed can be compared rather "
|
||
"than being two conventions on one page",
|
||
"prices over" in _T11_E2E_ROW
|
||
and "prices over" in ("" if isinstance(_T11_E2E, _Raised)
|
||
else _T11_E2E["Price history"]))
|
||
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("16. TARIFF-17 - the three buckets, and the only one that moves a "
|
||
"DECISION")
|
||
# ⚠️ WHAT THIS SECTION IS FOR, AND WHY A SPREAD ASSERTION WOULD GRADE THE WRONG
|
||
# THING. The optimiser reads exactly two quantities off a series: the SPREAD
|
||
# (`max - min` over the firm points) and which hour is cheapest
|
||
# (`min(firm, key=...)`, static_tariff.arbitrage()). Every price component
|
||
# reaches them through one of three buckets:
|
||
#
|
||
# LEVEL additive, identical every hour -> reaches NEITHER, provably. An
|
||
# additive constant cancels
|
||
# exactly in `max - min`, and
|
||
# adding it to every point leaves
|
||
# the argmin where it was.
|
||
# SPREAD multiplicative on spot -> scales the spread against a
|
||
# fixed cycle_cost_threshold_eur.
|
||
# ORDER varies by hour, block, day-type -> changes WHICH HOUR IS CHEAPEST.
|
||
#
|
||
# `price_taxes_eur_kwh` is LEVEL. It is therefore NOT a wrong number and never
|
||
# was: it cannot distort either quantity. The defect TARIFF-17 closes is that a
|
||
# component belonging in the ORDER bucket - the Walloon and Brussels DSOs'
|
||
# day/night distribution bands - had nowhere to live.
|
||
#
|
||
# ⚠️ SO THE REGRESSION BELOW GRADES THE ARGMIN, NOT THE MAGNITUDE. A
|
||
# spread-magnitude check passes on an implementation that reads the table ONCE
|
||
# and adds the same number to every point - which is the LEVEL bucket wearing a
|
||
# period table, and is the single likeliest way this feature gets broken. 16d
|
||
# is that mutation's named red.
|
||
|
||
_T17_UTC0 = datetime(2026, 1, 14, 23, 0, tzinfo=UTC) # 00:00 Brussels, Thu
|
||
# A plain Belgian winter weekday: solar-flattened midday, evening peak. Nothing
|
||
# here is chosen to make the table win - 16b proves no SCALAR can reproduce it.
|
||
_T17_SPOT = (0.070, 0.065, 0.062, 0.060, 0.061, 0.068,
|
||
0.082, 0.110, 0.125, 0.100, 0.070, 0.050,
|
||
0.038, 0.030, 0.035, 0.048, 0.072, 0.120,
|
||
0.160, 0.180, 0.150, 0.115, 0.090, 0.078)
|
||
_T17_RAW = [RawPoint(_T17_UTC0 + timedelta(hours=h), _T17_SPOT[h], False)
|
||
for h in range(24)]
|
||
_T17_CAPS = ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY}),
|
||
egress_hosts=("example.invalid",))
|
||
|
||
# ORES's 2026 residential distribution row, EUR/kWh. The tariff card prints
|
||
# `Enkelvoudig | Dag | Nacht | Uitsl. nacht` for every Walloon DSO and
|
||
# `Normaal | Uitsl. nacht` for Fluvius, which is why Flanders is a scalar and
|
||
# Wallonia is not. Exclusive night is a separate physical circuit and is not
|
||
# modelled here.
|
||
_T17_ENKELVOUDIG, _T17_DAG, _T17_NACHT = 0.1198, 0.1327, 0.0739
|
||
_T17_TABLE_JSON = {"periods": [
|
||
{"name": "dag", "from": "07:00", "to": "22:00", "days": "mon-fri",
|
||
"eur_per_kwh": _T17_DAG},
|
||
{"name": "nacht", "from": "22:00", "to": "07:00", "days": "mon-fri",
|
||
"eur_per_kwh": _T17_NACHT},
|
||
{"name": "nacht", "from": "00:00", "to": "24:00", "days": "sat,sun",
|
||
"eur_per_kwh": _T17_NACHT}]}
|
||
_T17_TABLE = derived(lambda: parse_taxes_profile(_T17_TABLE_JSON))
|
||
|
||
|
||
def _t17_band(weekday, hour):
|
||
"""ORES's band, written HERE, by different arithmetic than the module uses.
|
||
|
||
The check that grades `taxes_at()` must not get its expected value from
|
||
`taxes_at()`, or it compares a number against itself - MUTATION.md habit 2.
|
||
"""
|
||
return _T17_DAG if (weekday < 5 and 7 <= hour < 22) else _T17_NACHT
|
||
|
||
|
||
def _t17_cheapest(points):
|
||
p = min(points, key=lambda q: q.eur_per_kwh)
|
||
return brussels(p.start).hour
|
||
|
||
|
||
def _t17_spread(points):
|
||
return round(max(p.eur_per_kwh for p in points)
|
||
- min(p.eur_per_kwh for p in points), 6)
|
||
|
||
|
||
print()
|
||
print("16a. the fixture is not degenerate, and the two arms differ in EXACTLY "
|
||
"ONE way - asserted before anything is concluded from them")
|
||
# ⚠️ MUTATION.md habit 1 and habit 7. Three of the checks in this chain turned
|
||
# out to be graded by something they did not own, and a fourth survived a whole
|
||
# suite because its fixture differed in two ways at once. So: the two arms are
|
||
# ONE `assemble()` call each, over the SAME caps object, the SAME RawPoint list
|
||
# and the SAME markup, and the only argument that differs is `taxes`.
|
||
_T17_FLAT_OUT = derived(lambda: assemble(_T17_CAPS, _T17_RAW, MARKUP,
|
||
_T17_ENKELVOUDIG))
|
||
_T17_TAB_OUT = derived(lambda: assemble(_T17_CAPS, _T17_RAW, MARKUP,
|
||
_T17_TABLE))
|
||
check("the table parses, through static_tariff's OWN grammar",
|
||
not isinstance(_T17_TABLE, _Raised))
|
||
# Graded on this file's own two calls: the arms may differ only in `taxes`.
|
||
_T17_ARMS = {t.id: [ast.unparse(a) for a in n.value.args[0].body.args]
|
||
for n in ast.parse(open(__file__, encoding="utf-8").read()).body
|
||
if isinstance(n, ast.Assign) and isinstance(n.value, ast.Call)
|
||
and isinstance(n.value.func, ast.Name)
|
||
and n.value.func.id == "derived" and n.value.args
|
||
and isinstance(n.value.args[0], ast.Lambda)
|
||
and isinstance(n.value.args[0].body, ast.Call)
|
||
for t in n.targets if isinstance(t, ast.Name)
|
||
and t.id in ("_T17_FLAT_OUT", "_T17_TAB_OUT")}
|
||
print(f" arms: {_T17_ARMS}")
|
||
check(f"the two arms are the same feed: same caps, same {len(_T17_RAW)} "
|
||
f"RawPoints and the same markup {MARKUP} - the two `assemble()` calls "
|
||
f"share their first three arguments by NAME and differ only in the fourth",
|
||
sorted(_T17_ARMS) == ["_T17_FLAT_OUT", "_T17_TAB_OUT"]
|
||
and [len(v) for v in _T17_ARMS.values()] == [4, 4]
|
||
and _T17_ARMS["_T17_FLAT_OUT"][:3] == _T17_ARMS["_T17_TAB_OUT"][:3]
|
||
== ["_T17_CAPS", "_T17_RAW", "MARKUP"]
|
||
and _T17_ARMS["_T17_FLAT_OUT"][3] != _T17_ARMS["_T17_TAB_OUT"][3])
|
||
_T17_BANDS = derived(lambda: sorted({p.eur_per_kwh
|
||
for p in _T17_TABLE.periods}))
|
||
check(f"...and the table is genuinely hour-varying: {_T17_BANDS} EUR/kWh, two "
|
||
f"distinct bands {round(_T17_DAG - _T17_NACHT, 4)} apart. A one-band "
|
||
f"table is a scalar and would grade nothing",
|
||
_T17_BANDS == sorted({_T17_DAG, _T17_NACHT}) and len(_T17_BANDS) == 2)
|
||
_T17_RAW_MIN = derived(lambda: min(range(24), key=lambda h: _T17_SPOT[h]))
|
||
check(f"...and the UNTAXED feed's own cheapest hour is {_T17_RAW_MIN:02d}:00, "
|
||
f"which is inside the DAY band - so the night band has something to win "
|
||
f"against. A feed already cheapest at night would pass under any "
|
||
f"implementation",
|
||
_T17_RAW_MIN == 13 and _t17_band(3, _T17_RAW_MIN) == _T17_DAG)
|
||
check(f"...and the flat scalar this is measured against is ORES's OWN "
|
||
f"enkelvoudig rate {_T17_ENKELVOUDIG}, which sits BETWEEN the two bands "
|
||
f"- so 'the table is just bigger' is not available as an explanation",
|
||
_T17_NACHT < _T17_ENKELVOUDIG < _T17_DAG)
|
||
|
||
|
||
print()
|
||
print("16b. the LEVEL bucket, as a FAMILY: NO scalar can do what the table "
|
||
"does")
|
||
# ⚠️ THIS IS WHAT MAKES 16c's CONCLUSION ATTRIBUTABLE. A single flat/table pair
|
||
# differs in two things at once - the arithmetic level AND the shape - and
|
||
# MUTATION.md's eleven instances are all a fixture that could not separate
|
||
# them. So the flat arm is not one scalar, it is the whole FAMILY of scalars
|
||
# swept across and beyond the table's own range: if every one of them leaves
|
||
# the cheapest hour and the spread exactly where the untaxed feed had them,
|
||
# then whatever 16c measures cannot be a level effect, because level effects
|
||
# have been shown to be inert on this feed.
|
||
_T17_SWEEP = (0.0, _T17_NACHT, _T17_ENKELVOUDIG, _T17_DAG, 1.0,
|
||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||
_T17_LEVEL = derived(lambda: [
|
||
(s, _t17_cheapest(assemble(_T17_CAPS, _T17_RAW, MARKUP, s)),
|
||
_t17_spread(assemble(_T17_CAPS, _T17_RAW, MARKUP, s)))
|
||
for s in _T17_SWEEP])
|
||
for _s, _h, _sp in (_T17_LEVEL if isinstance(_T17_LEVEL, list) else []):
|
||
print(f" flat taxes {_s:<7.4f} -> cheapest hour {_h:02d}, "
|
||
f"spread {_sp:.4f}")
|
||
check(f"every one of the {len(_T17_SWEEP)} scalars leaves the cheapest hour at "
|
||
f"{_T17_RAW_MIN:02d}:00 - the untaxed feed's own - so a LEVEL component "
|
||
f"provably cannot reach the argmin",
|
||
isinstance(_T17_LEVEL, list)
|
||
and {h for _s, h, _sp in _T17_LEVEL} == {_T17_RAW_MIN})
|
||
check("...and leaves the spread IDENTICAL to the last digit, which is the "
|
||
"other half of the same statement: an additive constant cancels in "
|
||
"`max - min`",
|
||
isinstance(_T17_LEVEL, list)
|
||
and len({sp for _s, _h, sp in _T17_LEVEL}) == 1)
|
||
check("...so `price_taxes_eur_kwh` was never a wrong number. It is inert on "
|
||
"both quantities the optimiser reads, and the defect TARIFF-17 closes is "
|
||
"a MISSING CAPABILITY rather than a bad constant",
|
||
isinstance(_T17_LEVEL, list)
|
||
and _T17_LEVEL[0][1:] == _T17_LEVEL[-1][1:])
|
||
|
||
|
||
print()
|
||
print("16c. THE REGRESSION: a Walloon bihoraire profile moves WHICH HOUR IS "
|
||
"CHEAPEST")
|
||
_T17_H_FLAT = derived(lambda: _t17_cheapest(_T17_FLAT_OUT))
|
||
_T17_H_TAB = derived(lambda: _t17_cheapest(_T17_TAB_OUT))
|
||
print(f" flat {_T17_ENKELVOUDIG} : cheapest hour {_T17_H_FLAT}, "
|
||
f"spread {_t17_spread(_T17_FLAT_OUT):.4f}")
|
||
print(f" ORES dag/nacht : cheapest hour {_T17_H_TAB}, "
|
||
f"spread {_t17_spread(_T17_TAB_OUT):.4f}")
|
||
check(f"the SAME feed is cheapest at {_T17_H_FLAT:02d}:00 under a flat scalar "
|
||
f"and at {_T17_H_TAB:02d}:00 under ORES's dag/nacht table. THIS is the "
|
||
f"property: a component in the ORDER bucket changes the hour a battery "
|
||
f"would buy in, and no scalar can",
|
||
_T17_H_FLAT == 13 and _T17_H_TAB == 3 and _T17_H_FLAT != _T17_H_TAB)
|
||
check(f"...and the hour it moves TO is a night-band hour, which is the "
|
||
f"mechanism rather than a coincidence: cheap network hours coincide with "
|
||
f"cheap spot hours, so flattening them understates the spread "
|
||
f"({_t17_spread(_T17_FLAT_OUT):.4f} against {_t17_spread(_T17_TAB_OUT):.4f} "
|
||
f"EUR/kWh) and declines cycles that would have paid",
|
||
_t17_band(3, _T17_H_TAB) == _T17_NACHT
|
||
and _t17_spread(_T17_TAB_OUT) > _t17_spread(_T17_FLAT_OUT))
|
||
# ⚠️ AND THE SPREAD CLAUSE ABOVE IS A REPORTED CONSEQUENCE, NOT THE CHECK. The
|
||
# card is explicit that a spread-magnitude assertion grades the wrong bucket
|
||
# and would pass on a LEVEL-bucket implementation; the argmin above is what
|
||
# goes red for one. Both are printed so a reader can see which is which.
|
||
|
||
|
||
print()
|
||
print("16d. driven through the SHIPPED DECISION, not through assemble()'s "
|
||
"output")
|
||
# ⚠️ habit 4. `min(firm, key=...)` lives in static_tariff.arbitrage(), and that
|
||
# is the expression a strategy reads. A check that stops at the assembled list
|
||
# grades the list; this one grades the verdict an optimiser is handed.
|
||
def _t17_verdict(points):
|
||
read = PriceRead.series(points, PriceProduct.DAY_AHEAD, "t17",
|
||
datetime.now(UTC))
|
||
plan = TariffPlan(read, None, "provider")
|
||
return plan.arbitrage(now=LONG_AGO, peak_guard_active=False,
|
||
min_spread_eur_kwh=PRICE_OPTION_DEFAULTS[
|
||
"cycle_cost_threshold_eur"],
|
||
uncertainty=frozenset())
|
||
|
||
|
||
_T17_V_FLAT = derived(lambda: _t17_verdict(_T17_FLAT_OUT))
|
||
_T17_V_TAB = derived(lambda: _t17_verdict(_T17_TAB_OUT))
|
||
_T17_BUY = derived(lambda: (
|
||
_T17_V_FLAT.match(allowed=lambda a: brussels(a.cheapest.start).hour,
|
||
refused=lambda r: r.reason),
|
||
_T17_V_TAB.match(allowed=lambda a: brussels(a.cheapest.start).hour,
|
||
refused=lambda r: r.reason)))
|
||
print(f" arbitrage().cheapest hour: flat {_T17_BUY[0]!r}, "
|
||
f"table {_T17_BUY[1]!r}")
|
||
check("BOTH arms are ALLOWED at the shipped cycle_cost_threshold_eur, so the "
|
||
"comparison below is between two verdicts and not between a verdict and "
|
||
"a refusal",
|
||
isinstance(_T17_BUY, tuple) and _T17_BUY[0] == 13 and _T17_BUY[1] == 3)
|
||
check("...and the BUY HOUR the optimiser is handed moves 13:00 -> 03:00 on the "
|
||
"same feed. The add-on would charge the battery at midday on a flat tax "
|
||
"basis and at 03:00 on ORES's real one",
|
||
isinstance(_T17_BUY, tuple) and _T17_BUY[0] != _T17_BUY[1])
|
||
|
||
|
||
print()
|
||
print("16e. assemble() evaluates the table at EACH POINT'S OWN INSTANT")
|
||
# ⚠️ THE MUTATION THIS IS AIMED AT is hoisting the lookup out of the loop -
|
||
# reading the table once and adding one number to every point. That leaves the
|
||
# series the right MAGNITUDE and the wrong SHAPE, which is exactly the LEVEL
|
||
# bucket, and 16c goes red for it. This check says the same thing per point and
|
||
# against `_t17_band()` - arithmetic written in this file, not borrowed from
|
||
# the module under test.
|
||
_T17_DELTAS = derived(lambda: [
|
||
(brussels(p.start).hour,
|
||
round(p.eur_per_kwh - _T17_SPOT[brussels(p.start).hour] - MARKUP, 6))
|
||
for p in _T17_TAB_OUT])
|
||
_T17_WANT = derived(lambda: [(h, round(_t17_band(3, h), 6)) for h in range(24)])
|
||
check(f"the tax component added to each of the 24 points is that point's own "
|
||
f"band, hour by hour, graded against this file's arithmetic: "
|
||
f"{_T17_DELTAS[:4]} ... {_T17_DELTAS[-2:]}",
|
||
_T17_DELTAS == _T17_WANT)
|
||
check(f"...and there really are TWO distinct deltas in one series "
|
||
f"{sorted({d for _h, d in _T17_DELTAS})}, so 'evaluated per point' is "
|
||
f"distinguishable from 'evaluated once'. Under a scalar there is exactly "
|
||
f"one, which is the whole difference between the buckets",
|
||
len({d for _h, d in _T17_DELTAS}) == 2
|
||
and len({round(p.eur_per_kwh - _T17_SPOT[brussels(p.start).hour]
|
||
- MARKUP, 6) for p in _T17_FLAT_OUT}) == 1)
|
||
|
||
|
||
print()
|
||
print("16f. the SCALAR path is unchanged - arithmetically, not merely in "
|
||
"intent")
|
||
# ⚠️ AC1, and the reference site is on it. `taxes_at()` on a float returns that
|
||
# float, and `0.0 + markup` is exactly `markup` in IEEE 754 for every finite
|
||
# markup - so the pre-TARIFF-17 expression is reproduced here and compared
|
||
# point for point rather than reasoned about.
|
||
def _t17_old_assemble(caps, raw, markup, taxes):
|
||
"""price.py's assemble() as it stood at c518007. Byte-for-byte arithmetic."""
|
||
add = 0.0
|
||
if Component.MARKUP in caps.adds:
|
||
add += float(markup)
|
||
if Component.TAXES in caps.adds:
|
||
add += float(taxes)
|
||
return [PricePoint(start=brussels(p.start),
|
||
eur_per_kwh=round(p.eur_per_kwh + add, 6),
|
||
provisional=bool(p.provisional))
|
||
for p in raw]
|
||
|
||
|
||
_T17_SCALARS = []
|
||
for _caps in (_T17_CAPS,
|
||
ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY,
|
||
Component.MARKUP}),
|
||
egress_hosts=("example.invalid",)),
|
||
ProviderCapabilities(product=PriceProduct.STATIC, sources=ALL_IN)):
|
||
for _t in (0.0, TAXES, _T17_ENKELVOUDIG, -0.02):
|
||
_T17_SCALARS.append(
|
||
([p.eur_per_kwh for p in assemble(_caps, _T17_RAW, MARKUP, _t)],
|
||
[p.eur_per_kwh
|
||
for p in _t17_old_assemble(_caps, _T17_RAW, MARKUP, _t)]))
|
||
check(f"over {len(_T17_SCALARS)} (capability, scalar) combinations x "
|
||
f"{len(_T17_RAW)} points, every assembled price equals the OLD "
|
||
f"expression's to the last digit - `==`, not `near()`",
|
||
all(a == b for a, b in _T17_SCALARS))
|
||
check("...and the combinations are not all the same answer, or the line above "
|
||
"would be one comparison repeated",
|
||
len({tuple(a) for a, _b in _T17_SCALARS}) > 1)
|
||
|
||
|
||
print()
|
||
print("16g. the new key is FRAMEWORK-OWNED, and a plugin that names it is "
|
||
"still refused")
|
||
check("`price_taxes_profile` is in FRAMEWORK_KEYS",
|
||
"price_taxes_profile" in _price.FRAMEWORK_KEYS)
|
||
check("...and BANNED_IN_PLUGIN_CODE is DERIVED from that set, so it arrived "
|
||
"there without a second list being edited",
|
||
"price_taxes_profile" in BANNED_IN_PLUGIN_CODE
|
||
and set(_price.FRAMEWORK_KEYS) <= set(BANNED_IN_PLUGIN_CODE))
|
||
check("...so PluginOptions refuses to hand it over, naming the framework",
|
||
raises(lambda: PluginOptions(opts()).get("price_taxes_profile"),
|
||
FrameworkOption)
|
||
and "framework" in _reason(
|
||
lambda: PluginOptions(opts()).get("price_taxes_profile")))
|
||
|
||
|
||
class _T17Rogue(PriceProvider):
|
||
"""A plugin that reaches for the taxes TABLE instead of the scalar.
|
||
|
||
The shape of the defect: it would add the site's own per-hour levies a
|
||
SECOND time, per hour, and the resulting price is wrong by one tax band
|
||
with nothing in any log reporting it.
|
||
"""
|
||
|
||
id = "t17_rogue"
|
||
|
||
@classmethod
|
||
def capabilities(cls):
|
||
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY}),
|
||
egress_hosts=("example.invalid",))
|
||
|
||
async def fetch(self, opts, since, until, transport):
|
||
extra = opts.get("price_taxes_profile") # the leak
|
||
return [RawPoint(since, 0.1 + float(bool(extra)), False)]
|
||
|
||
|
||
_T17_LEAK = derived(lambda: code_leaks(_T17Rogue))
|
||
check(f"code_leaks() finds it in a plugin's own code: {_T17_LEAK}",
|
||
_T17_LEAK == ["price_taxes_profile"])
|
||
check("...and register() refuses to install it, so this is an inability and "
|
||
"not a convention",
|
||
raises(lambda: register(_T17Rogue), Exception)
|
||
and "price_taxes_profile" in _reason(lambda: register(_T17Rogue)))
|
||
check("...and the docstring above, which says 'taxes' and 'price' in prose, is "
|
||
"NOT what made it fire - docstrings are stripped before the scan",
|
||
"price_taxes_profile" not in
|
||
"".join(_T17Rogue.__doc__ or "") + (_T17Rogue.capabilities.__doc__ or ""))
|
||
|
||
|
||
print()
|
||
print("16h. it reaches BOTH shipped plugins that take taxes - and NEITHER of "
|
||
"the ones that do not")
|
||
# ⚠️ TARIFF-15's `custom_formula` declares {ENERGY, MARKUP}, so the framework
|
||
# adds TAXES for it; `generic_dayahead` declares {ENERGY} and gets both. The
|
||
# table must move the argmin under each. `manual_csv` publishes an all-in
|
||
# figure and is the NEGATIVE control - the table must be routed through
|
||
# `caps.adds` and not bolted onto every provider.
|
||
_T17_BY_PLUGIN = derived(lambda: {
|
||
pid: (_t17_cheapest(assemble(cls.capabilities(), _T17_RAW, MARKUP,
|
||
_T17_ENKELVOUDIG)),
|
||
_t17_cheapest(assemble(cls.capabilities(), _T17_RAW, MARKUP,
|
||
_T17_TABLE)),
|
||
sorted(c.value for c in cls.capabilities().adds))
|
||
for pid, cls in sorted(PROVIDERS.items())})
|
||
for _pid, _row in (sorted(_T17_BY_PLUGIN.items())
|
||
if isinstance(_T17_BY_PLUGIN, dict) else []):
|
||
print(f" {_pid:<18} adds {str(_row[2]):<22} cheapest hour "
|
||
f"flat {_row[0]:02d} -> table {_row[1]:02d}")
|
||
check("custom_formula declares {ENERGY, MARKUP}, so the framework adds TAXES "
|
||
"for it - and the table moves its cheapest hour 13 -> 03",
|
||
isinstance(_T17_BY_PLUGIN, dict)
|
||
and _T17_BY_PLUGIN["custom_formula"][:2] == (13, 3)
|
||
and _T17_BY_PLUGIN["custom_formula"][2] == ["taxes"])
|
||
check("generic_dayahead declares {ENERGY} and gets markup AND taxes - and the "
|
||
"table moves its cheapest hour 13 -> 03 too",
|
||
isinstance(_T17_BY_PLUGIN, dict)
|
||
and _T17_BY_PLUGIN["generic_dayahead"][:2] == (13, 3)
|
||
and _T17_BY_PLUGIN["generic_dayahead"][2] == ["markup", "taxes"])
|
||
check("manual_csv publishes an all-in figure, adds NOTHING, and is left where "
|
||
"it was - the table is routed through caps.adds, not bolted on",
|
||
isinstance(_T17_BY_PLUGIN, dict)
|
||
and _T17_BY_PLUGIN["manual_csv"][2] == []
|
||
and _T17_BY_PLUGIN["manual_csv"][0] == _T17_BY_PLUGIN["manual_csv"][1]
|
||
== 13)
|
||
check(f"...and that is EVERY registered plugin, enumerated from PROVIDERS "
|
||
f"rather than typed: {sorted(_T17_BY_PLUGIN)}",
|
||
isinstance(_T17_BY_PLUGIN, dict)
|
||
and set(_T17_BY_PLUGIN) == set(PROVIDERS) and len(PROVIDERS) >= 3)
|
||
|
||
|
||
print()
|
||
print("16i. Brussels WALL CLOCK, on the 25-hour day - inherited from the "
|
||
"grammar, not re-derived")
|
||
# ⚠️ THE 23- AND 25-HOUR DAYS ARE WHERE A SECOND IMPLEMENTATION WOULD DIVERGE,
|
||
# which is why there is not a second one: `StaticProfile.at()` renders the
|
||
# instant at the Brussels offset and matches on the LOCAL weekday and minute.
|
||
# 2026-10-25 is the autumn transition: local 02:00 happens twice, both passes
|
||
# are night, and the night->day boundary is still local 07:00.
|
||
_T17_DST0 = datetime(2026, 10, 24, 22, 0, tzinfo=UTC) # 00:00 CEST, Sunday
|
||
_T17_DST_RAW = [RawPoint(_T17_DST0 + timedelta(hours=h), 0.10, False)
|
||
for h in range(25)]
|
||
_T17_DST = derived(lambda: [
|
||
(brussels(p.start).hour, brussels(p.start).utcoffset().seconds // 3600,
|
||
round(p.eur_per_kwh - 0.10 - MARKUP, 6))
|
||
for p in assemble(_T17_CAPS, _T17_DST_RAW, MARKUP, _T17_TABLE)])
|
||
print(f" local hours: {[h for h, _o, _d in _T17_DST]}")
|
||
check("the 25-hour day assembles 25 points and the local hour 02 appears "
|
||
"TWICE, once at +02:00 and once at +01:00",
|
||
isinstance(_T17_DST, list) and len(_T17_DST) == 25
|
||
and [(h, o) for h, o, _d in _T17_DST].count((2, 2)) == 1
|
||
and [(h, o) for h, o, _d in _T17_DST].count((2, 1)) == 1)
|
||
check("...and 2026-10-25 is a SUNDAY, so ORES's weekend row prices all 25 "
|
||
"hours at the night band - a table that matched on the UTC hour, or on a "
|
||
"24-slot array, would have run off the end of the day",
|
||
isinstance(_T17_DST, list)
|
||
and {d for _h, _o, d in _T17_DST} == {round(_T17_NACHT, 6)})
|
||
# The weekday boundary, to show the band edge really is wall clock: the FIRST
|
||
# day-band point sits at local 07:00 in both halves of the year, which is
|
||
# 06:00 UTC in January and 05:00 UTC in July.
|
||
_T17_EDGE = derived(lambda: tuple(
|
||
min(brussels(p.start) for p in assemble(
|
||
_T17_CAPS,
|
||
[RawPoint(base + timedelta(hours=h), 0.10, False) for h in range(24)],
|
||
MARKUP, _T17_TABLE)
|
||
if round(p.eur_per_kwh - 0.10 - MARKUP, 6) == round(_T17_DAG, 6))
|
||
for base in (datetime(2026, 1, 14, 23, 0, tzinfo=UTC), # Thu, CET
|
||
datetime(2026, 7, 15, 22, 0, tzinfo=UTC)))) # Thu, CEST
|
||
check(f"...and the dag band opens at local 07:00 on BOTH a CET and a CEST "
|
||
f"weekday - {[e.isoformat() for e in _T17_EDGE]} - so it is the same UTC "
|
||
f"instant only on one of them",
|
||
isinstance(_T17_EDGE, tuple)
|
||
and [e.hour for e in _T17_EDGE] == [7, 7]
|
||
and len({e.astimezone(UTC).hour for e in _T17_EDGE}) == 2)
|
||
|
||
|
||
print()
|
||
print("16j. a MALFORMED table: refused at startup, and loud rather than "
|
||
"silent if it ever gets past")
|
||
_T17_GAP = {"periods": [{"name": "dag", "from": "07:00", "to": "22:00",
|
||
"days": "all", "eur_per_kwh": _T17_DAG}]}
|
||
_T17_GAP_MSG = _reason(lambda: parse_taxes_profile(_T17_GAP))
|
||
print(f" {_T17_GAP_MSG[:110]}")
|
||
check(f"a table that leaves an hour unpriced is refused by the SAME tiling "
|
||
f"rule static_profile has, and the refusal names the key the operator "
|
||
f"typed",
|
||
_T17_GAP_MSG.startswith("price_taxes_profile leaves ")
|
||
and "no period" in _T17_GAP_MSG)
|
||
check("...and the fetch path does NOT turn it into a traceback into the 30 s "
|
||
"tick, nor silently fall back to the scalar: it is a §11.4 no-data read "
|
||
"naming the key",
|
||
run(load_prices(opts(price_provider="generic_dayahead",
|
||
price_taxes_profile=_T17_GAP),
|
||
_T17_UTC0, _T17_UTC0 + timedelta(hours=24),
|
||
canned(b""))).match(
|
||
series=lambda _r: False,
|
||
no_data=lambda r: r.reason.startswith(
|
||
"price_taxes_profile cannot be used:")))
|
||
check("...and the value that is NOT a table at all - the shipped \"\" - leaves "
|
||
"the scalar path exactly where it was",
|
||
taxes_profile(opts()) is None
|
||
and taxes_profile(opts(price_taxes_profile="")) is None
|
||
and taxes_profile(opts(price_taxes_profile="null")) is None)
|
||
|
||
|
||
print()
|
||
print("16k. END TO END through the SHIPPED load_prices(), on the SHIPPED "
|
||
"plugin - because everything above could be true with the option unread")
|
||
# ⚠️ WHAT THIS CATCHES THAT 16c CANNOT. 16c hands `assemble()` a parsed table
|
||
# directly. `load_prices()` is what has to READ the option, parse it and pass
|
||
# it, and a mutation that drops any of those three leaves every check above
|
||
# green - the framework would be capable of an ORDER-bucket component and no
|
||
# site could configure one. So this drives the real `generic_dayahead`, over
|
||
# the real columnar parser, from an OPTIONS DICT.
|
||
_T17_BODY = columnar_feed([v * 1000.0 for v in _T17_SPOT], start=_T17_UTC0)
|
||
_T17_E2E_TABLE = json.dumps(_T17_TABLE_JSON)
|
||
|
||
|
||
def _t17_load(**over):
|
||
return run(load_prices(
|
||
opts(price_provider="generic_dayahead", **over),
|
||
_T17_UTC0, _T17_UTC0 + timedelta(hours=24),
|
||
canned(_T17_BODY)))
|
||
|
||
|
||
_T17_E2E = derived(lambda: tuple(
|
||
r.match(series=lambda x: (_t17_cheapest(x.points),
|
||
len({round(pt.eur_per_kwh
|
||
- _T17_SPOT[brussels(pt.start).hour]
|
||
- MARKUP, 6) for pt in x.points})),
|
||
no_data=lambda x: x.reason)
|
||
for r in (_t17_load(price_taxes_eur_kwh=_T17_ENKELVOUDIG),
|
||
_t17_load(price_taxes_profile=_T17_E2E_TABLE))))
|
||
print(f" flat scalar option : cheapest hour {_T17_E2E[0][0]}, "
|
||
f"{_T17_E2E[0][1]} distinct tax delta(s)")
|
||
print(f" table option : cheapest hour {_T17_E2E[1][0]}, "
|
||
f"{_T17_E2E[1][1]} distinct tax delta(s)")
|
||
check("the OPTION is read, parsed and applied: the same shipped feed and the "
|
||
"same shipped plugin are cheapest at 13:00 under price_taxes_eur_kwh and "
|
||
"at 03:00 under price_taxes_profile",
|
||
isinstance(_T17_E2E, tuple) and _T17_E2E[0] == (13, 1)
|
||
and _T17_E2E[1] == (3, 2))
|
||
check("...and the scalar option is still the one that answers when the table "
|
||
"is absent, which is every install that exists today",
|
||
isinstance(_T17_E2E, tuple) and _T17_E2E[0][1] == 1)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
print()
|
||
print("17. TARIFF-16 - the INJECTION leg, which a scalar cannot hold")
|
||
#
|
||
# WHAT THIS SECTION IS DEFENDING. `export_price_eur_kwh()` models what a site
|
||
# is PAID for export as ONE number. The market publishes a LINE, and the cards
|
||
# say so in their own words:
|
||
#
|
||
# Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
|
||
# "Het injectietarief is dynamisch en kan per uur wijzigen"
|
||
# - identical on the January and the September 2026 cards
|
||
# ENGIE Dynamic (B x eSpot_15) - A
|
||
# Aspiravi (non-dynamic) 0,07 x Belpex - 2
|
||
#
|
||
# ⚠️ AND WHAT IT IS **NOT** DEFENDING, FIRST, BECAUSE OVERSTATING IT WOULD BE
|
||
# THE DEFECT TARIFF-07 WITHDREW THREE PLUGINS FOR. `arbitrage.py` does not read
|
||
# `export_price_eur_kwh` at all - its own comment says so, with the reason -
|
||
# so no trade this add-on takes is decided by this number and nothing here is a
|
||
# wrong-trade fix. 17h GRADES that claim rather than repeating it.
|
||
#
|
||
# ⚠️ EVERY COEFFICIENT BELOW IS SOMEBODY ELSE'S PUBLISHED NUMBER, transcribed
|
||
# from the cards and from nowhere in app/: section 2b's rule applied to
|
||
# arithmetic, which is 15a's move as well.
|
||
|
||
# Eneco Zon & Wind Dynamisch, the INJECTION line. ⚠️ NO `card` BLOCK, AND THAT
|
||
# IS THE CARD'S OWN SHAPE RATHER THAN AN OMISSION HERE: Eneco prints this
|
||
# formula with NO example price beside it. Inventing one would grade these
|
||
# coefficients against arithmetic done with these coefficients, which is
|
||
# MUTATION.md habit 4 in a fixture.
|
||
ENECO_INJECTION = {"index": "epex_be", "resolution": "hourly",
|
||
"factor": 0.100, "offset": -1.188,
|
||
"units": "c_eur_per_kwh", "vat_pct": 0}
|
||
# Aspiravi, non-dynamic: `0,07 x Belpex - 2`. A SECOND supplier, so that the
|
||
# pass-through bound below is graded against a range somebody published rather
|
||
# than against one number.
|
||
ASPIRAVI_INJECTION = {"index": "epex_be", "resolution": "hourly",
|
||
"factor": 0.07, "offset": -2.0,
|
||
"units": "c_eur_per_kwh", "vat_pct": 0}
|
||
|
||
print()
|
||
print("17a. the four properties a scalar cannot hold - the reproduction, as "
|
||
"checks")
|
||
# ⚠️ PRECONDITION FIRST (habit 1). Every claim below is a DIFFERENCE between
|
||
# the consumption leg and the injection leg, and a difference asserted against
|
||
# two fixtures that happen to be equal is decoration. The consumption side is
|
||
# ENECO_CARD, section 15's own fixture, held BY IDENTITY - not a second copy of
|
||
# Eneco's numbers typed here.
|
||
check(f"the two legs of the SAME supplier's card really are two: consumption "
|
||
f"factor {ENECO_CARD['factor']} against injection "
|
||
f"{ENECO_INJECTION['factor']}, vat_pct {ENECO_CARD['vat_pct']} against "
|
||
f"{ENECO_INJECTION['vat_pct']}, offset {ENECO_CARD['offset']} against "
|
||
f"{ENECO_INJECTION['offset']}",
|
||
ENECO_CARD["factor"] != ENECO_INJECTION["factor"]
|
||
and ENECO_CARD["vat_pct"] != ENECO_INJECTION["vat_pct"]
|
||
and ENECO_CARD["offset"] != ENECO_INJECTION["offset"])
|
||
check("...and the injection factor is NOT the consumption one with a sign "
|
||
"flip, which is the shape somebody would reach for first",
|
||
ENECO_INJECTION["factor"] != -ENECO_CARD["factor"]
|
||
and ASPIRAVI_INJECTION["factor"] != -ENECO_CARD["factor"])
|
||
|
||
_T16_SPEC = derived(lambda: parse_export_formula(json.dumps(ENECO_INJECTION)))
|
||
_T16_ASP = derived(lambda: parse_export_formula(
|
||
json.dumps(ASPIRAVI_INJECTION)))
|
||
check(f"PROPERTY 1 - its OWN factor: the injection leg parses to "
|
||
f"factor {derived(lambda: _T16_SPEC.factor)} while section 15's "
|
||
f"consumption card parses to "
|
||
f"{derived(lambda: parse_price_formula(json.dumps(ENECO_CARD)).factor)}"
|
||
f", from two INDEPENDENT options",
|
||
derived(lambda: _T16_SPEC.factor) == 0.100
|
||
and derived(lambda: parse_price_formula(
|
||
json.dumps(ENECO_CARD)).factor) == 0.102)
|
||
check(f"PROPERTY 2 - a NEGATIVE offset, which the markup option cannot hold "
|
||
f"at all: config.yaml declares `price_markup_eur_kwh: float(0,1)`, and "
|
||
f"the injection leg carries "
|
||
f"{derived(lambda: _T16_SPEC.offset)} {ENECO_INJECTION['units']} = "
|
||
f"{derived(lambda: _T16_SPEC.offset_eur_kwh):+.5f} EUR/kWh",
|
||
derived(lambda: _T16_SPEC.offset) == -1.188
|
||
and derived(lambda: _T16_SPEC.offset_eur_kwh) < 0.0)
|
||
check(f"PROPERTY 3 - vat_pct is PER LEG, so the 6% asymmetry is expressible: "
|
||
f"{derived(lambda: parse_price_formula(json.dumps(ENECO_CARD)).vat_pct)}"
|
||
f" on consumption against {derived(lambda: _T16_SPEC.vat_pct)} on "
|
||
f"injection - \"injectietarieven zijn vrijgesteld van btw\"",
|
||
derived(lambda: parse_price_formula(json.dumps(ENECO_CARD)).vat_pct)
|
||
== 6.0 and derived(lambda: _T16_SPEC.vat_pct) == 0.0)
|
||
# ⚠️ AND THE ASYMMETRY IS GRADED AS AN ARITHMETIC DIFFERENCE, NOT AS TWO FIELD
|
||
# VALUES. Two specs can carry 6 and 0 and still be multiplied by the same
|
||
# number if `in_card_units()` ever read VAT from anywhere but the spec.
|
||
_T16_VAT_PAIR = derived(lambda: (
|
||
in_card_units(parse_price_formula(json.dumps(ENECO_CARD)), 100.0)
|
||
/ (0.102 * 100.0 + 1.00),
|
||
in_card_units(_T16_SPEC, 100.0) / (0.100 * 100.0 - 1.188)))
|
||
print(f" VAT multiplier actually applied: consumption "
|
||
f"{derived(lambda: _T16_VAT_PAIR[0]):.4f}, injection "
|
||
f"{derived(lambda: _T16_VAT_PAIR[1]):.4f}")
|
||
check("...and the multiplier each leg actually APPLIES differs by exactly "
|
||
"6%: 1.06 on consumption, 1.00 on injection, measured through the "
|
||
"shipped arithmetic rather than read off the field",
|
||
derived(lambda: abs(_T16_VAT_PAIR[0] - 1.06) < 1e-9)
|
||
and derived(lambda: abs(_T16_VAT_PAIR[1] - 1.00) < 1e-9))
|
||
|
||
# PROPERTY 4 - it goes negative, and a PV household exports in exactly those
|
||
# hours. The break-even index is the card's own arithmetic: 1,188 / 0,1.
|
||
_T16_BREAKEVEN = 1.188 / 0.100
|
||
_T16_CURVE = derived(lambda: [
|
||
(idx, price_eur_kwh(_T16_SPEC, idx))
|
||
for idx in (-18.2, 0.0, _T16_BREAKEVEN, 61.96, 133.9905)])
|
||
for _i, _p in (derived(lambda: _T16_CURVE) or []):
|
||
print(f" BELPEX-H {_i:+9.4f} EUR/MWh -> injection {_p:+.6f} EUR/kWh")
|
||
check(f"PROPERTY 4 - the published line is NEGATIVE below "
|
||
f"{_T16_BREAKEVEN:.2f} EUR/MWh of BELPEX-H, zero AT it and positive "
|
||
f"above, through the shipped arithmetic - so 'you can be charged to "
|
||
f"export' is this module's answer and not a remark",
|
||
derived(lambda: [round(p, 8) for _i, p in _T16_CURVE])
|
||
== [round(p, 8) for p in (-0.03008, -0.01188, 0.0, 0.05008,
|
||
0.1221105)])
|
||
check("...and the SCALAR cannot hold any of it: one option, one number, the "
|
||
"same in every hour whatever the index did",
|
||
len({export_price_eur_kwh(opts())}) == 1
|
||
and export_price_eur_kwh(opts()) ==
|
||
PRICE_OPTION_DEFAULTS["export_price_eur_kwh"])
|
||
|
||
print()
|
||
print("17b. ONE parser, not two - the identity, pinned on the AST and on the "
|
||
"refusals")
|
||
# ⚠️ THE CLAIM IS AN IDENTITY ("it is THIS parser"), so MUTATION.md habit 10
|
||
# says pin it structurally AND grade its meaning behaviourally. Both are here:
|
||
# 17b1 the grammar family is DEFINED IN EXACTLY ONE MODULE, with the scope
|
||
# COMPUTED by parsing every file under app/ (habit 12 - a hand-written scope is
|
||
# the same defect as a hand-written fixture); 17b2 the two options give the
|
||
# SAME refusal sentences, each DERIVED from the other's rather than typed.
|
||
_T16_FAMILY = (
|
||
"FormulaSpec", "FormulaInvalid", "parse_price_formula",
|
||
"parse_export_formula", "in_card_units", "price_eur_kwh",
|
||
"card_discrepancy", "card_refusal", "_card", "_finite", "_month_date",
|
||
"to_resolution", "UNITS", "RESOLUTIONS", "INDEXES", "CARD_TOLERANCE",
|
||
"_LEGS", "_Leg", "implausible_injection", "implausible_all_in",
|
||
"EUR_KWH_PER_EUR_MWH", "PASSTHROUGH_LO", "PASSTHROUGH_HI",
|
||
"INJECTION_OFFSET_MAX_EUR_KWH", "_CARD_KEYS", "_TOP_KEYS",
|
||
"EXPORT_FORMULA_OPTION", "stale_card", "preview_lines", "CARD_STALE_DAYS",
|
||
"PLAUSIBLE_LO_EUR_KWH", "PLAUSIBLE_HI_EUR_KWH")
|
||
|
||
|
||
def _t16_defs(path):
|
||
"""Top-level names a module DEFINES - classes, functions, assignments.
|
||
|
||
⚠️ IT HANDLES `AnnAssign` AND test_static_tariff.py's 10a DOES NOT, which
|
||
matters here and not there: `UNITS: dict = {...}`, `RESOLUTIONS: tuple` and
|
||
`INDEXES: dict` are ANNOTATED assignments, so a scanner that only walks
|
||
`ast.Assign` would report them as defined in ZERO modules and the
|
||
"exactly one" line below would go red on the shipped tree - the failure
|
||
mode that gets a check deleted rather than read.
|
||
"""
|
||
tree = ast.parse(open(path, encoding="utf-8").read())
|
||
out = set()
|
||
for node in tree.body:
|
||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
|
||
ast.ClassDef)):
|
||
out.add(node.name)
|
||
elif isinstance(node, ast.Assign):
|
||
out |= {t.id for t in node.targets if isinstance(t, ast.Name)}
|
||
elif isinstance(node, ast.AnnAssign) and isinstance(node.target,
|
||
ast.Name):
|
||
out.add(node.target.id)
|
||
return out
|
||
|
||
|
||
_T16_APP = sorted(glob.glob(os.path.join(HERE, "app", "*.py")))
|
||
_T16_WHERE = {name: sorted(os.path.basename(f) for f in _T16_APP
|
||
if name in _t16_defs(f))
|
||
for name in _T16_FAMILY}
|
||
_T16_HOME = ["custom_formula.py"]
|
||
_T16_STRAY = {n: w for n, w in _T16_WHERE.items() if w != _T16_HOME}
|
||
print(f" {len(_T16_FAMILY)} formula-grammar names over {len(_T16_APP)} "
|
||
f"modules under app/; not in custom_formula.py alone: {_T16_STRAY}")
|
||
check(f"every one of the {len(_T16_FAMILY)} formula-grammar names is defined "
|
||
f"in EXACTLY ONE of the {len(_T16_APP)} modules under app/ - a second "
|
||
f"copy of `_finite` or of the unit table takes this red wherever it is "
|
||
f"put, including in a module this check has never heard of",
|
||
all(len(w) == 1 for w in _T16_WHERE.values()))
|
||
check("...and that module is custom_formula.py in every case, so the grammar "
|
||
"did not move house either",
|
||
_T16_STRAY == {})
|
||
check(f"...and the pin is not vacuous: every name really is a top-level "
|
||
f"definition and the scan read more than a handful of modules "
|
||
f"({len(_T16_APP)})",
|
||
all(w for w in _T16_WHERE.values()) and len(_T16_APP) > 5)
|
||
|
||
_T16_BODY = ast.parse(open(os.path.join(HERE, "app", "custom_formula.py"),
|
||
encoding="utf-8").read())
|
||
_T16_WRAP = next((n for n in _T16_BODY.body
|
||
if isinstance(n, ast.FunctionDef)
|
||
and n.name == "parse_export_formula"), None)
|
||
_T16_CALLS = ([ast.unparse(n.func) for n in ast.walk(_T16_WRAP)
|
||
if isinstance(n, ast.Call)] if _T16_WRAP else [])
|
||
check(f"...and `parse_export_formula` is a WRAPPER and not a fork: its whole "
|
||
f"body calls {_T16_CALLS}, pinned on the AST rather than on the source "
|
||
f"text, because `\"parse_price_formula\" in src` is true of a copy that "
|
||
f"merely mentions it",
|
||
_T16_CALLS == ["parse_price_formula"])
|
||
|
||
print()
|
||
print("17b2. and the two options give the SAME refusals - derived from each "
|
||
"other, not typed twice")
|
||
# ⚠️ EVERY EXPECTED SENTENCE HERE IS THE OTHER OPTION'S OWN SENTENCE with the
|
||
# key substituted, which is TARIFF-17's 10b move. Nothing is transcribed, so a
|
||
# fork that reworded one refusal - or quietly stopped refusing something - is
|
||
# red on the row it broke and not on all of them.
|
||
|
||
|
||
def _t16_pair(blob):
|
||
"""The two legs' refusals on one stimulus."""
|
||
return (_reason(lambda: parse_price_formula(json.dumps(blob))),
|
||
_reason(lambda: parse_export_formula(json.dumps(blob))))
|
||
|
||
|
||
# Built FROM section 15's real card, so each row differs from an ACCEPTED
|
||
# baseline in exactly one field (habit 7). ⚠️ The baseline itself is checked
|
||
# first, on BOTH legs, or every refusal below has nowhere else to come from.
|
||
_T16_OK_BOTH = _t16_pair(ENECO_CARD)
|
||
check(f"the CONTROL: section 15's real Eneco card is accepted by BOTH options "
|
||
f"{_T16_OK_BOTH}. ⚠️ It is used here as a PARSER STIMULUS and is not a "
|
||
f"claim that a consumption card is an injection contract - what it "
|
||
f"establishes is that the rows below are refusals of the FIELD each "
|
||
f"names",
|
||
_T16_OK_BOTH == ("", ""))
|
||
_T16_SHARED = [
|
||
("a factor that is not a number", spoilt(factor="0.102 * spot")),
|
||
("a NaN factor", spoilt(factor=float("nan"))),
|
||
("a boolean factor", spoilt(factor=True)),
|
||
("an unknown index", spoilt(index="belpex_h")),
|
||
("an unknown resolution", spoilt(resolution="half_hourly")),
|
||
("missing units", spoilt(units="")),
|
||
("an unknown unit", spoilt(units="cent_per_kwh")),
|
||
("a vat_pct of 106", spoilt(vat_pct=106)),
|
||
("an unknown top-level field", dict(ENECO_CARD, expr="0.1*x")),
|
||
("an unknown card field", dict(ENECO_CARD,
|
||
card=dict(ENECO_CARD["card"], vat=6))),
|
||
("a card month that is not YYYY-MM", spoilt(card_month="September")),
|
||
("a card that is not an object", dict(ENECO_CARD, card=3)),
|
||
]
|
||
_T16_ROWS = []
|
||
for _what, _blob in _T16_SHARED:
|
||
_a, _b = _t16_pair(_blob)
|
||
_T16_ROWS.append((_what, _a, _b,
|
||
_a.replace("price_formula", EXPORT_FORMULA_OPTION)
|
||
== _b))
|
||
for _what, _a, _b, _same in _T16_ROWS:
|
||
print(f" {'same' if _same else 'DIFF'} {_what}: {_a[:56]!r}")
|
||
check(f"all {len(_T16_ROWS)} SHARED refusals are the consumption leg's OWN "
|
||
f"sentence with the key substituted - derived, not transcribed, so a "
|
||
f"second parser diverging on any one of them is red on that row alone",
|
||
all(_same for _w, _a, _b, _same in _T16_ROWS))
|
||
check("...and none of those rows is vacuous: every one really did refuse on "
|
||
"both legs, or 'the two empty strings match' would pass the line above",
|
||
all(_a and _b for _w, _a, _b, _s in _T16_ROWS))
|
||
# ⚠️ AND THE ROWS THAT DELIBERATELY DIFFER, NAMED WITH THEIR SHAPES RATHER THAN
|
||
# ROUNDED OFF (habit 13). Three sentences are per-LEG on purpose - `_LEGS` is
|
||
# exactly the set of things that are not shared - and a check that asserted
|
||
# "every refusal is identical" would have to be weakened the first time one of
|
||
# them was right to differ. So they are asserted to DIFFER, and to differ in
|
||
# the direction that points the operator at the right option.
|
||
_T16_DIFFER = [
|
||
("factor 0 -> where a FLAT price of THIS leg lives",
|
||
spoilt(factor=0.0), "static_profile", "export_price_eur_kwh"),
|
||
# UX-08 2c: a negative factor gets one sentence on both legs now ("put the
|
||
# minus sign on the offset"), so it left this table.
|
||
("a card miss -> what else to check on THIS leg",
|
||
spoilt(factor=0.120), "network charges are not in the offset",
|
||
"the VAT is 0 for injection"),
|
||
("invalid JSON -> WHEN this option is read at all, which is not the "
|
||
"same answer on the two legs: `price_formula` decides nothing unless "
|
||
"price_provider is 'custom_formula', and the injection leg is read "
|
||
"whatever the consumption provider is",
|
||
"{not json", "unless “Dynamic price source” is 'custom_formula'",
|
||
"only if your injection price follows the market"),
|
||
]
|
||
for _what, _blob, _cons_mark, _inj_mark in _T16_DIFFER:
|
||
_a, _b = ((_reason(lambda b=_blob: parse_price_formula(b)),
|
||
_reason(lambda b=_blob: parse_export_formula(b)))
|
||
if isinstance(_blob, str) else _t16_pair(_blob))
|
||
check(f"{_what}: the consumption refusal says {_cons_mark!r} and the "
|
||
f"injection one says {_inj_mark!r} - both refuse, and each names "
|
||
f"the option the operator should actually reach for",
|
||
_cons_mark in _a and _cons_mark not in _b
|
||
and _inj_mark.lower() in _b.lower()
|
||
and _inj_mark.lower() not in _a.lower())
|
||
|
||
print()
|
||
print("17c. a NEGATIVE injection price is ACCEPTED - and TARIFF-15's band is "
|
||
"MEASURED refusing it, rather than asserted to")
|
||
# ⚠️ THE ACCEPTANCE CRITERION, AND THE COUNTERFACTUAL THAT MAKES IT A CLAIM.
|
||
# `implausible_all_in()` floors the CONSUMPTION leg at +0.02 EUR/kWh of energy,
|
||
# which is right there and fatal here: Eneco's injection price is below zero
|
||
# for any BELPEX-H under 11,88 EUR/MWh, and a PV household exports in exactly
|
||
# those hours. So the same spec is put through BOTH bands and the two answers
|
||
# are printed side by side. Refusing a negative injection price would hide the
|
||
# hours in which exporting COSTS money.
|
||
_T16_NEG_CARD = derived(lambda: _cf.FormulaSpec(
|
||
index="epex_be", resolution="hourly", factor=0.100, offset=-1.188,
|
||
units="c_eur_per_kwh", vat_pct=0, card_month="2026-09",
|
||
card_index_eur_per_mwh=-18.2, card_expect_incl_vat=-3.008,
|
||
option=EXPORT_FORMULA_OPTION))
|
||
_T16_NEG_PRICE = derived(lambda: price_eur_kwh(_T16_NEG_CARD, -18.2))
|
||
_T16_OLD_BAND = derived(lambda: implausible_all_in(_T16_NEG_CARD, 0.11736))
|
||
_T16_NEW_BAND = derived(lambda: implausible_injection(_T16_NEG_CARD))
|
||
print(f" the same spec, at BELPEX-H -18.2 EUR/MWh -> "
|
||
f"{derived(lambda: _T16_NEG_PRICE):+.5f} EUR/kWh")
|
||
print(f" implausible_all_in() -> "
|
||
f"{'REFUSES' if _T16_OLD_BAND else 'accepts'}")
|
||
print(f" implausible_injection() -> "
|
||
f"{'REFUSES' if _T16_NEW_BAND else 'accepts'}")
|
||
check(f"the stimulus really is negative ({derived(lambda: _T16_NEG_PRICE)}), "
|
||
f"or the two lines below would be grading a positive number and would "
|
||
f"pass whatever either band did",
|
||
derived(lambda: _T16_NEG_PRICE) < 0.0)
|
||
check(f"TARIFF-15's consumption band REFUSES it, for its floor's reason - "
|
||
f"measured, so 'the scalar band would have ruled the market out' is an "
|
||
f"observation ({str(_T16_OLD_BAND)[:70]!r})",
|
||
isinstance(_T16_OLD_BAND, str)
|
||
and "outside the plausible band" in _T16_OLD_BAND
|
||
and f"{PLAUSIBLE_LO_EUR_KWH}" in _T16_OLD_BAND)
|
||
check("...and the INJECTION band accepts it, which is the acceptance "
|
||
"criterion: a negative injection price is the real market and is not "
|
||
"refused",
|
||
_T16_NEW_BAND is None)
|
||
check("...and so does the shipped PARSER, end to end, on the real published "
|
||
"line with no card block at all - `0,1 x BELPEX-H - 1,188` starts",
|
||
derived(lambda: _T16_SPEC.factor) == 0.100
|
||
and _reason(lambda: parse_export_formula(
|
||
json.dumps(ENECO_INJECTION))) == "")
|
||
check("...and Aspiravi's `0,07 x Belpex - 2` starts too, so the band admits a "
|
||
"RANGE of published contracts rather than one tuned pair",
|
||
_reason(lambda: parse_export_formula(
|
||
json.dumps(ASPIRAVI_INJECTION))) == "")
|
||
|
||
print()
|
||
print("17d. ...and an IMPLAUSIBLE one is still refused, by two rungs that are "
|
||
"graded on their REASONS and mutated TOGETHER")
|
||
# ⚠️ TEST-16's RULE. Two rungs can answer one stimulus - a whole card read 100x
|
||
# too large trips both - so each is given a stimulus the OTHER cannot reach,
|
||
# and each stimulus differs from the ACCEPTED baseline in EXACTLY ONE FIELD
|
||
# (habits 1 and 7). The precondition is its own check: the quantity the other
|
||
# rung grades is asserted to be INSIDE its band, so a row cannot pass because
|
||
# the wrong rung caught it.
|
||
|
||
|
||
def _t16_inj(**over):
|
||
"""Eneco's published injection line with one field replaced."""
|
||
b = dict(ENECO_INJECTION)
|
||
b.update(over)
|
||
return b
|
||
|
||
|
||
def _t16_refusal(blob):
|
||
return _reason(lambda: parse_export_formula(json.dumps(blob)))
|
||
|
||
|
||
check("the CONTROL: the published line is accepted, so every refusal below "
|
||
"has nowhere else to come from",
|
||
_t16_refusal(ENECO_INJECTION) == "")
|
||
_T16_ONLY_PT = _t16_inj(factor=ENECO_INJECTION["factor"] * 100.0)
|
||
_T16_ONLY_OFF = _t16_inj(offset=ENECO_INJECTION["offset"] * 100.0)
|
||
_T16_BOTH = _t16_inj(factor=ENECO_INJECTION["factor"] * 100.0,
|
||
offset=ENECO_INJECTION["offset"] * 100.0)
|
||
check("PRECONDITION: each stimulus differs from the accepted baseline in "
|
||
"EXACTLY ONE field - the pass-through one moves `factor` and nothing "
|
||
"else, the offset one moves `offset` and nothing else",
|
||
{k for k in _T16_ONLY_PT if _T16_ONLY_PT[k] != ENECO_INJECTION[k]}
|
||
== {"factor"}
|
||
and {k for k in _T16_ONLY_OFF if _T16_ONLY_OFF[k] != ENECO_INJECTION[k]}
|
||
== {"offset"})
|
||
_T16_PT_OFFSET = ENECO_INJECTION["offset"] * UNITS[ENECO_INJECTION["units"]]
|
||
_T16_OFF_PT = (ENECO_INJECTION["factor"] * UNITS[ENECO_INJECTION["units"]]
|
||
/ EUR_KWH_PER_EUR_MWH)
|
||
print(f" pass-through stimulus: offset stays {_T16_PT_OFFSET:+.5f} EUR/kWh "
|
||
f"(bound {INJECTION_OFFSET_MAX_EUR_KWH}) - the offset rung CANNOT fire")
|
||
print(f" offset stimulus : pass-through stays {_T16_OFF_PT:.4g}x "
|
||
f"(band {PASSTHROUGH_LO}-{PASSTHROUGH_HI}) - the pass-through rung "
|
||
f"CANNOT fire")
|
||
check("PRECONDITION: on each stimulus the OTHER rung's quantity is INSIDE its "
|
||
"band, so neither row can pass because the wrong rung answered - which "
|
||
"is the masking TEST-16 exists for",
|
||
abs(_T16_PT_OFFSET) <= INJECTION_OFFSET_MAX_EUR_KWH
|
||
and PASSTHROUGH_LO <= _T16_OFF_PT <= PASSTHROUGH_HI)
|
||
_T16_PT_WHY = _t16_refusal(_T16_ONLY_PT)
|
||
_T16_OFF_WHY = _t16_refusal(_T16_ONLY_OFF)
|
||
_T16_BOTH_WHY = _t16_refusal(_T16_BOTH)
|
||
check(f"a factor 100x out is refused BY THE PASS-THROUGH RUNG, naming the "
|
||
f"share of the wholesale price it would pay ({_T16_PT_WHY[:60]!r})",
|
||
"passes through" in _T16_PT_WHY
|
||
and f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x" in _T16_PT_WHY
|
||
and "larger than" not in _T16_PT_WHY)
|
||
check(f"an offset 100x out is refused BY THE OFFSET RUNG, naming the EUR/kWh "
|
||
f"it works out at ({_T16_OFF_WHY[:60]!r})",
|
||
"larger than" in _T16_OFF_WHY
|
||
and "passes through" not in _T16_OFF_WHY)
|
||
check(f"...and the WHOLE card 100x out - which trips both - is refused, with "
|
||
f"the pass-through rung answering first ({_T16_BOTH_WHY[:44]!r})",
|
||
"passes through" in _T16_BOTH_WHY)
|
||
check("...and neither refusal mentions the SIGN or the LEVEL of a price, "
|
||
"which is why they can bound a leg that legitimately goes below zero",
|
||
all("below zero" not in w.split("⚠️")[0]
|
||
for w in (_T16_PT_WHY, _T16_OFF_WHY)))
|
||
# ⚠️ THE BOUNDS ARE GRADED FROM BOTH SIDES, or "within the band" and "within
|
||
# anything" would be the same check. The published contracts sit at 1.00x and
|
||
# 0.70x; the band is 0.10-10.
|
||
_T16_EDGES = derived(lambda: [
|
||
(f"{_pt}x", implausible_injection(_cf.FormulaSpec(
|
||
index="epex_be", resolution="hourly",
|
||
factor=_pt * EUR_KWH_PER_EUR_MWH / UNITS["c_eur_per_kwh"],
|
||
offset=-1.188, units="c_eur_per_kwh", vat_pct=0,
|
||
option=EXPORT_FORMULA_OPTION)) is None)
|
||
for _pt in (0.05, PASSTHROUGH_LO, 0.70, 1.00, PASSTHROUGH_HI, 20.0)])
|
||
print(f" pass-through accepted? {_T16_EDGES}")
|
||
check(f"the pass-through band is the number being enforced, not a shape: "
|
||
f"{PASSTHROUGH_LO}x and {PASSTHROUGH_HI}x pass and 0.05x / 20x do not, "
|
||
f"with both published contracts (0.70x, 1.00x) inside",
|
||
derived(lambda: [ok for _n, ok in _T16_EDGES])
|
||
== [False, True, True, True, True, False])
|
||
|
||
print()
|
||
print("17e. the card self-check applies WHERE THERE IS A CARD - and where "
|
||
"there is not, that is STATED")
|
||
# ⚠️ THE HONEST HALF OF THIS CARD. TARIFF-15 could demonstrate two tariffs end
|
||
# to end rather than three and refused to fabricate a third; the same fact
|
||
# lands here as an OPTIONAL `card` block. Eneco prints its injection formula
|
||
# with NO price beside it, so on that card there is nothing to reproduce -
|
||
# requiring a block would make the published line inexpressible, and inventing
|
||
# the estimate would grade the coefficients against arithmetic done with the
|
||
# same coefficients.
|
||
check("the consumption leg still REFUSES a formula with no card block, "
|
||
"unchanged, and says why the card is required rather than that a field "
|
||
"is missing",
|
||
"no \"card\" block" in _reason(lambda: parse_price_formula(json.dumps(
|
||
{k: v for k, v in ENECO_CARD.items() if k != "card"}))))
|
||
check("...and the INJECTION leg accepts one, because that is the shape of the "
|
||
"card it is read off",
|
||
_t16_refusal(ENECO_INJECTION) == ""
|
||
and derived(lambda: _T16_SPEC.card_graded) is False)
|
||
check("...and says so in a field callers can ask, rather than reporting a "
|
||
"discrepancy of zero - which is what 'the coefficients reproduce the "
|
||
"card' looks like, for the one state in which nothing was compared",
|
||
raises(lambda: card_discrepancy(_T16_SPEC), FormulaInvalid)
|
||
and "no card block" in _reason(lambda: card_discrepancy(_T16_SPEC)))
|
||
# ...and where a card IS supplied on this leg, the SAME rung grades it with the
|
||
# SAME tolerance. Graded against section 15's real printed 15.55 - the stimulus
|
||
# is the consumption card put through the injection option, which is what makes
|
||
# the expectation somebody else's published number rather than this file's.
|
||
_T16_WITH_CARD = _reason(lambda: parse_export_formula(json.dumps(ENECO_CARD)))
|
||
_T16_SPOILT_CARD = _reason(lambda: parse_export_formula(json.dumps(
|
||
spoilt(card_expect_incl_vat=15.05))))
|
||
check(f"a card block ON THIS LEG is graded by the same rung at the same "
|
||
f"tolerance: the real card passes and a transposed digit (15.05 for "
|
||
f"15.55) is refused, naming the option the operator typed "
|
||
f"({_T16_SPOILT_CARD[:64]!r})",
|
||
_T16_WITH_CARD == ""
|
||
and "the card says" in _T16_SPOILT_CARD
|
||
and _T16_SPOILT_CARD.startswith(EXPORT_FORMULA_OPTION))
|
||
check(f"...and half a tolerance out still passes, so {CARD_TOLERANCE} is "
|
||
f"enforced on this leg too rather than 'something'",
|
||
_reason(lambda: parse_export_formula(json.dumps(spoilt(
|
||
card_expect_incl_vat=round(in_card_units(parse_price_formula(
|
||
json.dumps(ENECO_CARD)), 133.9905) + CARD_TOLERANCE * 0.5,
|
||
6))))) == "")
|
||
# ⚠️ WHAT THE UNGRADED STATE COSTS, SAID WITH ITS SIZE (habit 13). Without a
|
||
# printed estimate the two coefficient bounds are the WHOLE of what is
|
||
# mechanised, and they are two orders of magnitude coarser than the card check.
|
||
_T16_SNEAK = derived(lambda: parse_export_formula(json.dumps(
|
||
_t16_inj(factor=0.070))))
|
||
check(f"and the gap is real and is not glossed: with no card, a factor of "
|
||
f"0.070 instead of 0.100 - Aspiravi's number on Eneco's contract, 30% "
|
||
f"of every export payment - passes both bounds, because 0.70x is a "
|
||
f"published pass-through. Nothing here can catch a wrong-contract pair; "
|
||
f"only a printed estimate can, and this card has none",
|
||
derived(lambda: _T16_SNEAK.factor) == 0.070)
|
||
|
||
print()
|
||
print("17f. the SCALAR path is unchanged - arithmetically, not merely in "
|
||
"intent")
|
||
# ⚠️ THE ACCEPTANCE CRITERION TARIFF-17 GRADED THE SAME WAY (16f). A site with
|
||
# a flat injection price is every site that exists today, and "we did not mean
|
||
# to change it" is not a measurement.
|
||
_T16_OLD_SCALAR = [ast.unparse(n) for n in ast.walk(first(
|
||
(n for n in ast.parse(open(os.path.join(HERE, "app", "price.py"),
|
||
encoding="utf-8").read()).body
|
||
if isinstance(n, ast.FunctionDef) and n.name == "export_price_eur_kwh"),
|
||
"def export_price_eur_kwh in price.py"))
|
||
if isinstance(n, ast.Return)]
|
||
check(f"`export_price_eur_kwh()`'s BODY is still the single expression it has "
|
||
f"always been, pinned on the AST rather than on the source text: "
|
||
f"{_T16_OLD_SCALAR}",
|
||
_T16_OLD_SCALAR ==
|
||
["return float(price_option(opts, 'export_price_eur_kwh'))"])
|
||
_T16_SITES = [opts(), opts(export_price_eur_kwh=0.0),
|
||
opts(export_price_eur_kwh=-0.02),
|
||
opts(export_price_eur_kwh=0.045),
|
||
opts(export_price_eur_kwh=""),
|
||
{}, {"export_price_eur_kwh": 0.07}]
|
||
_T16_IDX = (-18.2, 0.0, 61.96, 133.9905, 400.0)
|
||
_T16_SAME = derived(lambda: [
|
||
(export_price_eur_kwh(o),
|
||
float(price_option(o, "export_price_eur_kwh")),
|
||
{export_price_at(o, i) for i in _T16_IDX})
|
||
for o in _T16_SITES])
|
||
check(f"over {len(_T16_SITES)} option dicts, the reader equals the "
|
||
f"pre-TARIFF-16 expression to the last digit - `==`, not `near()`",
|
||
derived(lambda: all(a == b for a, b, _c in _T16_SAME)))
|
||
check(f"...and with the formula option unset - which is every install today - "
|
||
f"`export_price_at()` returns that SAME number at all "
|
||
f"{len(_T16_IDX)} index values, including a negative one: a flat "
|
||
f"injection price does not move with the market, which is what flat "
|
||
f"means",
|
||
derived(lambda: all(c == {a} for a, _b, c in _T16_SAME)))
|
||
check("...and the dicts are not all the same answer, or the two lines above "
|
||
"would be one comparison repeated",
|
||
derived(lambda: len({a for a, _b, _c in _T16_SAME})) > 2)
|
||
# ...and the formula, when set, is what answers - or everything above would be
|
||
# true of a tree that parses the option and never reads it (TARIFF-17's M4).
|
||
_T16_LIVE = derived(lambda: [
|
||
export_price_at(opts(**{EXPORT_FORMULA_OPTION:
|
||
json.dumps(ENECO_INJECTION)}), i)
|
||
for i in _T16_IDX])
|
||
print(f" with the formula set: {[round(v, 6) for v in _T16_LIVE]}")
|
||
check(f"with `{EXPORT_FORMULA_OPTION}` SET, the seam answers from the formula "
|
||
f"- five distinct prices where the scalar has one, and the negative "
|
||
f"index gives a negative price rather than a floored zero",
|
||
derived(lambda: len(set(_T16_LIVE))) == len(_T16_IDX)
|
||
and derived(lambda: _T16_LIVE[0]) < 0.0
|
||
and derived(lambda: round(_T16_LIVE[0], 9)) == -0.03008)
|
||
|
||
print()
|
||
print("17g. the new key is FRAMEWORK-OWNED, and a plugin that names it is "
|
||
"still refused")
|
||
check(f"`{EXPORT_FORMULA_OPTION}` is in FRAMEWORK_KEYS, beside the scalar it "
|
||
f"varies",
|
||
EXPORT_FORMULA_OPTION in _price.FRAMEWORK_KEYS
|
||
and "export_price_eur_kwh" in _price.FRAMEWORK_KEYS)
|
||
check("...and BANNED_IN_PLUGIN_CODE is DERIVED from that set, so it arrived "
|
||
"there without a second list being edited",
|
||
EXPORT_FORMULA_OPTION in BANNED_IN_PLUGIN_CODE
|
||
and set(_price.FRAMEWORK_KEYS) <= set(BANNED_IN_PLUGIN_CODE))
|
||
check("...so PluginOptions refuses to hand it over, naming the framework",
|
||
raises(lambda: PluginOptions(opts()).get(EXPORT_FORMULA_OPTION),
|
||
FrameworkOption)
|
||
and "framework" in _reason(
|
||
lambda: PluginOptions(opts()).get(EXPORT_FORMULA_OPTION)))
|
||
|
||
|
||
class _T16Rogue(PriceProvider):
|
||
"""A plugin that reaches for the INJECTION curve.
|
||
|
||
The shape of the defect: a CONSUMPTION plugin that could read what the site
|
||
is paid for export could fold it into the series it emits, and `assemble()`
|
||
has no way to see a component that arrived inside a RawPoint.
|
||
"""
|
||
|
||
id = "t16_rogue"
|
||
|
||
@classmethod
|
||
def capabilities(cls):
|
||
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
|
||
sources=frozenset({Component.ENERGY}),
|
||
egress_hosts=("example.invalid",))
|
||
|
||
async def fetch(self, opts, since, until, transport):
|
||
leak = opts.get("price_export_formula") # the leak
|
||
return [RawPoint(since, 0.1 + float(bool(leak)), False)]
|
||
|
||
|
||
_T16_LEAK = derived(lambda: code_leaks(_T16Rogue))
|
||
check(f"code_leaks() finds it in a plugin's own code: {_T16_LEAK}",
|
||
_T16_LEAK == [EXPORT_FORMULA_OPTION])
|
||
check("...and register() refuses to install it, so this is an inability and "
|
||
"not a convention",
|
||
raises(lambda: register(_T16Rogue), FrameworkOption)
|
||
and EXPORT_FORMULA_OPTION in _reason(lambda: register(_T16Rogue)))
|
||
check("...and it is the LEAK rung that refused, not the duplicate-id or "
|
||
"retired-id one two lines above it in register() - those raise "
|
||
"ValueError, and `raises()` grades the exact class",
|
||
not raises(lambda: register(_T16Rogue), ValueError))
|
||
check("...and the docstring above, which says 'injection' and 'export' in "
|
||
"prose, is NOT what made it fire - docstrings are stripped before the "
|
||
"scan",
|
||
EXPORT_FORMULA_OPTION not in
|
||
"".join(_T16Rogue.__doc__ or "")
|
||
+ (_T16Rogue.capabilities.__doc__ or ""))
|
||
check("...and the plugin id really was not installed by the attempt",
|
||
"t16_rogue" not in PROVIDERS)
|
||
|
||
print()
|
||
print("17h. NOTHING IN THIS ADD-ON TRADES ON IT - graded, not attested")
|
||
# ⚠️ THIS IS THE CHECK BEHIND A SENTENCE, AND THE SENTENCE IS THE ONE MOST
|
||
# LIKELY TO ROT. DOCS.md and this module both say no strategy reads an
|
||
# injection price; the day somebody wires one in, that becomes false in prose
|
||
# nobody re-reads. So the SCOPE IS COMPUTED (habit 12): every module under app/
|
||
# is parsed and asked which of the three injection readers it calls.
|
||
_T16_READERS = ("export_price_eur_kwh", "export_price_at", "export_formula")
|
||
|
||
|
||
def _t16_calls(path):
|
||
"""The names called anywhere in a module, by simple or attribute name."""
|
||
out = set()
|
||
for node in ast.walk(ast.parse(open(path, encoding="utf-8").read())):
|
||
if isinstance(node, ast.Call):
|
||
f = node.func
|
||
out.add(f.id if isinstance(f, ast.Name)
|
||
else f.attr if isinstance(f, ast.Attribute)
|
||
else ast.unparse(f))
|
||
return out
|
||
|
||
|
||
_T16_CALLERS = {os.path.basename(f): sorted(set(_T16_READERS)
|
||
& _t16_calls(f))
|
||
for f in _T16_APP}
|
||
_T16_CALLERS = {m: c for m, c in _T16_CALLERS.items() if c}
|
||
print(f" modules under app/ that call an injection reader: {_T16_CALLERS}")
|
||
check(f"`arbitrage.py` calls NONE of {list(_T16_READERS)} - it prices the "
|
||
f"sell side as displaced IMPORT and compares two all-in CONSUMPTION "
|
||
f"prices, "
|
||
f"which is its own accounting-basis comment and is what makes this card "
|
||
f"a missing capability rather than a wrong trade",
|
||
"arbitrage.py" not in _T16_CALLERS)
|
||
check("...and no other module under app/ calls one either: the only caller is "
|
||
"price.py, where `export_price_at()` calls the other two. Wire one into "
|
||
"a strategy and this row goes red, which is the point - the DOCS "
|
||
"sentence is graded rather than attested",
|
||
set(_T16_CALLERS) == {"price.py"})
|
||
check("...and the scan is not vacuous: it found the readers where they DO "
|
||
"live, so 'nothing calls them' is a result and not an empty search",
|
||
derived(lambda: _T16_CALLERS["price.py"]) == sorted(_T16_READERS[:2])
|
||
or derived(lambda: set(_T16_CALLERS["price.py"])) <= set(_T16_READERS)
|
||
and len(derived(lambda: _T16_CALLERS["price.py"])) >= 2)
|
||
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# TARIFF-16's MUTATION LEDGER. Every row was RUN, not reasoned about, and
|
||
# the method is MUTATION.md's.
|
||
#
|
||
# ⚠️ NO ROW HERE IS AN EDIT-AND-REVERT (habit 18). The harness
|
||
# (`/home/homelab/.cache/t16-mut/mut.sh`) rsyncs a FRESH COPY of the tree per
|
||
# mutation into a `mktemp` directory, excluding `__pycache__`, mutates the
|
||
# copy, mounts it `:ro` into the checks image and deletes it. There is no
|
||
# revert to fail and no shared backup path; a `:ro` tree with no cache in it
|
||
# cannot read a stale `.pyc`, which is TEST-15 closed the way CI closes it.
|
||
# Each row's byte delta and the clean/mutant tree fingerprints were printed by
|
||
# the harness before the suites ran; the clean fingerprint is
|
||
# `71160af73582cde8` on every row.
|
||
#
|
||
# ⚠️ AND THAT DESIGN WAS EXERCISED RATHER THAN ADMIRED. Two copies of this
|
||
# sweep were running at one point - a scheduling mistake, exactly habit 18's
|
||
# second bullet - and because each holds its own `mktemp` tree neither could
|
||
# restore over the other's work. The orphan was found by `docker inspect`
|
||
# showing two different `run.XXXXXX` mounts and killed; no result below comes
|
||
# from a tree two mutations deep.
|
||
#
|
||
# ⚠️ SCOPE, STATED (habit 13). Rows marked FULL were run over all 29 files.
|
||
# The rest were run over the five files that can reach this card -
|
||
# test_price.py, test_validate.py, test_price_forecast.py,
|
||
# test_static_tariff.py, test_defaults_pin.py - so those rows say which suites
|
||
# went red and do NOT claim anything about the other 24.
|
||
#
|
||
# # what was changed delta red on
|
||
# --- ------------------------------------------------- ----- ---------------
|
||
# M1 the PASS-THROUGH rung of +10 B 17d (3),
|
||
# implausible_injection() made unreachable. test_validate
|
||
# (1)
|
||
# M2 the OFFSET rung made unreachable. +10 B 17d (1),
|
||
# test_validate
|
||
# (1)
|
||
# M3 ⚠️ TEST-16's rule: BOTH rungs that can answer +20 B 17d (4),
|
||
# the hundredfold stimulus, TOGETHER. Neither test_validate
|
||
# masks the other - each still has a stimulus the (2)
|
||
# other cannot reach, so the pair moves six rows.
|
||
# M4 parse_price_formula() stops applying the +10 B 17d (3),
|
||
# injection band at all. test_validate
|
||
# (2)
|
||
# M5 ⚠️ THE HEADLINE, AND THE ACCEPTANCE CRITERION. +174 B 17c, on the
|
||
# implausible_injection() given TARIFF-15's ONE check that
|
||
# floor - a plausibility band that refuses a names it.
|
||
# legitimately NEGATIVE injection price. FULL 29-FILE
|
||
# Eneco's real published line stops starting. SWEEP: red on
|
||
# test_price.py,
|
||
# GREEN in the
|
||
# other 28.
|
||
# M6 `card_required=True` on the injection leg, so -1 B 17a (1),
|
||
# Eneco's line - printed with no price beside it - test_validate
|
||
# becomes inexpressible. ⚠️ ONE BYTE. (7)
|
||
# M7 card_discrepancy() returns 0.0 on an UNGRADED +33 B 17e (1)
|
||
# leg instead of refusing to answer - i.e. reports
|
||
# "the coefficients reproduce the card" for the
|
||
# one state in which nothing was compared.
|
||
# M8 the card self-check skipped where a card IS +10 B 17e (1) AND
|
||
# present, on EITHER leg. TARIFF-15's
|
||
# own 15b - 11
|
||
# checks in
|
||
# test_price,
|
||
# which is what
|
||
# "one rung, two
|
||
# legs" means
|
||
# M9 `price_export_formula` removed from -27 B 17g (7 checks)
|
||
# FRAMEWORK_KEYS.
|
||
# M10 a SECOND formula grammar: `UNITS` and +117 B 17b (2). FULL
|
||
# `_finite` copied into price.py. 29-FILE SWEEP:
|
||
# red on
|
||
# test_price.py,
|
||
# GREEN in the
|
||
# other 28.
|
||
# M11 parse_export_formula() drops `option=`, so every -30 B 17a (1), 17b2,
|
||
# refusal on the injection leg names test_validate
|
||
# `price_formula`. (9)
|
||
# M12 export_price_at() parses the formula and never +8 B 17f (1). FULL
|
||
# uses it - the option is configurable and decides 29-FILE SWEEP:
|
||
# nothing (TARIFF-17's M4, one option over). red on
|
||
# test_price.py,
|
||
# GREEN in the
|
||
# other 28.
|
||
# M13 a negative injection price silently FLOORED at +10 B 17f (1)
|
||
# zero by export_price_at().
|
||
# M14 validate.py never reads the option. +7 B test_validate
|
||
# (7 checks)
|
||
# M15 the UNGRADED warning suppressed: the leg starts +10 B test_validate
|
||
# and nothing says which check did not run. (1)
|
||
# M16 ⚠️ ZERO BYTES, AND NO DECISION CHANGES. The two +0 B test_validate,
|
||
# legs TRANSPOSED in the VAT-asymmetry banner. the ordered-
|
||
# Nothing the add-on DOES moves; the only surface pair check
|
||
# that moves is the sentence, which now tells the ONLY (hab. 14)
|
||
# operator their VAT treatment exactly backwards.
|
||
# M17 the injection leg's `flat` clause pointed at -6 B 17b2 DIFF row
|
||
# `static_profile`, so a site is told to fix the for factor 0
|
||
# consumption option when its injection formula
|
||
# is wrong.
|
||
# M18 the scalar reader given an IN-CODE LITERAL +4 B 17f's AST pin,
|
||
# fallback (`opts.get("export_price_eur_kwh", section 11,
|
||
# 0.04)`) instead of routing through AND
|
||
# PRICE_OPTION_DEFAULTS - CFG-03's fourth place. test_defaults_
|
||
# pin (1)
|
||
# M19 the injection leg's JSON-error hint pointed at +75 B 17b2 DIFF row
|
||
# the CONSUMPTION leg's rule ("leave it empty for invalid
|
||
# unless price_provider is 'custom_formula'"), JSON
|
||
# which is false of the leg you are PAID on.
|
||
# M20 ⚠️ RECORDED FOR ITS SHAPE, NOT AS A WIN. -23 B test_price.py
|
||
# export_price_eur_kwh() rerouted through dies, and
|
||
# export_price_at(). Because the seam falls BACK names NOTHING
|
||
# to the scalar the two recurse, so test_price.py
|
||
# dies with a RecursionError and ZERO named
|
||
# checks. That is habit 5 in the small: a failure
|
||
# with no name attached reads like broken
|
||
# infrastructure. M18 is the same property
|
||
# mutated in a way a check can NAME.
|
||
#
|
||
# ⚠️ WHAT NO ROW ABOVE COULD SEE, with its shape (habit 13):
|
||
# * 17b's pin grades WHERE the grammar is defined, never WHAT it means -
|
||
# rewriting `implausible_injection` in place to accept anything passes it
|
||
# (17c and 17d grade the meaning), and it covers `app/` only: a second
|
||
# parser under `tools/` or in a check file is outside it;
|
||
# * 17h grades that no module under `app/` CALLS an injection reader. A
|
||
# strategy that reached the option through `price_option(opts, ...)`
|
||
# directly, or through a name this scan resolves differently, is outside
|
||
# it - the scan is keyed by called NAME and therefore over-approximates,
|
||
# which makes "nothing calls them" the strong answer and a hit the weak
|
||
# one;
|
||
# * nothing here grades the DOCS.md injection section, which is prose. Every
|
||
# number it quotes is one 17a, 17c, 17d or 17e prints on every run, so a
|
||
# reader can check them, but no assertion holds them;
|
||
# * and the deepest gap is the one 17e's last check MEASURES rather than
|
||
# mentions: with no printed estimate there is no check anywhere that can
|
||
# tell a right pair of injection coefficients from a wrong one. A `factor`
|
||
# of 0.070 where the contract says 0.100 - 30% of every export payment -
|
||
# passes both bounds, because 0.70x is a pass-through somebody publishes.
|
||
# --------------------------------------------------------------------------- #
|
||
# TARIFF-17's MUTATION LEDGER. Every row was RUN, not reasoned about, and the
|
||
# method is MUTATION.md's. ⚠️ NO ROW HERE IS AN EDIT-AND-REVERT: the harness
|
||
# (`/home/homelab/.cache/t17-mut/mut.sh`) rsyncs a FRESH COPY of the tree per
|
||
# mutation, excluding `__pycache__`, mutates the copy, mounts it `:ro` into the
|
||
# checks image and deletes it. There is no revert to fail and no shared backup
|
||
# path, which is habit 18 closed structurally rather than by discipline; and a
|
||
# `:ro` tree with no cache in it cannot read a stale `.pyc`, which is TEST-15
|
||
# closed the way CI closes it. Each row's byte delta and the mutant/clean
|
||
# sha256 pair were printed by the harness before the suites ran.
|
||
#
|
||
# # what was changed delta red on
|
||
# --- ------------------------------------------------- ----- ---------------
|
||
# M1 assemble() hoists the table lookup out of the +17 B 16c 16d 16e 16k
|
||
# loop (`taxes_at(taxes, brussels(raw[0].start))`) - THE headline
|
||
# -> the LEVEL bucket wearing a period table. mutation. FULL
|
||
# 29-FILE SWEEP:
|
||
# red on
|
||
# test_price.py,
|
||
# GREEN in the
|
||
# other 28.
|
||
# M2 taxes_at() returns `periods[0]`'s value instead +17 B 16c 16d 16e 16k
|
||
# of the point's own band.
|
||
# M3 "price_taxes_profile" removed from -27 B 16g (5 checks)
|
||
# FRAMEWORK_KEYS.
|
||
# M4 load_prices() parses the table and never applies +10 B 16k ONLY - the
|
||
# it (`if table is not None and False`). reason 16k is
|
||
# a separate
|
||
# check: every
|
||
# other TARIFF-17
|
||
# check stays
|
||
# green while no
|
||
# site can
|
||
# configure one.
|
||
# M5 validate.py never reads the option. -32 B test_validate
|
||
# (6 checks)
|
||
# M6 the two-writers refusal (table + non-default -1 B test_validate
|
||
# scalar) is made unreachable. (2 checks)
|
||
# M7 the cents/units rung's threshold 1.0 -> 1000.0. +3 B test_validate,
|
||
# the SIBELGA
|
||
# line ONLY - the
|
||
# ORES line goes
|
||
# on passing for
|
||
# the PARSER's
|
||
# reason, which
|
||
# is what grading
|
||
# the reason
|
||
# buys.
|
||
# M8 ⚠️ TEST-16's rule: BOTH rungs that can answer +3 B test_validate
|
||
# the cents stimulus, TOGETHER - the units rung (+ 3) (3 checks),
|
||
# AND price.py's MAX_PLAUSIBLE_EUR_KWH. test_price,
|
||
# Neither masks the other. test_static
|
||
# M9 a SECOND period grammar: `_check_tiling` and +86 B test_static 10a
|
||
# `MINUTES_PER_DAY` copied into price.py. (2 checks)
|
||
# M10 parse_taxes_profile() drops `option=`, so every -61 B test_static 10b,
|
||
# refusal names `static_profile`. test_price 16j.
|
||
# ⚠️ GREEN in
|
||
# test_validate -
|
||
# that file's
|
||
# own prefix
|
||
# supplies the
|
||
# key, so it
|
||
# cannot see
|
||
# this. Named
|
||
# rather than
|
||
# rounded off.
|
||
# M11 StaticProfile.at() matches on the UTC clock +15 B 16e, 16i and
|
||
# instead of Brussels wall time. test_static
|
||
# (5 checks)
|
||
# M12 _price() ignores its `means` argument. -26 B test_static 10b
|
||
# M13 assemble() adds the markup unconditionally, -42 B 16f and
|
||
# ignoring caps.adds. section 3
|
||
# (6 checks)
|
||
#
|
||
# ⚠️ WHAT NO ROW ABOVE COULD SEE, with its shape (habit 13):
|
||
# * passing `p.start` (UTC) to `taxes_at()` instead of `local` is an
|
||
# EQUIVALENT mutant, not a survivor: `StaticProfile.at()` calls
|
||
# `brussels()` on whatever it is given, so the two are the same instant by
|
||
# construction and no fixture can separate them;
|
||
# * 10a grades WHERE the grammar is defined, never WHAT it means - rewriting
|
||
# `_check_tiling` in place to accept a gap passes it (section 5 and 10b are
|
||
# what grade the meaning), and it covers `app/` only;
|
||
# * nothing here grades the DOCS.md bucket table, which is prose. The numbers
|
||
# it quotes are the ones 16b and 16c print on every run, so a reader can
|
||
# check them, but no assertion holds them.
|
||
|
||
|
||
print()
|
||
if fails:
|
||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||
sys.exit(1)
|
||
print("all checks passed")
|