`price_taxes_eur_kwh` shipped at 0.06 EUR/kWh with no recorded provenance -
no card, no date, no component list - and roughly HALF the true Flemish
all-in non-energy figure. It now ships at 0.11736, and the rows it is made
of are in the code rather than in this message:
Fluvius distribution (Antwerpen) 5,35 c EUR/kWh
Bijzondere accijns, "Alle verbruik" 4,876
Bijdrage groene stroom en WKK Vlaanderen 1,51
Energiebijdrage (0 EUR/MWh since 2026-08-01) 0
------
11,736 c EUR/kWh = 0.11736
Eneco residential card for Flanders, September 2026, incl. 6 % VAT.
`app/price.py`'s FLEMISH_TAXES_COMPONENTS_EUR_KWH carries the rows and
test_price.py section 1b re-adds them - and also compares the total against
the card's own figures typed in the test file, so an edit that moved the
rows and the default together is still caught.
WHAT THIS IS NOT. It is not an arbitrage-correctness fix and it cannot be.
This scalar is a LEVEL component: an additive constant cancels exactly in
`max - min` and leaves the argmin where it was. TARIFF-17 shipped that proof
and it is unchanged - test_price.py 16b drives six flat scalars spanning and
exceeding the whole per-DSO range and gets the identical cheapest hour and
the identical spread to the last digit. DOCS and the CHANGELOG say so in
those words.
WHAT IT IS. A display and guard fix:
* every number an operator reads, which at half the true value made the
add-on look considerably better than it is; and
* §17's negative-price guard in arbitrage.py - the one rung that tests an
ABSOLUTE all-in price rather than a difference. It fires when
`spot < -(markup + taxes)`, so its boundary moves -0.09 -> -0.14736
EUR/kWh of spot. test_arbitrage.py section 2c drives both sides through
the shipped decide() and pins the boundary from below as well.
The default is Antwerpen's real row, near the LOW end of the 0.117-0.131
per-DSO band, and that is deliberate: the error directions are not
symmetric. Too high carries a genuinely negative hour above zero and the
add-on exports into paid-for disposal - §17's own case. Too low only
declines a discharge that would have paid.
TARIFF-17's INTERACTION, WHICH HAD TO BE DECIDED. Nothing in that rung
hardcoded 0.06; it read PRICE_OPTION_DEFAULTS, so moving the default moves
what it refuses. The Supervisor sets `App.options = deepmerge(config.yaml
defaults, stored options)`, so a saved blob carries the key whether it was
typed or not - every stored 0.06 became "non-default" with nobody editing
anything, and a working Walloon or Brussels site with a taxes table would
have met SystemExit(78) on the upgrade over an inert number. price.py now
carries SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) and validate.py reads a
superseded default as untouched too. The rung is NOT weakened: 0.1198 beside
a table is still refused by name, and the refusal now names both figures.
config.yaml and PRICE_OPTION_DEFAULTS moved together; CFG-03's tripwire
(test_price.py section 1, test_defaults_pin.py) stayed green by agreeing.
test_price.py section 0's fourth check was REWRITTEN rather than re-pointed:
it used to justify the file's own grading constants by observing that the
shipped pair was degenerate (0.06 is exactly twice 0.03). TARIFF-18 ends
that coincidence, so the sentence is no longer true and is not kept.
29/29 test files green. Mutation ledger on the PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
9.0 KiB
TARIFF-15 — the operator's own tariff card, and why the card has to grade it
Status: DECIDED 2026-09-13 — ship custom_formula with FIXED coefficients and
a REQUIRED card block whose printed price the add-on reproduces at startup or
refuses to run. No expression evaluation, ever. Declare {ENERGY, MARKUP},
never ALL_IN.
Filed with TARIFF-15. Follows TARIFF-07, which withdrew three per-supplier plugins and named a formula provider as the answer it could not give.
1. Why a formula rather than more plugins
TARIFF-07 established the negative half: a per-supplier plugin has no source for its numbers. This card establishes the positive one.
- There is no machine-readable source of Belgian tariff coefficients anywhere. CREG's consumer tariff-card pages 404. CREG Scan is a JavaScript-only wizard. The annual report carries the supplier list and no per-product coefficients. VREG was unreachable. Every coefficient found in research came from a supplier's own PDF, one of them image-only.
- So there is nothing to scrape, and fourteen plugins would still have no source for their numbers — they would be fourteen copies of the invention TARIFF-07 deleted three of.
- The customer is the only party who can see their own coefficients, and the card they read them from is the only artefact that can grade them. That second clause is the whole design; everything below follows from it.
2. The shape is the market's published arithmetic
Both suppliers whose 2026 cards were readable print three things on one line: the formula, the reference index it assumes, and the resulting price.
| computed | the card prints | |
|---|---|---|
Eneco (0.102 × 133.9905 + 1) × 1.06 |
15.5471 | 15,55 |
ENGIE (0.1 × 61.96 + 0.9) × 1.06 |
7.5218 | 7,522 |
Two independent suppliers, reproduced to the last printed digit, through the
shipped code (test_price.py 15a runs both, and then runs both again end to
end through load_prices()).
That third column is not a nicety. It is the only externally-published number in this whole feature, and it is what makes the design safe.
3. The double-counting risk, and which half of it is new
price.py's trap 1 already mechanises that a plugin cannot double-count:
PluginOptions refuses the framework keys, code_leaks() refuses a plugin
whose code mentions them, assemble() adds the complement exactly once.
custom_formula introduces a risk none of those can see, because it is not
in code: the operator double-counting, by folding their network charges
into offset while price_taxes_eur_kwh also carries them. code_leaks()
scans code. This is config, typed by a person reading a PDF.
It is a real reading of a real card, not a hypothetical. A Belgian card's
Tariefformule line is the energy component alone — Eneco tabulates
network tariffs in one table and levies in a second; ENGIE states in so many
words that the formula excludes network, distribution, transport, grid levies
and excise. An operator who wants "the price I actually pay" and adds ~12
c€/kWh to offset has done something entirely reasonable and is now billed for
network charges twice, on every hour, with §11.4 making the whole class of
price defect invisible.
Decision: the card grades it. At startup validate.py recomputes the
card's own printed price from the operator's coefficients and refuses to start
unless the two agree to within 0.01 — which is the last digit a card prints.
An inflated offset cannot pass, because the printed estimate is by
construction the energy component: 15.55 becomes 28.27 and the add-on stops and
says why.
And the limit, stated 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, or off a card the contract has since rolled over from — agrees
with itself perfectly and passes. Nothing here or anywhere else can catch that.
3b. The one unit error the card check cannot see
A factor applied to both sides — coefficients and printed price — cancels out of a comparison between them. That is a second quantity, so it is a second refusal: the card's own point must land inside 0.02–1.00 EUR/kWh.
⚠️ Its floor is applied to the ENERGY figure and not to the all-in one, and
that asymmetry was found by a mutation rather than by reasoning. The shipped
price_taxes_eur_kwh was 0.06 — three times the floor — so a card read a
hundred times too small comes out at 0.0016 EUR/kWh of energy, is carried over
0.02 by the flat tax addend alone, and passes a band applied to the total. The
hundredfold error is exactly what the band exists for. Graded from both
directions in test_price.py 15e/15i and driven red as M10. (Annotated
2026-09-13: TARIFF-18 raised the default to 0.11736, nearly six times the
floor. The asymmetry is unchanged in kind and larger in degree — the flat
addend that carries a hundredfold-small card over 0.02 got bigger.)
4. Fixed coefficients. No expression evaluation of any kind
Decided: never a grammar. Two arguments, and the second is the one that decides it.
- Security. The add-on serves
POST config/save→POST /addons/self/optionson its own ingress page, soprice_formulais settable over HTTP.evalon it is remote code execution in a root container holdingSUPERVISOR_TOKEN, withhomeassistant_apiandhassio_api, driving an inverter over RS485.eval(s, {"__builtins__": {}})is not a sandbox and this repository is not going to pretend otherwise. - ⚠️ AND THE DECIDING ARGUMENT IS NOT SECURITY: AN EXPRESSION IS UNVERIFIABLE. Two coefficients can be graded against the card's printed estimate. An arbitrary expression cannot be graded against anything — so it makes the invisible-wrong-coefficient problem strictly worse, which is the exact failure this provider exists to prevent. An expression would buy the operator freedom to be wrong in a way nothing could report.
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
B × index + A, so an expression buys nothing the market needs either.
Future shapes become NAMED FIELDS — clamp_lo, factor_negative,
offset_bands — each individually refusable and each individually gradeable
against a card. parse_price_formula() therefore refuses an unknown key
rather than ignoring it: an operator who types "expr" is told it does not
exist, instead of being priced on the fields they also typed with the one they
believed in dropped in silence.
test_price.py 15f walks the module's AST for eval, exec, compile,
__import__ and literal_eval, and exercises the scanner's red direction
on every run so it cannot become a scanner that scans nothing.
5. {ENERGY, MARKUP}, not ALL_IN
The contract's formula is the supplier's markup — that is what B and A
are — but it is not the levies. Declaring ALL_IN would lie on behalf of every
operator who typed exactly what their card says and would drop
price_taxes_eur_kwh from every hour: measured at 0.2125 → 0.1555 EUR/kWh.
price_markup_eur_kwh is therefore ignored under this provider, and logged
as ignored, which is the framework's existing rule rather than a new one.
6. What was reused rather than rebuilt
The index path is generic_dayahead's: same EPEX BE feed, same
Brussels-calendar range, same columnar parser, same declared egress hosts. The
override/range decision moved into one shared dayahead_feed_url() that both
plugins call, because two copies of that rule would be two places for the
short-horizon defect TARIFF-09 fixed to come back in only one of them.
resolution: hourly averages each hour's members. ⚠️ That is not
_Series.at()'s hourly assumption returning (TARIFF-14): it decides how many
points are emitted; the window each point covers is still derived from its
neighbours in _cover_table(). A feed that is already hourly averages one
member per hour — the identity — so the option cannot silently assume the
granularity it was handed. Graded.
7. What this card did NOT do
- The injection leg. Eneco uses
0,102on consumption and0,100on injection, and injection is VAT-exempt where consumption is at 6 % — a second per-contract formula, not a second copy of this one. Separate card. - The hour-varying network charge that bites Wallonia. Separate card.
- Raising
price_taxes_eur_kwhfrom 0.06 to ~0.12. Separate card, and an owner's decision. Note §3b: the band's floor is deliberately insensitive to whatever that number becomes. (Annotated 2026-09-13: that card is TARIFF-18 and it shipped on this branch at 0.11736 — the Eneco residential card for Flanders, September 2026, incl. 6 % VAT, summed from Fluvius Antwerpen distribution 5,35 + bijzondere accijns 4,876 + bijdrage groene stroom en WKK 1,51 c€/kWh. §3b held: the floor is still on the energy figure and nothing in this module needed a new number.)