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
+179 -10
View File
@@ -116,7 +116,7 @@ whole rendered list, in both languages.
| | |
|---|---|
| **All 71 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
| **All 72 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
@@ -1369,7 +1369,7 @@ does, and only after you have read why.
| option | meaning |
|---|---|
| `price_provider` | `off` (default) or one of the two plugins below |
| `price_provider` | `off` (default) or one of the three plugins below |
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
| `price_taxes_eur_kwh` | Levies and VAT, EUR/kWh. Same rule |
| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below |
@@ -1378,6 +1378,7 @@ does, and only after you have read why.
| `price_horizon_h` | How far ahead to ask for, hours |
| `price_timeout_s` | Per-request timeout |
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
| `price_formula` | For `custom_formula` only: your own contract's two coefficients, off your own tariff card. Empty by default — see below |
**Which plugin sources what.** This is the one thing to get right, because
adding your markup to a price that already contains it makes every future
@@ -1386,6 +1387,7 @@ arbitrage decision wrong by that amount, in a direction nothing reports.
| plugin | product | the published figure contains | the add-on adds |
|---|---|---|---|
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
| `custom_formula` | day-ahead | energy **and** your contract's markup — your card's `Tariefformule` line | taxes |
| `manual_csv` | day-ahead | everything, all-in | nothing |
⚠️ **Three plugins were withdrawn in TARIFF-07, and if your configuration names
@@ -1425,7 +1427,9 @@ Belgian dynamic contract is a **formula**:
consumption: price = B x spot + A <- B and A are in YOUR contract
```
The add-on cannot express that yet. Two gaps, and the second is the harder one:
`generic_dayahead` cannot express that. Two gaps, and the second is the
harder one — **both are closed by `custom_formula`**, which is what the
rest of this section is about:
* **No multiplier.** `B` is typically slightly above 1 (it scales the wholesale
price before anything is added). A flat markup cannot scale anything, so the
@@ -1443,13 +1447,20 @@ deciding **which hours** are cheap that is usually good enough; for deciding
compares against — it is an approximation, and one that errs by however far `B`
is from 1.
A **formula provider** (`B x spot + A`, with your own coefficients entered once)
is the planned answer and is a separate card. It is not implemented here. Until
it ships, the two supported honest options are:
**`custom_formula` is the answer to exactly this, and it shipped in TARIFF-15.**
You type your contract's two coefficients once, off your own tariff card, and
the add-on evaluates `B x index + A` against the same EPEX BE day-ahead curve
`generic_dayahead` reads. Both gaps above close: `B` is a real multiplier, and
`A` may be negative. It is documented in full in **[Your own tariff formula
(`custom_formula`)](#your-own-tariff-formula-custom_formula)** below.
1. **`generic_dayahead`** with your markup and taxes set as well as you can —
So there are three honest options rather than two:
1. **`custom_formula`** — your contract's own arithmetic, if you can read the
two coefficients off your tariff card. This is the one to use.
2. **`generic_dayahead`** with your markup and taxes set as well as you can —
accepting the approximation above; or
2. **`manual_csv`**, where you compute the all-in prices yourself from your own
3. **`manual_csv`**, where you compute the all-in prices yourself from your own
contract's formula and hand the add-on the answers. Nothing is approximated,
but nothing is automatic either.
@@ -1472,7 +1483,7 @@ the `sources` declaration is the part a reviewer has to read.
**Real-time / imbalance products still have a place in the machinery, but no
plugin declares one.** A plugin that declares `PriceProduct.REALTIME` is flagged
high-volatility and gets widened arbitrage thresholds; that comes from the
plugin's own declaration, never from its name. Both shipped plugins are
plugin's own declaration, never from its name. All three shipped plugins are
day-ahead, so nothing exercises that path on a real install today — it is kept
on purpose so the first genuine imbalance product does not have to rebuild it.
See `DECISIONS/TARIFF-07-withdrawing-three-plugins.md`.
@@ -1529,7 +1540,165 @@ and the moment it retires; the plan's own age is **not** reset by the failed
refresh, so a kept plan still expires on schedule and is never presented as a
current one. One failed fetch is a blip, not a lost tariff.
**`manual_csv` — the offline fallback.** It needs no internet, no supplier
#### Your own tariff formula (`custom_formula`)
**What it is for.** A Belgian dynamic contract prices energy as a formula —
your supplier's tariff card prints it as `Tariefformule` — and the two
coefficients in it belong to *your contract*. There is no machine-readable
source for them anywhere: CREG's consumer tariff-card pages return 404, CREG
Scan is a JavaScript wizard, the annual report lists suppliers without
per-product coefficients, and VREG was unreachable. Every coefficient found in
research came off a supplier's own PDF, one of them image-only. **You are the
only party who can see your own coefficients**, so you type them, once.
Set `price_provider: custom_formula` and fill in `price_formula` with one line
of JSON:
```json
{"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}}
```
The contract it expresses is:
```
price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct / 100)
```
expressed in `units`, and **converted to EUR/kWh by the add-on**. You type what
your card prints, in the card's own units; every unit conversion and the VAT
multiplication are the add-on's job, because those are exactly the two steps
where a human silently loses a factor of 10, 100 or 1.06.
| field | what it is |
|---|---|
| `index` | the reference series. `epex_be` — the EPEX BE day-ahead curve in EUR/MWh, the same feed `generic_dayahead` reads |
| `resolution` | `quarter_hourly` (the 96 points the feed publishes) or `hourly` (each hour's quarters averaged into one point). Your card says which series your contract is written against |
| `factor` | `B` — the multiplier on the index. Must be a number, and positive |
| `offset` | `A` — the fixed part, in the same units. **May be negative** |
| `units` | `c_eur_per_kwh`, `eur_per_kwh` or `eur_per_mwh`. **Required, with no default** |
| `vat_pct` | the VAT percentage. `6` for Belgian residential electricity — not `0.06` and not `106`. `0` is legitimate |
| `card` | the month the card is dated, the reference index it assumed, and the price it prints. **Required** — see below |
##### A worked example, from a real card
Eneco's September 2026 card prints the formula, the reference index it assumed
*and* the resulting price. With `factor: 0.102`, `offset: 1.00`, `vat_pct: 6`
and the card's own index of `133.9905` EUR/MWh:
```
(0.102 x 133.9905 + 1.00) x 1.06 = 15.5471 c€/kWh the card prints 15,55
```
ENGIE's card, in the same shape with its own numbers:
```
(0.100 x 61.96 + 0.90) x 1.06 = 7.5218 c€/kWh the card prints 7,522
```
Both reproduce to the last digit the card prints, through the shipped code —
`test_price.py` section 15a runs exactly these two.
##### ⚠️ The `card` block is required, and it is the safety mechanism
Because your card prints the index it assumed **and** the price that comes out,
the add-on can check your coefficients against your supplier's own worked
example. At startup it recomputes that printed price from the numbers you
typed and **refuses to start** unless the two agree to within `0.01` — naming
the discrepancy, the ratio, and the likely causes.
That catches, in one check: VAT left out, VAT applied twice, a transposed
digit, and a **unit mismatch** between your coefficients and the price you
typed beside them (a factor of 10, 100 or 1000).
And it catches the expensive one. **Your card's formula line is the energy
component only.** Eneco tabulates network tariffs in one table and levies in a
second; ENGIE states that the formula excludes network, distribution,
transport, grid levies and excise. The add-on therefore declares that
`custom_formula` supplies *energy and markup*, and adds `price_taxes_eur_kwh`
on top — once. If you fold your network charges into `offset`, you are billed
for them twice, and nothing on the status page would ever show it. It cannot
happen: ~12 c€/kWh of network charge moves the computed 15.55 to about 27, and
the add-on refuses to start and says so.
⚠️ **And the limit of that check, in the same breath as the claim: it grades
your coefficients against your card, not your card against reality.** Two
numbers copied off the wrong product — or off a card your contract has since
rolled over from — agree with each other perfectly and pass. Nothing can catch
that. What is mechanised is that your coefficients and your card's printed
price *agree*; whether the card is the right card is yours to know. The add-on
warns (and does not refuse) when `card.month` is more than about six months
old, because ENGIE freezes coefficients for a contract's duration and an old
card is often exactly right.
One more check runs beside it: the card's own point must land inside the range
a Belgian retail tariff can occupy (0.02–1.00 EUR/kWh). That exists because a
unit error applied to **both** sides at once cancels out of the comparison
above and cannot cancel out of this one. It bounds the card's own point, **not
every hour** — a live hour may legitimately fall outside it, and a negative
day-ahead hour, which is the best charging opportunity of the year, is never
refused.
##### ⚠️ Fixed coefficients. No expression is evaluated, ever
`price_formula` takes numbers. There is no field that accepts an expression,
and an unrecognised field is **refused** rather than ignored, so typing
`"expr": "..."` tells you it does not exist instead of quietly pricing you on
the other fields.
Two reasons, and the second is the decisive one:
* This string is settable over HTTP — the add-on's own ingress configuration
page posts it to the Supervisor — so evaluating it would be remote code
execution inside a root container that holds a Supervisor token and drives
an inverter. (`eval` with an emptied `__builtins__` is not a sandbox.)
* **An expression could not be graded against anything.** Two coefficients can
be checked against your card's printed price; an arbitrary expression
cannot, so it would make the invisible-wrong-coefficient problem strictly
worse — which is the exact failure this provider exists to prevent.
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
`B x index + A`, so an expression would buy nothing the market needs. If a
future contract needs a different shape, it gets a **named field**
(`clamp_lo`, `factor_negative`, `offset_bands`) that can be refused on its
own. Never a grammar.
##### What you see at startup
The first time the feed answers, the add-on logs a preview of the next 24
hours as `index EUR/MWh -> all-in EUR/kWh`, followed by the minimum, median,
maximum and the day's spread. It is there so that a formula off by a factor of
1000 is visible in one line rather than in a month of bills:
```
custom_formula preview - the next 24 h of YOUR tariff, index EUR/MWh -> all-in
EUR/kWh, computed as (0.102 x index + 1) x (1 + 6/100) c_eur_per_kwh, converted
to EUR/kWh and with price_taxes_eur_kwh added by the framework:
2026-09-10T00:00:00+02:00 216.080 -> 0.30423
2026-09-10T00:15:00+02:00 216.080 -> 0.30423
... 72 further point(s) not listed; the figures below cover ALL 96
min 0.17277 / median 0.28031 / max 0.67499 EUR/kWh, spread 0.50222 EUR/kWh
over 96 point(s). ⚠️ If those are a thousand times what your bill says,
`units` is wrong; if the spread is 0, `factor` is.
```
(That transcript is the shipped code run against the committed
`scenarios/energy-charts-be-price-2026-09-10-quarterhourly.json` capture with
the default markup and taxes, not an illustration.)
##### What it does not do
The **injection** leg is a separate card. Eneco uses `0,102` on consumption and
`0,100` on injection, and injection is VAT-exempt where consumption is at 6 %,
so it is a second per-contract formula rather than a second copy of this one.
`export_price_eur_kwh` is still the scalar you enter by hand.
#### `manual_csv` — the offline fallback
It needs no internet, no supplier
account and no HTTP stack at all, and it is the supported way to run this
add-on price-aware on a site with no outbound connectivity. Write a file and
point `price_csv_path` at it: