TARIFF-15: your own tariff card, as two coefficients the card itself grades
checks / unit (pull_request) Successful in 7m14s

A Belgian dynamic contract prices energy as `B x index + A`, and the two
coefficients exist on exactly one artefact: the customer's own tariff card.
There is no machine-readable source for them anywhere - CREG's consumer
tariff-card pages 404, CREG Scan is a JS wizard, the annual report has the
supplier list without per-product coefficients, VREG was unreachable, and
every coefficient found in research came off a supplier's own PDF, one
image-only. So nothing can be scraped and a per-supplier plugin would have no
source for its numbers, which is why TARIFF-07 withdrew three of them. The
customer is the only party who can see their own coefficients; they type them.

New provider `custom_formula`, one new option `price_formula` (a JSON string,
the `static_profile` precedent exactly), and a new module
`app/custom_formula.py`. The index path is `generic_dayahead`'s, not a second
one: the URL/override decision is now one shared `dayahead_feed_url()` both
plugins call, same feed, same Brussels-calendar range, same columnar parser,
same declared egress hosts.

It declares {ENERGY, MARKUP}, NOT ALL_IN. A card's Tariefformule line is the
energy component alone - Eneco tabulates network tariffs and levies
separately, ENGIE states the formula excludes network, distribution,
transport, levies and excise - so ALL_IN would lie for every operator who
typed what their card says and would drop the taxes from every hour
(0.2125 -> 0.1555 EUR/kWh, measured).

THE CARD IS THE GRADER, and that is why this is safe to ship. The framework
already mechanises that a PLUGIN cannot double-count. This introduces a risk
none of those guards can see because it is config, not code: the OPERATOR
double-counting, by folding network charges into `offset` while
price_taxes_eur_kwh carries them too. So validate.py recomputes the card's own
printed price from the operator's coefficients at startup and refuses to start
unless the two agree to within 0.01, naming the discrepancy. An inflated
offset cannot pass: 15.55 becomes 28.27. The same check catches VAT omitted,
VAT doubled, a transposed digit and a unit mismatch between the two sides.

And the limit, in the same sentence as the claim: it grades the coefficients
against the card, NOT the card against reality. A consistent-but-wrong pair
passes. A second refusal covers the one unit error the first cannot see - the
one applied to both sides at once - by bounding the card's own point to
0.02-1.00 EUR/kWh. Its FLOOR is applied to the energy figure rather than the
all-in one because the shipped 0.06 of taxes is three times that floor and
would carry a hundredfold error over it alone; found by writing the mutation,
not by review (M10, +0 B).

The market's own arithmetic, reproduced through the shipped code:

  Eneco (0.102 x 133.9905 + 1) x 1.06 = 15.5471   card prints 15,55
  ENGIE (0.1   x  61.96   + 0.9) x 1.06 = 7.5218  card prints 7,522

NO EXPRESSION EVALUATION OF ANY KIND. The string is settable over HTTP through
the add-on's own ingress page, so eval on it is RCE in a root container
holding SUPERVISOR_TOKEN and driving an inverter over RS485 - but the deciding
argument is that an expression is UNVERIFIABLE: two coefficients can be graded
against the card, an arbitrary expression cannot be graded against anything,
so it would make the invisible-wrong-coefficient problem strictly worse. An
unknown field is REFUSED rather than ignored, so `"expr"` is told it does not
exist. Future shapes become named fields, never a grammar. test_price.py 15f
walks the module's AST for eval/exec/compile/__import__/literal_eval and
exercises the scanner's red direction on every run.

Refused at startup: card absent or out by >0.01; `units` absent (no default);
factor == 0 (every spread 0, arbitrage silently never fires - the message
points at static_profile); factor < 0 (inverts the market); vat_pct outside
0-30 (0 stays legitimate); non-finite or non-numeric; unknown field, index or
resolution; the plausibility band. A card older than ~6 months WARNS and does
not refuse - ENGIE freezes coefficients for a contract's duration. Negative
output prices are not refused anywhere.

A startup preview logs the next 24 h as `index EUR/MWh -> all-in EUR/kWh` with
min/median/max and the day's spread, through a new default-silent
`PriceProvider.startup_preview()` hook - only the plugin knows what went in to
its own arithmetic. Emitted once per process, and a preview that raises cannot
cost the site its prices.

`custom_formula` joins the config.yaml enum through the DERIVED set
PROVIDERS | RETIRED_PROVIDERS | {"off"}, not a hardcoded list. Both
translations describe the option and the new id. DOCS.md replaces the
"planned" sentence with the shape, a worked example from a real card, a real
startup transcript and the limit of the check stated beside the claim.

22 mutations, one per check added, no survivors - MUTATION.md carries the
ledger. M13 (the preview's index column) was a -13 B survivor of the whole
suite on the first attempt because the check graded the HEADING and not the
row; it is graded on a rendered row now. All 29 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
Claude Opus 5
2026-09-13 03:49:08 +02:00
parent aade2c79fd
commit ac9854886b
15 changed files with 2316 additions and 103 deletions
+272 -58
View File
@@ -72,9 +72,10 @@ THE THREE THINGS THIS MODULE IS SHAPED BY
reads the enum. A plugin id appears nowhere in this file except in
`PROVIDERS`, in `RETIRED_PROVIDERS`, and in the class that answers to it.
⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME` (TARIFF-07). Both
shipped plugins are day-ahead, so `_WIDE`, `Uncertainty.REALTIME_PRODUCT`
and `WIDEN_FACTOR`'s 2.5 currently have no shipped declarer. They are KEPT
⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME` (TARIFF-07). All
three shipped plugins are day-ahead, so `_WIDE`,
`Uncertainty.REALTIME_PRODUCT` and `WIDEN_FACTOR`'s 2.5 currently have no
shipped declarer. They are KEPT
deliberately - see DECISIONS/TARIFF-07-withdrawing-three-plugins.md - and
test_price.py section 10 keeps them live by declaring REALTIME from a class
defined outside this file, which is AC2's own stimulus.
@@ -149,6 +150,10 @@ from enum import Enum
from textwrap import dedent
from urllib.parse import urlencode
from .custom_formula import (
FormulaInvalid, PREVIEW_HOURS, parse_price_formula, preview_lines,
price_eur_kwh, to_resolution)
_LOG = logging.getLogger("goodwe.price")
@@ -846,6 +851,13 @@ 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-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
# app/custom_formula.py, 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.
"price_formula": "",
# OPT-04. §10.1 priority 3, and §14's `optimizer.cycle_cost_threshold_eur`.
#
# ⚠️ THEY LIVE HERE FOR CFG-03's SAKE, exactly as `static_profile` does, and
@@ -983,11 +995,20 @@ def export_price_eur_kwh(opts: dict) -> float:
the SAME `B x spot - A` shape as the consumption curve but carries ITS OWN
coefficients and its own VAT treatment - injection is VAT-exempt where
consumption is at 6% - so it is a second per-contract formula, not a second
copy of the first. Nothing in this add-on can evaluate either one yet
(`generic_dayahead` cannot express `B x spot + A` at all; the formula
provider is a separate card). Until it can, this scalar is a SIMPLIFICATION
an operator enters by hand - an average of what they are actually paid -
and not a reflection of the market. When the formula provider lands, an
copy of the first.
⚠️ TARIFF-15 LANDED THE EVALUATOR AND DID NOT LAND THIS CURVE, and the
difference is the point rather than an omission. `custom_formula` can now
express `B x index + A` exactly - it is what it exists for - but it is a
CONSUMPTION provider: it declares {ENERGY, MARKUP}, the framework adds the
consumption taxes, and an injection leg carries its OWN coefficients
(Eneco: 0,102 consumption against 0,100 injection) and its own VAT
treatment (injection is exempt; consumption is at 6%). Pointing this
scalar at that machinery would mean pricing an injection curve with a
consumption card, which is the wrong-card defect the card self-check
exists to refuse. So until the injection card lands this scalar remains a
SIMPLIFICATION an operator enters by hand - an average of what they are
actually paid - and not a reflection of the market. When it does land, an
injection curve gets a `PriceProvider` of its own and this scalar becomes
its FIXED-product default; the seam is already the right shape for that.
@@ -1046,6 +1067,28 @@ class PriceProvider(ABC):
inheriting the silence.
"""
def startup_preview(self, points) -> list:
"""Lines to log ONCE, the first time this provider answers (TARIFF-15).
`points` are the ASSEMBLED, all-in `PricePoint`s - what the site is
really priced on - and the default is to say nothing, so no existing
plugin changes behaviour.
⚠️ IT IS A HOOK RATHER THAN A LINE IN `load_prices()` BECAUSE THE
FRAMEWORK CANNOT KNOW WHAT IS WORTH PREVIEWING. Only the plugin knows
what went IN to its own arithmetic - for `custom_formula` the index in
EUR/MWh - and printing that beside the all-in figure is the whole
point: a factor of 1000 is obvious in one line of
`61.96 -> 0.13522` and invisible in a plan. The framework supplies the
one number a plugin may never read, by handing back the assembled
result rather than the tax rate.
⚠️ IT MUST NOT RAISE INTO THE CONTROL PATH and it is not trusted not
to: `_emit_startup_preview()` catches everything. A log line is not a
reason to stop regulating a battery.
"""
return []
# ⚠️ THE NAMES A PLUGIN'S OWN CODE MAY NOT MENTION. FRAMEWORK_KEYS is the
# obvious half; the other three are the routes review of PR #16 actually used to
@@ -1491,6 +1534,60 @@ def _points_from_columnar_json(body, expect_unit, scale,
return out
def dayahead_feed_url(plugin_id, opts, since, until, base, bzn) -> str:
"""The day-ahead URL for a window, or the operator's override, verbatim.
⚠️ SHARED BY `generic_dayahead` AND `custom_formula`, ON PURPOSE
(TARIFF-15). They read the SAME feed for the same range; two copies of this
rule would be two places for the Brussels-calendar derivation and the
override decision to disagree, and the one that is wrong would be wrong
silently - §11.4 turns a short horizon into provisional hours, not into an
error. It takes `plugin_id` rather than reading one, so the framework's
AC2 rule (no plugin id in shared code) still holds here.
⚠️ AN OVERRIDE IS THE EXACT URL AND NOTHING IS APPENDED TO IT, WHICH COSTS
THE OVERRIDING SITE THE RANGE. That is a decision, not an oversight, and
the sentence below is why it is not a footnote:
* `price_feed_url` says "override the plugin's default feed", and an
override that is silently rewritten is not one. Merging parameters
into an operator's query string means parsing and re-serialising a
URL they wrote - which reorders, de-duplicates and re-escapes it,
and can hand the server a request nobody typed.
* `start`/`end` are ENERGY-CHARTS' spelling, and both providers DECLARE
A SECOND HOST: `web-api.tp.entsoe.eu`. Nothing here has verified that
host's day-ahead parameters and nothing here should guess them - but
they are not `start`/`end`, so an override to the other DECLARED host
is exactly the case where appending our vocabulary adds two parameters
that host ignores and yields the short horizon anyway, with nothing
saying so.
* and blind appending is refused outright: `dated_feed_url()` will not
add a query string to a base that has one.
So the cost is real and it is REPORTED, at fetch time, on the shipped
logger, naming the horizon consequence rather than the substitution.
§11.4 is what makes a price defect quiet; an operator who has taken the
short horizon has to be able to find out from the log that they did.
"""
override = str(opts.get("price_feed_url") or "").strip()
if override:
_LOG.warning(
"price_feed_url is set, so %s is requesting %s exactly as "
"given and is adding NO date range to it. The public feed "
"answers a single Brussels day to a request that names no "
"range, and every hour past that day is then carried forward "
"from yesterday's profile and flagged provisional - §8.3 "
"drops provisional hours from the trade set, so arbitrage "
"would never see tomorrow as firm. Clear price_feed_url to "
"let this plugin ask for the %s..%s it was given.",
plugin_id, override, *(d.isoformat()
for d in brussels_date_range(since, until)))
return override
first, last = brussels_date_range(since, until)
return dated_feed_url(base, bzn=bzn, start=first.isoformat(),
end=last.isoformat())
@register
class GenericDayAhead(PriceProvider):
"""Public EPEX BE day-ahead feed, COLUMNAR, in EUR/MWh.
@@ -1533,57 +1630,22 @@ class GenericDayAhead(PriceProvider):
async def fetch(self, opts, since, until, transport):
"""Request the range the CALLER asked for, on the Brussels calendar.
⚠️ THE ARGUMENTS ARE USED. Until TARIFF-09 this method took `since` and
`until` and asked for the bare `?bzn=BE`, which answers ONE Brussels
day - so a 48 h horizon was 15 firm hours and 33 carried at 09:00, and
1 firm and 47 carried at 23:00, with the whole trade set inside a
single calendar day at every hour of every day. See
`brussels_date_range()` for the measurement and for why the calendar
is Brussels rather than UTC.
⚠️ THE ARGUMENTS ARE USED, and `dayahead_feed_url()` above is where
they are turned into a URL. Until TARIFF-09 this method took `since`
and `until` and asked for the bare `?bzn=BE`, which answers ONE
Brussels day - so a 48 h horizon was 15 firm hours and 33 carried at
09:00, and 1 firm and 47 carried at 23:00, with the whole trade set
inside a single calendar day at every hour of every day.
⚠️ AN OVERRIDE IS THE EXACT URL AND THIS METHOD APPENDS NOTHING TO IT,
WHICH COSTS THE OVERRIDING SITE THE RANGE. That is a decision, not an
oversight, and the sentence below is why it is not a footnote:
* `price_feed_url` says "override the plugin's default feed", and an
override that is silently rewritten is not one. Merging parameters
into an operator's query string means parsing and re-serialising a
URL they wrote - which reorders, de-duplicates and re-escapes it,
and can hand the server a request nobody typed.
* `start`/`end` are ENERGY-CHARTS' spelling, and this provider
DECLARES A SECOND HOST: `web-api.tp.entsoe.eu`. Nothing here has
verified that host's day-ahead parameters and nothing here should
guess them - but they are not `start`/`end`, so an override to the
other DECLARED host is exactly the case where appending our
vocabulary adds two parameters that host ignores and yields the
short horizon anyway, with nothing saying so.
* and blind appending is refused outright: `dated_feed_url()` will
not add a query string to a base that has one.
So the cost is real and it is REPORTED, at fetch time, on the shipped
logger, naming the horizon consequence rather than the substitution.
§11.4 is what makes a price defect quiet; an operator who has taken the
short horizon has to be able to find out from the log that they did.
⚠️ THE URL DECISION MOVED OUT OF THIS METHOD IN TARIFF-15 AND DID NOT
CHANGE. `custom_formula` reads the same feed with the same range and
the same parser, and a second copy of the override rule would be a
second place for it to be wrong - which is the reason `covers()`
delegates to `at()` rather than restating its window. The reasoning,
the warning and the exact URL are all in `dayahead_feed_url()`.
"""
override = str(opts.get("price_feed_url") or "").strip()
if override:
url = override
_LOG.warning(
"price_feed_url is set, so %s is requesting %s exactly as "
"given and is adding NO date range to it. The public feed "
"answers a single Brussels day to a request that names no "
"range, and every hour past that day is then carried forward "
"from yesterday's profile and flagged provisional - §8.3 "
"drops provisional hours from the trade set, so arbitrage "
"would never see tomorrow as firm. Clear price_feed_url to "
"let this plugin ask for the %s..%s it was given.",
self.id, url, *(d.isoformat()
for d in brussels_date_range(since, until)))
else:
first, last = brussels_date_range(since, until)
url = dated_feed_url(self.FEED_BASE, bzn=self.BZN,
start=first.isoformat(),
end=last.isoformat())
url = dayahead_feed_url(self.id, opts, since, until,
self.FEED_BASE, self.BZN)
check_egress(url, self.capabilities())
body = await transport(url, float(opts.get("price_timeout_s")))
return _points_from_columnar_json(body, expect_unit="EUR / MWh",
@@ -1660,10 +1722,160 @@ class ManualCsv(PriceProvider):
return out
@register
class CustomFormula(PriceProvider):
"""The operator's OWN tariff card, as two fixed coefficients. TARIFF-15.
SOURCES: energy, markup
The framework ADDS: taxes
⚠️ IT DECLARES `{ENERGY, MARKUP}` AND NOT `ALL_IN`, AND THAT IS THE WHOLE
SAFETY ARGUMENT. A Belgian card's `Tariefformule` line is the ENERGY
component only - Eneco tabulates network tariffs and levies separately,
ENGIE states that the formula excludes network, distribution, transport,
grid levies and excise - so declaring ALL_IN would lie on behalf of every
operator who typed exactly what their card says, and would silently drop
~12 c EUR/kWh from every hour. The framework adds `price_taxes_eur_kwh`
on top, once, and this class cannot see or apply it (trap 1).
The risk that introduces is one no guard in this file can see, because it
is not in code: THE OPERATOR folding their network charges into `offset`
while the tax option carries them too. `app/custom_formula.py`'s card
self-check is what refuses that - the card's printed estimate is the
energy component alone, so an inflated offset moves 15.55 to ~27 and the
add-on will not start. Its limit is stated in the same breath there: it
grades the coefficients against the card, NOT the card against reality.
⚠️ NO EXPRESSION EVALUATION OF ANY KIND, AND THE COEFFICIENTS ARE NUMBERS
BECAUSE THE PARSER REFUSES ANYTHING ELSE. This string is settable over
HTTP through the add-on's own ingress config page, so `eval` on it would
be RCE in a root container holding SUPERVISOR_TOKEN - and, the deciding
argument, an arbitrary expression could not be graded against the card at
all. See app/custom_formula.py.
⚠️ THE INDEX PATH IS `generic_dayahead`'s, NOT A SECOND ONE. Same feed,
same `dayahead_feed_url()` request on the Brussels calendar, same columnar
parser, same declared egress hosts. A second parser would be a second
failure mode for a document that already has one that works.
"""
id = "custom_formula"
FEED_BASE = "https://api.energy-charts.info/price"
BZN = "BE"
# Written by fetch(), read by startup_preview(), and by nothing else.
spec = None
index_by_instant: dict = {}
@classmethod
def capabilities(cls):
return ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY, Component.MARKUP}),
egress_hosts=("api.energy-charts.info", "web-api.tp.entsoe.eu"))
async def fetch(self, opts, since, until, transport):
"""Fetch the index, apply the card's arithmetic, return RawPoints.
⚠️ `since`/`until` ARE USED, through the shared URL builder - see
`dayahead_feed_url()` and TARIFF-09 for what ignoring them costs.
"""
try:
spec = parse_price_formula(opts.get("price_formula"))
except FormulaInvalid as e:
raise SourceUnavailable(f"price_formula: {e}")
if spec is None:
raise SourceUnavailable(
"price_provider is 'custom_formula' but price_formula is "
"empty, so there are no coefficients to price anything with. "
"validate.py refuses this configuration at startup; reaching "
"it here means the options blob changed under a running "
"add-on")
url = dayahead_feed_url(self.id, opts, since, until,
self.FEED_BASE, self.BZN)
check_egress(url, self.capabilities())
body = await transport(url, float(opts.get("price_timeout_s")))
# ⚠️ `scale=1.0`, SO THESE RawPoints CARRY EUR/**MWh** IN A FIELD
# SPELLED `eur_per_kwh`. That is a trap and it is contained on purpose:
# the list is destructured on the very next line and never leaves this
# method, so nothing downstream can read the wrong unit off it. The
# alternative - scaling to EUR/kWh and multiplying by 1000 again to
# feed the formula - is two conversions where the contract needs none,
# and every extra conversion here is another place to lose a factor of
# 1000. If this list ever DID escape, `_validate_raw()` would refuse it
# at `MAX_PLAUSIBLE_EUR_KWH` rather than price a day on it.
index = _points_from_columnar_json(body, expect_unit="EUR / MWh",
scale=1.0,
provisional_default=False)
pairs = to_resolution([(p.start, p.eur_per_kwh) for p in index],
spec.resolution)
self.spec = spec
self.index_by_instant = {t.astimezone(timezone.utc): v
for t, v in pairs}
# ⚠️ `provisional=False` FOR THE SAME REASON `generic_dayahead` SAYS
# SO: the day-ahead auction is settled once published, and the columnar
# document carries no per-point flag to read one from. FCST-04's
# carry-forward is what marks the hours nobody published yet.
return [RawPoint(start=t, eur_per_kwh=price_eur_kwh(spec, v),
provisional=False)
for t, v in pairs]
def startup_preview(self, points) -> list:
"""`index EUR/MWh -> all-in EUR/kWh` for the first 24 h, once.
⚠️ THE INDEX COLUMN IS WHY THIS HOOK EXISTS. The all-in price alone
says nothing about which of the two numbers is wrong; printed beside
the index it came from, a factor of 1000 is one glance. See
`preview_lines()` for the rows and the min/median/max/spread line.
"""
if self.spec is None or not points:
return []
cut = points[0].start + timedelta(hours=PREVIEW_HOURS)
rows = []
for point in points:
if point.start >= cut:
break
index = self.index_by_instant.get(
point.start.astimezone(timezone.utc))
if index is not None:
rows.append((point.start, index, point.eur_per_kwh))
return preview_lines(self.spec, rows)
# --------------------------------------------------------------------------- #
# The one entry point
# --------------------------------------------------------------------------- #
# Provider ids whose startup preview has already been emitted in this process.
# ⚠️ NOT A PLUGIN NAME IN THE FRAMEWORK: it is filled at runtime from whatever
# id was resolved, and starts empty. AC2 is intact.
_PREVIEWED: set = set()
def _emit_startup_preview(plugin, pid: str, points) -> None:
"""Log a plugin's preview once per process, and never fail because of it.
⚠️ THE ID IS RECORDED BEFORE THE HOOK RUNS, DELIBERATELY. A preview that
raises must not be retried every 30 minutes for the life of the add-on -
that would turn a cosmetic defect into a log flood on the one path §11.4
already makes hard to read.
"""
if pid in _PREVIEWED:
return
_PREVIEWED.add(pid)
try:
lines = list(plugin.startup_preview(points))
except Exception as e: # noqa: BLE001
_LOG.warning("%s's startup preview raised (%s: %s); prices are "
"unaffected - this is a log line, not a decision",
pid, type(e).__name__, e)
return
for line in lines:
_LOG.info("%s", line)
async def load_prices(opts: dict, since: datetime, until: datetime,
transport=None) -> PriceRead:
"""Resolve the configured provider, fetch, assemble all-in, return a read.
@@ -1709,8 +1921,9 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
if transport is None:
transport = https_get
plugin = cls()
try:
raw = await cls().fetch(PluginOptions(opts), since, until, transport)
raw = await plugin.fetch(PluginOptions(opts), since, until, transport)
_validate_raw(raw, caps)
points = assemble(caps, raw, markup, taxes)
except FrameworkOption:
@@ -1720,5 +1933,6 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
except Exception as e: # noqa: BLE001 - see the docstring
return PriceRead.no_data(f"{pid}: {type(e).__name__}: {e}")
_emit_startup_preview(plugin, pid, points)
return PriceRead.series(points, caps.product, pid,
datetime.now(timezone.utc))