TARIFF-17: a price component that varies by hour has somewhere to live
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:
glenn schrooyen
2026-09-13 15:31:15 +02:00
co-authored by Claude Opus 5
parent c518007caf
commit 2fac0aaf06
12 changed files with 1485 additions and 54 deletions
+121 -12
View File
@@ -851,6 +851,18 @@ PRICE_OPTION_DEFAULTS = {
# test_price.py section 1. A second defaults dict in the new module would be
# a fourth place option defaults live, which is exactly the open ticket.
"static_profile": "",
# TARIFF-17. A JSON object in `static_profile`'s OWN period grammar whose
# per-period `eur_per_kwh` is the TAXES COMPONENT for that band, not an
# all-in price; "" is unset and the scalar above decides. Parsed and
# validated by app/static_tariff.py (`parse_taxes_profile`), refused at
# startup by validate.py - the `static_profile` precedent exactly, and here
# for the same CFG-03 reason: this dict is the ONE in-code mirror of
# config.yaml.
#
# ⚠️ IT IS IN FRAMEWORK_KEYS. A plugin that could read this could add the
# taxes a second time, per hour, which is the double-counting defect trap 1
# exists to refuse - wearing a period table instead of a scalar.
"price_taxes_profile": "",
# TARIFF-15. A JSON object carrying ONE contract's two coefficients, the
# units they are printed in, its VAT rate and the card figures the add-on
# grades them against; "" is unset. Parsed and validated by
@@ -884,6 +896,12 @@ PRICE_OPTION_DEFAULTS = {
# that can double-count, so `PluginOptions` refuses them by name. Trap 1.
FRAMEWORK_KEYS = frozenset({
"price_markup_eur_kwh", "price_taxes_eur_kwh", "export_price_eur_kwh",
# TARIFF-17. The hour-varying spelling of `price_taxes_eur_kwh`, and it
# belongs here for the same reason the scalar does: it is a component the
# FRAMEWORK adds exactly once, by `caps.adds`. `BANNED_IN_PLUGIN_CODE` is
# derived from this set, so `code_leaks()` refuses a plugin that names it
# without anything else being edited.
"price_taxes_profile",
})
@@ -1019,6 +1037,27 @@ def export_price_eur_kwh(opts: dict) -> float:
return float(price_option(opts, "export_price_eur_kwh"))
def taxes_profile(opts: dict):
"""The configured `price_taxes_profile` as a period table, or None.
TARIFF-17. Raises `static_tariff.ProfileInvalid` on a malformed table - and
⚠️ THAT EXCEPTION IS NOT MEANT TO REACH A RUNNING SITE. validate.py parses
the same option at STARTUP and refuses to start on it, for the reason
`static_profile` is refused there: a table that only fails when a fetch
happens is a site that stops pricing at 03:00, and §11.4 turns that into a
quiet fallback nothing on the status page distinguishes from a healthy
fixed-tariff day. `load_prices()` still handles it, loudly, because a guard
whose only proof is "the other guard ran first" is not a guard.
⚠️ THE IMPORT IS LAZY AND THE CYCLE IS THE REASON. `static_tariff.py`
imports this module at module scope; importing it back at module scope here
would make which file is imported first decide whether the add-on starts.
`sys.modules` makes the repeat cost a dict lookup.
"""
from .static_tariff import parse_taxes_profile
return parse_taxes_profile(price_option(opts, "price_taxes_profile"))
# --------------------------------------------------------------------------- #
# The plugin interface
# --------------------------------------------------------------------------- #
@@ -1286,23 +1325,68 @@ def _validate_raw(points, caps):
seen = u
def taxes_at(taxes, instant: datetime) -> float:
"""The TAXES component at ONE instant. A scalar, or a period table.
TARIFF-17. THE DEFECT THIS EXISTS TO CLOSE, and it is a MISSING CAPABILITY
rather than a wrong constant: `price_taxes_eur_kwh` is additive and
identical every hour, so it cancels exactly out of `max - min` and cannot
move `min(firm, key=...)`. It is not a distortion - it is provably inert on
both quantities the optimiser reads. What was missing is anywhere for an
HOUR-VARYING non-energy component to live, and the Walloon and Brussels
DSOs bill one: ORES `dag 13,27 / nacht 7,39` c EUR/kWh, a 5,88 c gap whose
cheap band coincides with the cheap spot hours. Flattening it does not
shift the curve, it UNDERSTATES the spread and declines profitable cycles.
See DOCS.md "The three buckets".
⚠️ DUCK-TYPED ON `price_at`, NOT ON `isinstance`. price.py must not import
static_tariff.py - static_tariff.py imports THIS module, and a module-level
cycle would make the import order load-bearing. `taxes_profile()` below is
the one place the real parser is reached, and it imports it lazily.
⚠️ THE LOOKUP IS ON THE ~30 s PLANNING PATH through `assemble()`, so it
stays a linear scan over the two-to-four periods a Belgian tariff has
(`StaticProfile.at()`), with no parsing, no allocation and no I/O. A
malformed table never reaches here: validate.py refuses it at STARTUP.
"""
at = getattr(taxes, "price_at", None)
if at is None:
return float(taxes)
return float(at(instant))
def assemble(caps: ProviderCapabilities, raw, markup: float,
taxes: float) -> list:
taxes) -> list:
"""RawPoints -> canonical all-in PricePoints. THE ONLY PLACE MARKUP IS ADDED.
Adds exactly `caps.adds` - the complement of what the plugin declared its
source already contains. A plugin that publishes an all-in figure gets
nothing added; a raw EPEX figure gets both.
⚠️ `taxes` IS A SCALAR *OR* A PERIOD TABLE (TARIFF-17), AND IT IS EVALUATED
AT EACH POINT'S OWN INSTANT. Hoisting the lookup out of the loop - reading
it once and adding the same number to every point - is the mutation that
collapses the whole feature back into the LEVEL bucket: the assembled
series would still differ from the untaxed one by the right MAGNITUDE, and
the cheapest hour would be wrong. `test_price.py` section 24 is the check
that goes red for it, and it grades WHICH HOUR IS CHEAPEST rather than the
spread, because a spread-magnitude assertion passes on the hoisted version.
⚠️ THE SCALAR PATH IS UNCHANGED, ARITHMETICALLY AND NOT ONLY IN INTENT.
`taxes_at()` on a float returns that float at every instant, and
`0.0 + markup` is exactly `markup` in IEEE 754 for every finite markup, so
a site on a scalar gets the identical bytes it got before this landed.
"""
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]
add_markup = float(markup) if Component.MARKUP in caps.adds else 0.0
wants_taxes = Component.TAXES in caps.adds
out = []
for p in raw:
local = brussels(p.start)
add = add_markup + (taxes_at(taxes, local) if wants_taxes else 0.0)
out.append(PricePoint(start=local,
eur_per_kwh=round(p.eur_per_kwh + add, 6),
provisional=bool(p.provisional)))
return out
# --------------------------------------------------------------------------- #
@@ -1902,13 +1986,38 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
caps = cls.capabilities()
markup = float(price_option(opts, "price_markup_eur_kwh"))
taxes = float(price_option(opts, "price_taxes_eur_kwh"))
# ⚠️ TARIFF-17: THE TABLE WINS OVER THE SCALAR, AND validate.py REFUSES THE
# CASE WHERE BOTH SAY SOMETHING. Two writers of one quantity, one of which
# silently loses, is the shape validate.py exists to refuse - so the
# precedence here is only ever exercised on a configuration that has
# already been accepted, and it is spelled out rather than left implicit.
taxes_key = "price_taxes_eur_kwh"
try:
table = taxes_profile(opts)
except Exception as e: # noqa: BLE001
# Not silent, and not a traceback into the 30 s tick. validate.py
# should have refused this at startup; if the add-on is running with it
# anyway, the operator is told which key and what is wrong with it.
_LOG.error("price_taxes_profile cannot be used (%s: %s), so this fetch "
"has NO tax component to add - it is not falling back to "
"price_taxes_eur_kwh, because a silently different tax "
"basis is the defect this option exists to remove. Fix the "
"table or clear it; validate.py refuses it at startup",
type(e).__name__, e)
return PriceRead.no_data(
f"price_taxes_profile cannot be used: {e}")
if table is not None:
taxes = table
taxes_key = "price_taxes_profile"
else:
taxes = float(price_option(opts, "price_taxes_eur_kwh"))
# ⚠️ Not a refusal, but not silent either. A plugin whose feed is already
# all-in ignores these two numbers, and an operator who typed them has every
# reason to believe they took effect.
ignored = [n for c, n, v in ((Component.MARKUP, "price_markup_eur_kwh", markup),
(Component.TAXES, "price_taxes_eur_kwh", taxes))
(Component.TAXES, taxes_key, taxes))
if c not in caps.adds and v]
if ignored:
_LOG.warning("%s publishes a figure that already contains %s, so the "