TARIFF-15: your own tariff card, as two coefficients the card itself grades
checks / unit (pull_request) Successful in 7m14s
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:
@@ -324,6 +324,8 @@ import logging
|
||||
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from .custom_formula import (
|
||||
FormulaInvalid, implausible_all_in, parse_price_formula, stale_card)
|
||||
from .inverter import roster_entries
|
||||
from .maintenance import MAINT_OPTION_DEFAULTS
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
@@ -1249,9 +1251,12 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
share of the installed base, per §9.2 - cannot run at all.
|
||||
|
||||
Five refusals and two log lines (TARIFF-03 added one refusal, one parse and
|
||||
two notices; TARIFF-07 added the retired-id one), and every refusal is still
|
||||
case (a) from this module's docstring: a source the operator SET being
|
||||
overridden by a layer they cannot see. The additions are `static_profile`
|
||||
two notices; TARIFF-07 added the retired-id one; TARIFF-15 added three -
|
||||
a `custom_formula` with no formula, a formula that does not parse or does
|
||||
not reproduce its own card, and one whose card lands outside the plausible
|
||||
band - plus two notices, a formula nothing reads and a stale card), and
|
||||
every refusal is still case (a) from this module's docstring: a source the
|
||||
operator SET being overridden by a layer they cannot see. The additions are `static_profile`
|
||||
alongside the Home Assistant price pair - the same silent-loser shape as
|
||||
provider-plus-pair, and a LIVE one - and a `static_profile` that does not
|
||||
tile the day, which would leave hours with no price at all.
|
||||
@@ -1415,6 +1420,85 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
"for this product. Peak shaving, reserve management and the "
|
||||
"maintenance cycle are unaffected.", profile.prices[0])
|
||||
|
||||
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
||||
# STARTUP RATHER THAN AT 03:00.
|
||||
#
|
||||
# THE DEFECT THIS EXISTS TO PREVENT, and it is not one `code_leaks()` can
|
||||
# see because it is not in code: an operator folds their NETWORK CHARGES
|
||||
# into `offset` while `price_taxes_eur_kwh` carries them as well, and the
|
||||
# site is billed against a price ~12 c EUR/kWh high on every hour for as
|
||||
# long as nobody re-reads the configuration. §11.4 makes every price
|
||||
# defect quiet, so nothing on the status page would ever differ.
|
||||
#
|
||||
# The card is what grades it. A Belgian tariff card prints the formula,
|
||||
# the reference index it assumed AND the resulting price, so the add-on
|
||||
# recomputes that printed price from the numbers the operator typed and
|
||||
# refuses to start unless the two agree to within CARD_TOLERANCE. An
|
||||
# inflated `offset` cannot pass - the printed estimate is the energy
|
||||
# component alone, so ~12 c EUR/kWh moves 15.55 to ~27. The same check
|
||||
# catches VAT omitted, VAT applied twice, a transposed digit, and a unit
|
||||
# MISMATCH between the coefficients and the printed estimate. The one unit
|
||||
# error it cannot see is the one applied to BOTH sides at once, because
|
||||
# that cancels out of a comparison between them - which is why
|
||||
# `implausible_all_in()` below is a second refusal on a second quantity
|
||||
# rather than a restatement of this one.
|
||||
#
|
||||
# ⚠️ AND ITS LIMIT, IN THE SAME SENTENCE AS THE CLAIM, exactly as price.py
|
||||
# states trap 1's: IT GRADES THE COEFFICIENTS AGAINST THE CARD, NOT THE
|
||||
# CARD AGAINST REALITY. A consistent-but-wrong pair - both numbers off the
|
||||
# wrong product - reproduces its own printed price and passes. Nothing can
|
||||
# catch that.
|
||||
raw_formula = opts.get("price_formula", "")
|
||||
has_formula = bool(str(raw_formula or "").strip()) \
|
||||
or isinstance(raw_formula, dict)
|
||||
if pid == "custom_formula" and not has_formula:
|
||||
problems.append(
|
||||
"price_provider is 'custom_formula' but price_formula is empty, "
|
||||
"so the add-on has no coefficients to price anything with. Every "
|
||||
"fetch would return no-price-data and §11.4 would fall back "
|
||||
"silently - which is the shape of defect this provider exists to "
|
||||
"remove, arrived at by leaving one field blank. Copy the "
|
||||
"Tariefformule line off your supplier's tariff card, e.g. "
|
||||
"{\"index\":\"epex_be\",\"resolution\":\"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}} "
|
||||
"- see DOCS.md. Or set price_provider to 'generic_dayahead' for "
|
||||
"the wholesale curve with a flat markup.")
|
||||
elif has_formula:
|
||||
try:
|
||||
spec = parse_price_formula(raw_formula)
|
||||
except FormulaInvalid as e:
|
||||
spec = None
|
||||
problems.append(f"price_formula cannot be used: {e}")
|
||||
if spec is not None and pid != "custom_formula":
|
||||
# ⚠️ A WARNING AND NOT A REFUSAL. This is not two price sources
|
||||
# fighting over one flag - it is one that is simply not wired to
|
||||
# anything - so the §14 warning box does not apply. But an option
|
||||
# an operator filled in and that decides nothing is exactly the
|
||||
# silent loser this module's case (a) is about, so it is said out
|
||||
# loud rather than left to be discovered from a bill.
|
||||
_LOG.warning(
|
||||
"price_formula is filled in but price_provider is %r, so the "
|
||||
"coefficients decide NOTHING - only 'custom_formula' reads "
|
||||
"them. Set price_provider to 'custom_formula' to use them, or "
|
||||
"clear price_formula so the configuration says what the "
|
||||
"add-on is doing.", pid)
|
||||
if spec is not None and pid == "custom_formula":
|
||||
taxes = _number(opts, "price_taxes_eur_kwh",
|
||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||
band = implausible_all_in(spec, taxes if taxes is not None else 0.0)
|
||||
if band:
|
||||
problems.append(band)
|
||||
aged = stale_card(spec)
|
||||
if aged:
|
||||
# ⚠️ WARN, NEVER REFUSE. 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.
|
||||
_LOG.warning("%s", aged)
|
||||
|
||||
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
||||
# site with no price data falls back to the static profile; a dynamic site
|
||||
# with none configured has nothing to fall back to when its feed dies. That
|
||||
|
||||
Reference in New Issue
Block a user