TARIFF-17: a price component that varies by hour has somewhere to live
checks / unit (pull_request) Successful in 7m25s
checks / unit (pull_request) Successful in 7m25s
`price.py` modelled the whole non-energy part of a Belgian bill as ONE flat scalar. This adds `price_taxes_profile`, which carries the TAXES component as a period table, and makes `assemble()` evaluate it at each point's own instant. ⚠️ THE FLAT SCALAR WAS NOT A WRONG NUMBER, AND THIS IS NOT A CORRECTION OF ONE. The optimiser reads exactly two quantities off a series - the spread `max - min` over the firm points, and which hour is cheapest - and an additive constant identical in every hour cancels out of the first and leaves the second where it was. It is provably inert. Measured, and printed by test_price.py 16b on every run: with a flat taxes figure of 0.00, 0.0739, 0.1198, 0.1327 or 1.00 EUR/kWh the same 24-hour feed is cheapest at 13:00 in all five cases and its spread is 0.1500 EUR/kWh in all five cases. What was missing is anywhere for an HOUR-VARYING non-energy component to live. This is a missing capability, not a bad constant. Belgium has one. Fluvius distribution is a single rate - its tariff-card row reads `Normaal | Uitsl. nacht`, where exclusive night is a separate physical circuit and not a night register - so nothing changes for the reference site in Flanders. The Walloon DSOs and Sibelga are on the same card with four columns, `Enkelvoudig | Dag | Nacht | Uitsl. nacht`, and the gap is real money (c/kWh): ORES 13,27 / 7,39; AIESH 15,17 / 8,21; Tecteo RESA 12,20 / 7,01; Sibelga 9,39 / 7,10; ORES IMPACT 2026 16,578 / 5,090. ⚠️ AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO. The cheap network hours are the cheap spot hours, so flattening them does not shift the curve - it UNDERSTATES the spread and declines cycles that would have paid. Same feed, same markup, the only difference being the taxes argument: flat 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 -> cheapest hour 13, spread 0.1500 ORES dag 0.1327 / nacht 0.0739 -> cheapest hour 03, spread 0.1788 The regression check grades WHICH HOUR IS CHEAPEST, not the spread: an implementation that reads the table once and adds one number to every point is the LEVEL bucket wearing a period table - right magnitude, wrong shape - and a spread assertion passes on it. Measured: hoisting the lookup out of `assemble()`'s loop (+17 B) is red on six named checks in test_price.py and GREEN in the other 28 files. ONE GRAMMAR, NOT TWO. `static_tariff.py` already shipped the periods, the day selectors, the tiling rule and the DST handling, so `parse_taxes_profile()` is the same parser with the option name and the "what this number means" clause as arguments. A second tiling rule would be a second set of 23- and 25-hour-day bugs. test_static_tariff.py section 10 pins that two ways: by parsing every module under `app/` and asserting the grammar is defined in exactly one of them, and by deriving each of thirteen refusal sentences for the new option from the old option's own sentence rather than transcribing them. `Component.TAXES` semantics, `code_leaks()` and the double-counting guarantee are untouched. The new key joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived from that set, and a plugin that names it is refused at `register()`. It reaches both plugins that take taxes - `custom_formula` ({ENERGY, MARKUP}) and `generic_dayahead` ({ENERGY}) - and not `manual_csv`, which is all-in: the table is routed through `capabilities().adds`, exactly as the scalar is. ⚠️ A MALFORMED TABLE IS REFUSED AT STARTUP, NOT AT THE NEXT FETCH. It is read on the ~30 s planning path, and a table that first fails at 03:00 is a site that stops pricing at 03:00, which §11.4 turns into a quiet fallback indistinguishable from a healthy fixed-tariff day. validate.py refuses three things: a table that does not tile the day; a table set beside a NON-DEFAULT `price_taxes_eur_kwh`, because the table wins and the scalar would then be a number the operator typed that decides nothing; and a whole card typed in CENTS, which is how these figures are printed. That last rung is not a restatement of the parser's +-10 EUR/kWh bound - ORES's 13,27 trips the parser, but Sibelga's entire card, 9,39 and 7,10, is under it and would otherwise have started as a 9.39 EUR/kWh levy. Both rungs are graded by the REASON each gives, and mutating them together (TEST-16) moves three rows. NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "", and the scalar path is unchanged arithmetically rather than merely in intent: test_price.py 16f reproduces the pre-TARIFF-17 expression and compares 12 capability/scalar combinations point for point with `==`. 13 mutations, every one red on a named check, ledger in test_price.py below section 16k with byte deltas and the one equivalent mutant named. 29/29 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
co-authored by
Claude Opus 5
parent
c518007caf
commit
2fac0aaf06
@@ -59,6 +59,7 @@ is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
@@ -71,8 +72,9 @@ from app.price import (
|
||||
)
|
||||
from app.price_history import NoHistory
|
||||
from app.static_tariff import (
|
||||
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
|
||||
parse_static_profile, resolve_tariff, static_plan, static_status,
|
||||
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TAXES_PROFILE_OPTION,
|
||||
TariffPlan, parse_static_profile, parse_taxes_profile, resolve_tariff,
|
||||
static_plan, static_status,
|
||||
)
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
@@ -87,6 +89,16 @@ def check(name, cond):
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def _reason_of(fn):
|
||||
"""The message fn() raised, or "" - so a check can assert WHICH rule
|
||||
refused rather than only that something did (MUTATION.md habit 6)."""
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return str(e)
|
||||
return ""
|
||||
|
||||
|
||||
def raises(fn, exc):
|
||||
"""True only if fn() raises EXACTLY `exc`. A different exception is False -
|
||||
'it was refused' and 'it broke on the way to being refused' must not grade
|
||||
@@ -1130,6 +1142,189 @@ check("...and the table really does exercise both rungs and the allowing path, "
|
||||
{want for _n, _p, want in _TABLE9} == {"flat", "firm-pair", "allowed"})
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("10. TARIFF-17 - ONE period grammar, not two. A second tiling rule is a "
|
||||
"second set of DST bugs")
|
||||
# ⚠️ THE ACCEPTANCE CRITERION THIS GRADES, and why it needs two checks rather
|
||||
# than one. `price_taxes_profile` carries the TAXES component as a period table
|
||||
# so that a Walloon day/night distribution band can reach the optimiser. The
|
||||
# obvious way to build it is a second parser next to this one - and the two
|
||||
# would agree on every ordinary day and diverge on the 23- and 25-hour days,
|
||||
# where nobody looks and where §11.4 makes the divergence quiet.
|
||||
#
|
||||
# So the claim is an IDENTITY ("it is this grammar"), and MUTATION.md habit 10
|
||||
# says pin an identity structurally and a meaning behaviourally. Both are here:
|
||||
# 10a the grammar family is DEFINED IN EXACTLY ONE MODULE, computed by
|
||||
# parsing every file under app/ rather than by a list somebody typed;
|
||||
# 10b the two options produce the SAME REFUSAL SENTENCES, derived from each
|
||||
# other, which is what a fork would break first.
|
||||
|
||||
print()
|
||||
print("10a. the grammar is defined in exactly one module under app/, and the "
|
||||
"scope is COMPUTED")
|
||||
# ⚠️ A HAND-WRITTEN SCOPE IS THE SAME DEFECT AS A HAND-WRITTEN FIXTURE
|
||||
# (habit 12): it passes while the copy sits in the file nobody added to the
|
||||
# list. Every `app/*.py` is parsed; the family is taken from THIS module's own
|
||||
# top-level names, so it cannot drift from what the grammar actually is.
|
||||
#
|
||||
# ⚠️ AND THE FAMILY LIST HAS ONE DELIBERATE OMISSION, STATED HERE RATHER THAN
|
||||
# ROUNDED OFF (habit 13). `_days` is NOT in it, because `maintenance.py` has a
|
||||
# `_days` of its own - a NAMESAKE, not a copy - and the check below would go
|
||||
# red on it and be deleted by whoever hit it. The namesake is asserted to BE a
|
||||
# namesake, by signature, immediately afterwards, so the omission is graded
|
||||
# rather than assumed.
|
||||
_T17_FAMILY = ("Period", "StaticProfile", "ProfileInvalid", "_check_tiling",
|
||||
"_raise_gap", "_raise_overlap", "_minutes", "_price",
|
||||
"parse_static_profile", "DAY_NAMES", "MINUTES_PER_DAY",
|
||||
"_DAY_INDEX", "_DAY_ALIASES", "SUPPORTED_TZ")
|
||||
|
||||
|
||||
def _t17_defs(path):
|
||||
"""Top-level names a module DEFINES - classes, functions and assignments."""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
out = set()
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
|
||||
ast.ClassDef)):
|
||||
out.add(node.name)
|
||||
elif isinstance(node, ast.Assign):
|
||||
out |= {t.id for t in node.targets if isinstance(t, ast.Name)}
|
||||
return out
|
||||
|
||||
|
||||
_T17_APP = sorted((HERE / "app").glob("*.py"))
|
||||
_T17_WHERE = {name: sorted(p.name for p in _T17_APP if name in _t17_defs(p))
|
||||
for name in _T17_FAMILY}
|
||||
for _n, _w in sorted(_T17_WHERE.items()):
|
||||
print(f" {_n:<22} defined in {_w}")
|
||||
check(f"every one of the {len(_T17_FAMILY)} grammar names is defined in "
|
||||
f"EXACTLY ONE of the {len(_T17_APP)} modules under app/ - a copy of "
|
||||
f"`_check_tiling` in price.py would take this red",
|
||||
all(len(w) == 1 for w in _T17_WHERE.values()))
|
||||
check("...and that module is static_tariff.py in every case, so the grammar "
|
||||
"did not move house either",
|
||||
{w[0] for w in _T17_WHERE.values() if w} == {"static_tariff.py"})
|
||||
check("...and the pin is not vacuous: each of those names really is a "
|
||||
"top-level definition, so the scan found something rather than nothing",
|
||||
all(w for w in _T17_WHERE.values()) and len(_T17_APP) > 5)
|
||||
|
||||
|
||||
def _t17_sig(module, name):
|
||||
tree = ast.parse((HERE / "app" / module).read_text(encoding="utf-8"))
|
||||
fn = next(n for n in tree.body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
return [a.arg for a in fn.args.args]
|
||||
|
||||
|
||||
_T17_DAYS = (_t17_sig("static_tariff.py", "_days"),
|
||||
_t17_sig("maintenance.py", "_days"))
|
||||
print(f" _days: static_tariff{_T17_DAYS[0]} vs maintenance{_T17_DAYS[1]}")
|
||||
check(f"...and the ONE name held out of the family is held out honestly: "
|
||||
f"`maintenance._days{tuple(_T17_DAYS[1])}` renders an age in words and "
|
||||
f"is not a second copy of the grammar's "
|
||||
f"`_days{tuple(_T17_DAYS[0])}` day selector",
|
||||
_T17_DAYS[0] != _T17_DAYS[1] and _T17_DAYS[0] == ["value", "field"]
|
||||
and _T17_DAYS[1] == ["n"])
|
||||
# ⚠️ WHAT THIS PIN CANNOT SEE, WITH ITS SHAPE (habit 13). It grades WHERE the
|
||||
# grammar is defined, never WHAT it means: `_check_tiling` could be rewritten
|
||||
# in place to accept a gap and this would still pass. Section 5 and 10b are
|
||||
# what grade the meaning. It also covers `app/` only, and a second grammar
|
||||
# living under `tools/` or in a check file is outside it.
|
||||
|
||||
_T17_BODY = ast.parse(
|
||||
(HERE / "app" / "static_tariff.py").read_text(encoding="utf-8"))
|
||||
_T17_WRAP = next(
|
||||
(n for n in _T17_BODY.body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "parse_taxes_profile"),
|
||||
None)
|
||||
_T17_CALLS = ([ast.unparse(n.func) for n in ast.walk(_T17_WRAP)
|
||||
if isinstance(n, ast.Call)] if _T17_WRAP else [])
|
||||
check(f"...and `parse_taxes_profile` is a WRAPPER and not a fork: its whole "
|
||||
f"body calls {_T17_CALLS}, pinned on the AST rather than on the source "
|
||||
f"text, because `\"parse_static_profile\" in src` is true of a copy that "
|
||||
f"merely mentions it",
|
||||
_T17_CALLS == ["parse_static_profile"])
|
||||
|
||||
|
||||
print()
|
||||
print("10b. and the two options give the SAME refusals - derived from each "
|
||||
"other, not typed twice")
|
||||
# ⚠️ EVERY EXPECTED SENTENCE HERE IS THE OTHER OPTION'S OWN SENTENCE with the
|
||||
# key substituted. Nothing is transcribed, so a fork that reworded one refusal
|
||||
# - or that quietly stopped refusing something - is red on the row it broke and
|
||||
# not on all of them.
|
||||
_T17_BAD = [
|
||||
("a gap", {"periods": [{"name": "d", "from": "07:00", "to": "22:00",
|
||||
"days": "all", "eur_per_kwh": 0.13}]}),
|
||||
("an overlap", {"periods": [
|
||||
{"name": "a", "from": "00:00", "to": "24:00", "days": "all",
|
||||
"eur_per_kwh": 0.13},
|
||||
{"name": "b", "from": "07:00", "to": "22:00", "days": "all",
|
||||
"eur_per_kwh": 0.07}]}),
|
||||
("a bad HH:MM", {"periods": [{"name": "d", "from": "7.5", "to": "22:00",
|
||||
"days": "all", "eur_per_kwh": 0.13}]}),
|
||||
("a bad day", {"periods": [{"name": "d", "from": "00:00", "to": "24:00",
|
||||
"days": "funday", "eur_per_kwh": 0.13}]}),
|
||||
("missing days", {"periods": [{"name": "d", "from": "00:00", "to": "24:00",
|
||||
"eur_per_kwh": 0.13}]}),
|
||||
("a zero-length window", {"periods": [
|
||||
{"name": "d", "from": "07:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": 0.13}]}),
|
||||
("no periods list", {"timezone": "europe/brussels"}),
|
||||
("an empty periods list", {"periods": []}),
|
||||
("a foreign timezone", {"timezone": "utc", "periods": []}),
|
||||
("periods that are not a list", {"periods": 3}),
|
||||
("a row that is not an object", {"periods": ["dag"]}),
|
||||
("invalid JSON", "{not json"),
|
||||
("a 24:00 start", {"periods": [{"name": "d", "from": "24:00", "to": "07:00",
|
||||
"days": "all", "eur_per_kwh": 0.13}]}),
|
||||
]
|
||||
_T17_ROWS = []
|
||||
for _what, _bad in _T17_BAD:
|
||||
_a = _reason_of(lambda: parse_static_profile(_bad))
|
||||
_b = _reason_of(lambda: parse_taxes_profile(_bad))
|
||||
_T17_ROWS.append((_what, _a, _b,
|
||||
_a.replace("static_profile", TAXES_PROFILE_OPTION) == _b))
|
||||
for _what, _a, _b, _same in _T17_ROWS:
|
||||
print(f" {'ok ' if _same else 'XX '}{_what:<28} {_b[:74]}")
|
||||
check(f"all {len(_T17_ROWS)} malformations are refused IDENTICALLY under both "
|
||||
f"options, sentence for sentence, once the key name is substituted - "
|
||||
f"which is what one parser means and a fork could not hold",
|
||||
all(row[3] for row in _T17_ROWS))
|
||||
check("...and every one of them really did refuse, or 'the two agree' would be "
|
||||
"two empty strings agreeing",
|
||||
all(row[1] and row[2] for row in _T17_ROWS))
|
||||
check("...and the sentences are not all the same sentence, or the row above "
|
||||
"would be one comparison repeated 13 times",
|
||||
len({row[2] for row in _T17_ROWS}) == len(_T17_ROWS))
|
||||
|
||||
# ⚠️ THE ONE REFUSAL THAT IS DELIBERATELY DIFFERENT, and it is different
|
||||
# because the two options carry different QUANTITIES in the same grammar. A
|
||||
# period without `eur_per_kwh` gets a sentence naming what was wanted, and an
|
||||
# operator who typed an ALL-IN price into the taxes table is out by an energy
|
||||
# price with nothing reporting it.
|
||||
_T17_NOPRICE = {"periods": [{"name": "d", "from": "00:00", "to": "24:00",
|
||||
"days": "all"}]}
|
||||
_T17_M_S = _reason_of(lambda: parse_static_profile(_T17_NOPRICE))
|
||||
_T17_M_T = _reason_of(lambda: parse_taxes_profile(_T17_NOPRICE))
|
||||
print(f" static_profile : {_T17_M_S[-96:]}")
|
||||
print(f" taxes table : {_T17_M_T[-96:]}")
|
||||
check("the 'you left the number out' refusal is the one sentence the two do "
|
||||
"NOT share, and each names its own quantity: an all-in price for "
|
||||
"static_profile, the taxes component alone for the table",
|
||||
"an all-in eur_per_kwh" in _T17_M_S
|
||||
and "an all-in eur_per_kwh" not in _T17_M_T
|
||||
and "TAXES component alone" in _T17_M_T
|
||||
and "NOT an all-in price" in _T17_M_T
|
||||
and _T17_M_S.replace("static_profile", TAXES_PROFILE_OPTION) != _T17_M_T)
|
||||
check("...and both still name the ROW the operator has to go and fix, which is "
|
||||
"the half they do share",
|
||||
"periods[1].eur_per_kwh is missing" in _T17_M_S
|
||||
and "periods[1].eur_per_kwh is missing" in _T17_M_T)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
Reference in New Issue
Block a user