A Belgian dynamic contract prices energy as B × 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 JavaScript-only wizard, the annual report carries the supplier list without per-product coefficients, VREG was unreachable, and every coefficient found in research came off a supplier's own PDF, one of them image-only. So nothing can be scraped, and a per-supplier plugin would have no source for its numbers — which is exactly why TARIFF-07 withdrew three of them. The customer is the only party who can see their own coefficients; they type them, once.
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.
price = (factor × index_eur_per_mwh + offset) × (1 + vat_pct/100), in units, converted to EUR/kWh by the add-on. The operator types what the card prints, in the card's own units; every unit conversion and the VAT multiplication are the add-on's job, because those are the two steps that silently cost a factor of 10, 100 or 1.06.
The three real tariffs, reproduced through the shipped code
Both suppliers whose 2026 cards were readable print the formula, the reference index they assumed and the resulting price — so the expectation is somebody else's published number rather than a restatement of our own arithmetic.
computed by the shipped module
the card prints
Eneco (0.102 × 133.9905 + 1) × 1.06
15.54705286
15,55
ENGIE (0.1 × 61.96 + 0.9) × 1.06
7.52176
7,522
Both are then run end to end through load_prices() — registered plugin, columnar parser, assemble() — and come out at 0.212471 and 0.132218 EUR/kWh all-in, which is the card's printed price plus price_taxes_eur_kwh and nothing else (test_price.py 15a).
⚠️The ticket asks for three and only two exist. Two cards were readable and both are here. The third real formula in the repository — Eneco's injection leg 0,1 × BELPEX-H − 1,188 — has no printed estimate beside it, so there is nothing to grade it against and it could not be reproduced honestly. It is out of scope anyway (separate card, own coefficients, VAT-exempt), and the third reproduction is instead the 96-point captured real body run through the card's arithmetic point by point, against the document's own index column.
The double-counting mechanism, which is the hardest part
custom_formula declares {ENERGY, MARKUP}, not ALL_IN. 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 the formula excludes network, distribution, transport, grid levies and excise. ALL_IN would lie on behalf of every operator who typed exactly what their card says and would drop the taxes from every hour: 0.2125 → 0.1555 EUR/kWh, measured.
The framework already mechanises that a plugin cannot double-count (PluginOptions, FRAMEWORK_KEYS, code_leaks()). This introduces a risk none of those can see, because it is config and not code: the operator double-counting, by folding network charges into offset while price_taxes_eur_kwh carries them too.
So the card grades it.validate.py recomputes the card's own printed price from the operator's coefficients at startup and refuses to start unless they agree to within 0.01. An inflated offset cannot pass: ~12 c€/kWh of network charge moves the computed 15.55 to 28.27.
The refusal, verbatim
price_formula does not reproduce its own card, so the add-on will NOT start on it. Your contract's arithmetic is price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100); on the card's own index that is (0.102 x 133.9905 + 13) x (1 + 6/100) = 28.2671 c_eur_per_kwh, but card.expect_incl_vat says 15.55 - OUT BY +12.7171 c_eur_per_kwh (ratio 1.818; the tolerance is 0.01, which is the last digit a card prints). One of the two sides is wrong and the add-on cannot tell which, so it refuses rather than price a battery on it. The usual causes, in the order they happen: the coefficients and the expected price are in DIFFERENT units (ratio 10, 100 or 1000 - unitsmust be the one the formula line is printed in); VAT left out or applied twice (ratio 1.06 or 1/1.06); a transposed digit; the two sides copied off DIFFERENT cards or different months; or - the expensive one - network charges, levies or excise folded intooffset. ⚠️ THEY DO NOT BELONG THERE: a Belgian card's Tariefformule line is the ENERGY component alone (Eneco tabulates network tariffs and levies separately; ENGIE says in so many words that the formula excludes network, distribution, transport, levies and excise), the add-on adds price_taxes_eur_kwh on top of it, and folding ~12 c EUR/kWh of network charge into offset bills you for it twice. That is the error this check exists to catch, and it is why it cannot be switched off.
⚠️ 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 — both numbers off the wrong product — reproduces its own printed price perfectly and passes. Nothing can catch that; that sentence is in price.py's house style, in the module docstring, in validate.py, in config.yaml, in both translations and in DOCS.md, beside the claim each time.
A second refusal covers the one unit error the first cannot see — the one applied to both sides at once, which cancels out of a comparison between them: 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, because the shipped price_taxes_eur_kwh of 0.06 is three times that floor and would carry a hundredfold error over it on its own (0.0016 → 0.0616 EUR/kWh, inside the band). That was found by writing the mutation, not by review — M10, +0 B — and it is a deliberate deviation from the ticket's "median all-in" wording, stated in the code and in DECISIONS.
No expression evaluation of any kind, and how that is checked
Guaranteed structurally, in three places rather than by a rule:
parse_price_formula() accepts only numbers for factor/offset/vat_pct — a string is refused as not a number, so "0.1*x" never becomes anything;
an unknown field is refused, not ignored, so {"expr": "..."} is told the field does not exist instead of being priced on the fields beside it — this is the half that keeps "future shapes become named fields, never a grammar" honest;
there is no evaluator anywhere in the module to be reached.
Checked by test_price.py 15f, which walks the AST of app/custom_formula.py and of the plugin class for calls to eval/exec/compile/__import__/literal_eval — a tree walk rather than a grep, which is load-bearing here: the module's own docstring contains the text eval(s, {"__builtins__": {}}) while explaining why that is not a sandbox, and a text scan would fire on the documentation it also mandates (code_leaks()'s lesson). The check's red direction is exercised on every run against planted eval and literal_eval calls, and M20 plants a real one in the shipped module and turns it red.
The deciding argument is in the code and in DECISIONS: it is not security. eval on a string settable over HTTP in a root container holding SUPERVISOR_TOKEN and driving an inverter over RS485 is bad enough — but an expression is unverifiable. Two coefficients can be graded against the card's printed price; an arbitrary expression cannot be graded against anything, so it would make the invisible-wrong-coefficient problem strictly worse, which is the exact failure this provider exists to prevent.
Mutations — 22, one per check added, no survivors
Harness /home/homelab/.cache/t15-mut/run.py, backups beside it, __pycache__ purged on the host before every run, graded through the CI image with the tree bind-mounted :ro so no cache can be written into it at all. Full ledger in MUTATION.md.
mutation
file
Δ
red
M1
CARD_TOLERANCE widened to 100
custom_formula.py
+1 B
8
M2
CARD_TOLERANCE tightened to 0 (other direction)
custom_formula.py
−1 B
16
M3
units becomes optional
custom_formula.py
+10 B
1
M4
the factor == 0 rung removed
custom_formula.py
−8 B
1
M5
the factor < 0 rung removed
custom_formula.py
−7 B
1
M6
the vat_pct band widened to ±1000
custom_formula.py
+6 B
2
M7
the non-finite rung removed
custom_formula.py
−26 B
2
M8
the non-numeric rung removed
custom_formula.py
−16 B
4
M9
an unknown field ignored instead of refused
custom_formula.py
−2 B
1
M10
plausibility floor on the all-in figure instead of the energy one
custom_formula.py
+0 B
2
M11
hourly stops averaging
custom_formula.py
+12 B
3
M12
the hourly bucket takes the FIRST quarter, not the mean
custom_formula.py
−11 B
1
M13
the preview drops the index column from each row
custom_formula.py
−13 B
1
M14
the preview emitted on every refresh, not once
price.py
−8 B
1
M15
custom_formula declares ALL_IN
price.py
−41 B
10
M16
a stale card never warns
custom_formula.py
−8 B
1
M17
a stale card REFUSES instead of warning
validate.py
−3 B
1
M18
the empty-formula refusal removed from the gate
validate.py
−38 B
1
M19
the index scaled to EUR/kWh before the formula
price.py
+2 B
7
M20
an eval planted in app/custom_formula.py
custom_formula.py
+14 B
1
M21
custom_formula stops passing the caller's range
price.py
+0 B
1
M22
PAIRED: M1 and the plausibility band removed together
custom_formula.py
−60 B
12
Four worth reading:
M13 was a −13 B SURVIVOR of the whole suite on the first attempt. The check read "index EUR/MWh -> all-in EUR/kWh" in log — the preview's heading. Deleting the index column from every row left the heading describing something it no longer printed. MUTATION.md's third failure mode, in the check written to close it. It grades a rendered row now (' 216.080 -> 0.30123'), built from the captured document.
M10 is the real defect the battery found (see above), +0 B.
M15 is the one no code can catch: declaring ALL_IN makes assemble() add nothing and section 3's all-in check is derived from the declaration, so it passes on the lie — exactly as price.py's trap 1 says. What goes red is the docstring/declaration agreement check, the card reproductions and the sources pin. The guarantee at its true size.
M22 is the paired run (TEST-16): the card check and the band both answer a unit error, so they were weakened together. 12 reds against M1's 8, and the band's own checks stay red — neither rung was doing the other's work.
Both +0 B entries are called out as the reporting rule asks. The :ro mount makes stale bytecode structurally impossible here, and the host cache was purged before each run regardless.
Also in this change
The index path is generic_dayahead's, not a second one. The override/range decision moved into one shared dayahead_feed_url() that both plugins call — two copies would be two places for the short-horizon defect TARIFF-09 fixed to come back in only one of them. Same feed, same Brussels-calendar request, same columnar parser, same declared egress hosts. custom_formula is replayed from the same three committed captures, and the URL it asks for is asserted equal to generic_dayahead's.
resolution: hourly averages each hour's members. ⚠️ Not _Series.at()'s hourly assumption returning (TARIFF-14): it decides how many points are emitted; the window each covers is still derived from its neighbours in _cover_table(), and an already-hourly feed averages one member per hour — the identity — so it cannot assume the granularity it was handed. Graded, both halves.
A startup preview logs the next 24 h as index EUR/MWh -> all-in EUR/kWh with min/median/max and the spread, through a new default-silentPriceProvider.startup_preview() hook (only the plugin knows what went in to its own arithmetic; the framework hands back the assembled result rather than the tax rate a plugin may never read). Once per process, and a preview that raises cannot cost the site its prices — graded.
custom_formula joins the config.yaml enum through the DERIVED setPROVIDERS | RETIRED_PROVIDERS | {"off"}, not a hand-typed list. The three retired ids stay in the enum for TARIFF-07's reason.
Both translations describe the option and the new id. DOCS.md replaces the "planned" sentence with the shape, a field table, a worked example from a real card, a real startup transcript (generated from the committed capture, not illustrated) and the limit of the check beside the claim. CHANGELOG.md under ## Unreleased, no version number — the owner's decision, per PKG-06/07.
Stale claims elsewhere corrected rather than left: export_price_eur_kwh's docstring said nothing in the add-on can evaluate B × spot + A (now it can, but this provider is a consumption one and pointing an injection scalar at it would be the wrong-card defect); "both shipped plugins are day-ahead" → three; the option count 71 → 72 in the five places that state it in the present tense (the DECISIONS and CHANGELOG occurrences are historical records and were left).
Not in scope, each a card of its own
The injection leg's own coefficients (Eneco 0,102 consumption vs 0,100 injection, VAT-exempt against 6 %); the hour-varying network charge that bites Wallonia; raising price_taxes_eur_kwh from 0.06 to ~0.12.
Checks
All 29 suites green (suites=29 failures=0), 347 checks in test_price.py alone. Nothing was deployed: no ha addons update, no writes to /addons/, no contact with 192.168.2.158 or the inverter.
⚠️ One thing a reviewer should look at: test_price.py's summary block (if fails: … sys.exit(1)) had to be moved to the end of the file, because section 15 was appended after it — which would have left every new check's failure unreported and the suite exiting 0. That is fixed and the file now exits non-zero on a section-15 failure (verified by every mutation above returning rc=1), but it is the kind of thing worth a second pair of eyes.
## What this is
A Belgian dynamic contract prices energy as **`B × 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 JavaScript-only wizard, the annual report carries the supplier list without per-product coefficients, VREG was unreachable, and every coefficient found in research came off a supplier's own PDF, one of them image-only. So nothing can be scraped, and a per-supplier plugin would have **no source for its numbers** — which is exactly why TARIFF-07 withdrew three of them. The customer is the only party who can see their own coefficients; they type them, once.
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`.
```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}}
```
`price = (factor × index_eur_per_mwh + offset) × (1 + vat_pct/100)`, in `units`, **converted to EUR/kWh by the add-on**. The operator types what the card prints, in the card's own units; every unit conversion and the VAT multiplication are the add-on's job, because those are the two steps that silently cost a factor of 10, 100 or 1.06.
## The three real tariffs, reproduced through the shipped code
Both suppliers whose 2026 cards were readable print the formula, the reference index they assumed **and** the resulting price — so the expectation is somebody else's published number rather than a restatement of our own arithmetic.
| | computed by the shipped module | the card prints |
|---|---|---|
| Eneco `(0.102 × 133.9905 + 1) × 1.06` | **15.54705286** | **15,55** |
| ENGIE `(0.1 × 61.96 + 0.9) × 1.06` | **7.52176** | **7,522** |
Both are then run **end to end through `load_prices()`** — registered plugin, columnar parser, `assemble()` — and come out at `0.212471` and `0.132218` EUR/kWh all-in, which is the card's printed price plus `price_taxes_eur_kwh` and nothing else (`test_price.py` 15a).
⚠️ **The ticket asks for three and only two exist.** Two cards were readable and both are here. The third real formula in the repository — Eneco's injection leg `0,1 × BELPEX-H − 1,188` — has **no printed estimate beside it**, so there is nothing to grade it against and it could not be reproduced honestly. It is out of scope anyway (separate card, own coefficients, VAT-exempt), and the third reproduction is instead the **96-point captured real body** run through the card's arithmetic point by point, against the document's own index column.
## The double-counting mechanism, which is the hardest part
`custom_formula` declares **`{ENERGY, MARKUP}`, not `ALL_IN`**. 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 the formula excludes network, distribution, transport, grid levies and excise. `ALL_IN` would lie on behalf of every operator who typed exactly what their card says and would drop the taxes from every hour: **0.2125 → 0.1555 EUR/kWh, measured**.
The framework already mechanises that a *plugin* cannot double-count (`PluginOptions`, `FRAMEWORK_KEYS`, `code_leaks()`). This introduces a risk none of those can see, **because it is config and not code**: the **operator** double-counting, by folding network charges into `offset` while `price_taxes_eur_kwh` carries them too.
**So the card grades it.** `validate.py` recomputes the card's own printed price from the operator's coefficients at startup and refuses to start unless they agree to within `0.01`. An inflated `offset` cannot pass: ~12 c€/kWh of network charge moves the computed 15.55 to **28.27**.
### The refusal, verbatim
> `price_formula does not reproduce its own card, so the add-on will NOT start on it. Your contract's arithmetic is price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100); on the card's own index that is (0.102 x 133.9905 + 13) x (1 + 6/100) = 28.2671 c_eur_per_kwh, but card.expect_incl_vat says 15.55 - OUT BY +12.7171 c_eur_per_kwh (ratio 1.818; the tolerance is 0.01, which is the last digit a card prints). One of the two sides is wrong and the add-on cannot tell which, so it refuses rather than price a battery on it. The usual causes, in the order they happen: the coefficients and the expected price are in DIFFERENT units (ratio 10, 100 or 1000 - `units` must be the one the formula line is printed in); VAT left out or applied twice (ratio 1.06 or 1/1.06); a transposed digit; the two sides copied off DIFFERENT cards or different months; or - the expensive one - network charges, levies or excise folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian card's Tariefformule line is the ENERGY component alone (Eneco tabulates network tariffs and levies separately; ENGIE says in so many words that the formula excludes network, distribution, transport, levies and excise), the add-on adds price_taxes_eur_kwh on top of it, and folding ~12 c EUR/kWh of network charge into `offset` bills you for it twice. That is the error this check exists to catch, and it is why it cannot be switched off.`
### ⚠️ 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 — both numbers off the wrong product — reproduces its own printed price perfectly and passes. Nothing can catch that; that sentence is in `price.py`'s house style, in the module docstring, in `validate.py`, in `config.yaml`, in both translations and in DOCS.md, beside the claim each time.
A **second** refusal covers the one unit error the first cannot see — the one applied to *both* sides at once, which cancels out of a comparison between them: 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**, because the shipped `price_taxes_eur_kwh` of 0.06 is three times that floor and would carry a hundredfold error over it on its own (0.0016 → 0.0616 EUR/kWh, inside the band). **That was found by writing the mutation, not by review** — M10, `+0 B` — and it is a deliberate deviation from the ticket's "median all-in" wording, stated in the code and in DECISIONS.
## No expression evaluation of any kind, and how that is checked
Guaranteed **structurally**, in three places rather than by a rule:
1. `parse_price_formula()` accepts only **numbers** for `factor`/`offset`/`vat_pct` — a string is refused as *not a number*, so `"0.1*x"` never becomes anything;
2. an **unknown field is refused, not ignored**, so `{"expr": "..."}` is told the field does not exist instead of being priced on the fields beside it — this is the half that keeps "future shapes become named fields, never a grammar" honest;
3. there is no evaluator anywhere in the module to be reached.
**Checked** by `test_price.py` 15f, which walks the **AST** of `app/custom_formula.py` and of the plugin class for calls to `eval`/`exec`/`compile`/`__import__`/`literal_eval` — a tree walk rather than a grep, which is load-bearing here: the module's own docstring contains the text `eval(s, {"__builtins__": {}})` while explaining why that is not a sandbox, and a text scan would fire on the documentation it also mandates (`code_leaks()`'s lesson). The check's **red direction is exercised on every run** against planted `eval` and `literal_eval` calls, and M20 plants a real one in the shipped module and turns it red.
The deciding argument is in the code and in DECISIONS: it is **not** security. `eval` on a string settable over HTTP in a root container holding `SUPERVISOR_TOKEN` and driving an inverter over RS485 is bad enough — but **an expression is unverifiable**. Two coefficients can be graded against the card's printed price; an arbitrary expression cannot be graded against anything, so it would make the invisible-wrong-coefficient problem *strictly worse*, which is the exact failure this provider exists to prevent.
## Mutations — 22, one per check added, no survivors
Harness `/home/homelab/.cache/t15-mut/run.py`, backups beside it, `__pycache__` purged on the host before every run, graded through the CI image with the tree bind-mounted `:ro` so no cache can be written into it at all. Full ledger in `MUTATION.md`.
| | mutation | file | Δ | red |
|---|---|---|---|---|
| M1 | `CARD_TOLERANCE` widened to 100 | `custom_formula.py` | +1 B | **8** |
| M2 | `CARD_TOLERANCE` tightened to 0 (other direction) | `custom_formula.py` | −1 B | **16** |
| M3 | `units` becomes optional | `custom_formula.py` | +10 B | 1 |
| M4 | the `factor == 0` rung removed | `custom_formula.py` | −8 B | 1 |
| M5 | the `factor < 0` rung removed | `custom_formula.py` | −7 B | 1 |
| M6 | the `vat_pct` band widened to ±1000 | `custom_formula.py` | +6 B | 2 |
| M7 | the non-finite rung removed | `custom_formula.py` | −26 B | 2 |
| M8 | the non-numeric rung removed | `custom_formula.py` | −16 B | 4 |
| M9 | an unknown field ignored instead of refused | `custom_formula.py` | −2 B | 1 |
| M10 | plausibility **floor** on the all-in figure instead of the energy one | `custom_formula.py` | **+0 B** | 2 |
| M11 | `hourly` stops averaging | `custom_formula.py` | +12 B | 3 |
| M12 | the hourly bucket takes the FIRST quarter, not the mean | `custom_formula.py` | −11 B | 1 |
| M13 | the preview drops the index column from each row | `custom_formula.py` | −13 B | 1 |
| M14 | the preview emitted on every refresh, not once | `price.py` | −8 B | 1 |
| M15 | `custom_formula` declares `ALL_IN` | `price.py` | −41 B | **10** |
| M16 | a stale card never warns | `custom_formula.py` | −8 B | 1 |
| M17 | a stale card REFUSES instead of warning | `validate.py` | −3 B | 1 |
| M18 | the empty-formula refusal removed from the gate | `validate.py` | −38 B | 1 |
| M19 | the index scaled to EUR/kWh before the formula | `price.py` | +2 B | **7** |
| M20 | an `eval` planted in `app/custom_formula.py` | `custom_formula.py` | +14 B | 1 |
| M21 | `custom_formula` stops passing the caller's range | `price.py` | **+0 B** | 1 |
| M22 | **PAIRED**: M1 *and* the plausibility band removed together | `custom_formula.py` | −60 B | **12** |
Four worth reading:
* **M13 was a `−13 B` SURVIVOR of the whole suite on the first attempt.** The check read `"index EUR/MWh -> all-in EUR/kWh" in log` — the preview's **heading**. Deleting the index column from every **row** left the heading describing something it no longer printed. MUTATION.md's third failure mode, in the check written to close it. It grades a rendered row now (`' 216.080 -> 0.30123'`), built from the captured document.
* **M10** is the real defect the battery found (see above), `+0 B`.
* **M15** is the one no code can catch: declaring `ALL_IN` makes `assemble()` add nothing and section 3's all-in check is *derived from the declaration*, so it passes on the lie — exactly as `price.py`'s trap 1 says. What goes red is the docstring/declaration **agreement** check, the card reproductions and the `sources` pin. The guarantee at its true size.
* **M22** is the paired run (TEST-16): the card check and the band both answer a unit error, so they were weakened together. 12 reds against M1's 8, and the band's own checks stay red — neither rung was doing the other's work.
Both `+0 B` entries are called out as the reporting rule asks. The `:ro` mount makes stale bytecode structurally impossible here, and the host cache was purged before each run regardless.
## Also in this change
* **The index path is `generic_dayahead`'s, not a second one.** The override/range decision moved into one shared `dayahead_feed_url()` that both plugins call — two copies would be two places for the short-horizon defect TARIFF-09 fixed to come back in only one of them. Same feed, same Brussels-calendar request, same columnar parser, same declared egress hosts. `custom_formula` is replayed from the same three committed captures, and the URL it asks for is asserted equal to `generic_dayahead`'s.
* **`resolution: hourly`** averages each hour's members. ⚠️ Not `_Series.at()`'s hourly assumption returning (TARIFF-14): it decides how many points are *emitted*; the window each covers is still derived from its neighbours in `_cover_table()`, and an already-hourly feed averages one member per hour — the identity — so it cannot assume the granularity it was handed. Graded, both halves.
* **A startup preview** logs the next 24 h as `index EUR/MWh -> all-in EUR/kWh` with min/median/max and the spread, through a new **default-silent** `PriceProvider.startup_preview()` hook (only the plugin knows what went *in* to its own arithmetic; the framework hands back the assembled result rather than the tax rate a plugin may never read). Once per process, and a preview that raises cannot cost the site its prices — graded.
* **`custom_formula` joins the `config.yaml` enum through the DERIVED set** `PROVIDERS | RETIRED_PROVIDERS | {"off"}`, not a hand-typed list. The three retired ids stay in the enum for TARIFF-07's reason.
* Both translations describe the option and the new id. `DOCS.md` replaces the "planned" sentence with the shape, a field table, a worked example from a real card, a **real** startup transcript (generated from the committed capture, not illustrated) and the limit of the check beside the claim. `CHANGELOG.md` under `## Unreleased`, **no version number** — the owner's decision, per PKG-06/07.
* Stale claims elsewhere corrected rather than left: `export_price_eur_kwh`'s docstring said nothing in the add-on can evaluate `B × spot + A` (now it can, but this provider is a *consumption* one and pointing an injection scalar at it would be the wrong-card defect); "both shipped plugins are day-ahead" → three; the option count 71 → 72 in the five places that state it in the present tense (the DECISIONS and CHANGELOG occurrences are historical records and were left).
## Not in scope, each a card of its own
The injection leg's own coefficients (Eneco 0,102 consumption vs 0,100 injection, VAT-exempt against 6 %); the hour-varying network charge that bites Wallonia; raising `price_taxes_eur_kwh` from 0.06 to ~0.12.
## Checks
**All 29 suites green** (`suites=29 failures=0`), 347 checks in `test_price.py` alone. Nothing was deployed: no `ha addons update`, no writes to `/addons/`, no contact with 192.168.2.158 or the inverter.
⚠️ One thing a reviewer should look at: `test_price.py`'s summary block (`if fails: … sys.exit(1)`) had to be **moved to the end of the file**, because section 15 was appended after it — which would have left every new check's failure unreported and the suite exiting 0. That is fixed and the file now exits non-zero on a section-15 failure (verified by every mutation above returning `rc=1`), but it is the kind of thing worth a second pair of eyes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
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
admin
merged commit 068b83f57c into release/1.02026-09-13 03:58:46 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What this is
A Belgian dynamic contract prices energy as
B × 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 JavaScript-only wizard, the annual report carries the supplier list without per-product coefficients, VREG was unreachable, and every coefficient found in research came off a supplier's own PDF, one of them image-only. So nothing can be scraped, and a per-supplier plugin would have no source for its numbers — which is exactly why TARIFF-07 withdrew three of them. The customer is the only party who can see their own coefficients; they type them, once.New provider
custom_formula, one new optionprice_formula(a JSON string — thestatic_profileprecedent exactly), and a new moduleapp/custom_formula.py.price = (factor × index_eur_per_mwh + offset) × (1 + vat_pct/100), inunits, converted to EUR/kWh by the add-on. The operator types what the card prints, in the card's own units; every unit conversion and the VAT multiplication are the add-on's job, because those are the two steps that silently cost a factor of 10, 100 or 1.06.The three real tariffs, reproduced through the shipped code
Both suppliers whose 2026 cards were readable print the formula, the reference index they assumed and the resulting price — so the expectation is somebody else's published number rather than a restatement of our own arithmetic.
(0.102 × 133.9905 + 1) × 1.06(0.1 × 61.96 + 0.9) × 1.06Both are then run end to end through
load_prices()— registered plugin, columnar parser,assemble()— and come out at0.212471and0.132218EUR/kWh all-in, which is the card's printed price plusprice_taxes_eur_kwhand nothing else (test_price.py15a).⚠️ The ticket asks for three and only two exist. Two cards were readable and both are here. The third real formula in the repository — Eneco's injection leg
0,1 × BELPEX-H − 1,188— has no printed estimate beside it, so there is nothing to grade it against and it could not be reproduced honestly. It is out of scope anyway (separate card, own coefficients, VAT-exempt), and the third reproduction is instead the 96-point captured real body run through the card's arithmetic point by point, against the document's own index column.The double-counting mechanism, which is the hardest part
custom_formuladeclares{ENERGY, MARKUP}, notALL_IN. A Belgian card'sTariefformuleline is the energy component alone — Eneco tabulates network tariffs in one table and levies in a second; ENGIE states the formula excludes network, distribution, transport, grid levies and excise.ALL_INwould lie on behalf of every operator who typed exactly what their card says and would drop the taxes from every hour: 0.2125 → 0.1555 EUR/kWh, measured.The framework already mechanises that a plugin cannot double-count (
PluginOptions,FRAMEWORK_KEYS,code_leaks()). This introduces a risk none of those can see, because it is config and not code: the operator double-counting, by folding network charges intooffsetwhileprice_taxes_eur_kwhcarries them too.So the card grades it.
validate.pyrecomputes the card's own printed price from the operator's coefficients at startup and refuses to start unless they agree to within0.01. An inflatedoffsetcannot pass: ~12 c€/kWh of network charge moves the computed 15.55 to 28.27.The refusal, verbatim
⚠️ 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 — both numbers off the wrong product — reproduces its own printed price perfectly and passes. Nothing can catch that; that sentence is in
price.py's house style, in the module docstring, invalidate.py, inconfig.yaml, in both translations and in DOCS.md, beside the claim each time.A second refusal covers the one unit error the first cannot see — the one applied to both sides at once, which cancels out of a comparison between them: 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, because the shipped
price_taxes_eur_kwhof 0.06 is three times that floor and would carry a hundredfold error over it on its own (0.0016 → 0.0616 EUR/kWh, inside the band). That was found by writing the mutation, not by review — M10,+0 B— and it is a deliberate deviation from the ticket's "median all-in" wording, stated in the code and in DECISIONS.No expression evaluation of any kind, and how that is checked
Guaranteed structurally, in three places rather than by a rule:
parse_price_formula()accepts only numbers forfactor/offset/vat_pct— a string is refused as not a number, so"0.1*x"never becomes anything;{"expr": "..."}is told the field does not exist instead of being priced on the fields beside it — this is the half that keeps "future shapes become named fields, never a grammar" honest;Checked by
test_price.py15f, which walks the AST ofapp/custom_formula.pyand of the plugin class for calls toeval/exec/compile/__import__/literal_eval— a tree walk rather than a grep, which is load-bearing here: the module's own docstring contains the texteval(s, {"__builtins__": {}})while explaining why that is not a sandbox, and a text scan would fire on the documentation it also mandates (code_leaks()'s lesson). The check's red direction is exercised on every run against plantedevalandliteral_evalcalls, and M20 plants a real one in the shipped module and turns it red.The deciding argument is in the code and in DECISIONS: it is not security.
evalon a string settable over HTTP in a root container holdingSUPERVISOR_TOKENand driving an inverter over RS485 is bad enough — but an expression is unverifiable. Two coefficients can be graded against the card's printed price; an arbitrary expression cannot be graded against anything, so it would make the invisible-wrong-coefficient problem strictly worse, which is the exact failure this provider exists to prevent.Mutations — 22, one per check added, no survivors
Harness
/home/homelab/.cache/t15-mut/run.py, backups beside it,__pycache__purged on the host before every run, graded through the CI image with the tree bind-mounted:roso no cache can be written into it at all. Full ledger inMUTATION.md.CARD_TOLERANCEwidened to 100custom_formula.pyCARD_TOLERANCEtightened to 0 (other direction)custom_formula.pyunitsbecomes optionalcustom_formula.pyfactor == 0rung removedcustom_formula.pyfactor < 0rung removedcustom_formula.pyvat_pctband widened to ±1000custom_formula.pycustom_formula.pycustom_formula.pycustom_formula.pycustom_formula.pyhourlystops averagingcustom_formula.pycustom_formula.pycustom_formula.pyprice.pycustom_formuladeclaresALL_INprice.pycustom_formula.pyvalidate.pyvalidate.pyprice.pyevalplanted inapp/custom_formula.pycustom_formula.pycustom_formulastops passing the caller's rangeprice.pycustom_formula.pyFour worth reading:
−13 BSURVIVOR of the whole suite on the first attempt. The check read"index EUR/MWh -> all-in EUR/kWh" in log— the preview's heading. Deleting the index column from every row left the heading describing something it no longer printed. MUTATION.md's third failure mode, in the check written to close it. It grades a rendered row now (' 216.080 -> 0.30123'), built from the captured document.+0 B.ALL_INmakesassemble()add nothing and section 3's all-in check is derived from the declaration, so it passes on the lie — exactly asprice.py's trap 1 says. What goes red is the docstring/declaration agreement check, the card reproductions and thesourcespin. The guarantee at its true size.Both
+0 Bentries are called out as the reporting rule asks. The:romount makes stale bytecode structurally impossible here, and the host cache was purged before each run regardless.Also in this change
generic_dayahead's, not a second one. The override/range decision moved into one shareddayahead_feed_url()that both plugins call — two copies would be two places for the short-horizon defect TARIFF-09 fixed to come back in only one of them. Same feed, same Brussels-calendar request, same columnar parser, same declared egress hosts.custom_formulais replayed from the same three committed captures, and the URL it asks for is asserted equal togeneric_dayahead's.resolution: hourlyaverages each hour's members. ⚠️ Not_Series.at()'s hourly assumption returning (TARIFF-14): it decides how many points are emitted; the window each covers is still derived from its neighbours in_cover_table(), and an already-hourly feed averages one member per hour — the identity — so it cannot assume the granularity it was handed. Graded, both halves.index EUR/MWh -> all-in EUR/kWhwith min/median/max and the spread, through a new default-silentPriceProvider.startup_preview()hook (only the plugin knows what went in to its own arithmetic; the framework hands back the assembled result rather than the tax rate a plugin may never read). Once per process, and a preview that raises cannot cost the site its prices — graded.custom_formulajoins theconfig.yamlenum through the DERIVED setPROVIDERS | RETIRED_PROVIDERS | {"off"}, not a hand-typed list. The three retired ids stay in the enum for TARIFF-07's reason.DOCS.mdreplaces the "planned" sentence with the shape, a field table, a worked example from a real card, a real startup transcript (generated from the committed capture, not illustrated) and the limit of the check beside the claim.CHANGELOG.mdunder## Unreleased, no version number — the owner's decision, per PKG-06/07.export_price_eur_kwh's docstring said nothing in the add-on can evaluateB × spot + A(now it can, but this provider is a consumption one and pointing an injection scalar at it would be the wrong-card defect); "both shipped plugins are day-ahead" → three; the option count 71 → 72 in the five places that state it in the present tense (the DECISIONS and CHANGELOG occurrences are historical records and were left).Not in scope, each a card of its own
The injection leg's own coefficients (Eneco 0,102 consumption vs 0,100 injection, VAT-exempt against 6 %); the hour-varying network charge that bites Wallonia; raising
price_taxes_eur_kwhfrom 0.06 to ~0.12.Checks
All 29 suites green (
suites=29 failures=0), 347 checks intest_price.pyalone. Nothing was deployed: noha addons update, no writes to/addons/, no contact with 192.168.2.158 or the inverter.⚠️ One thing a reviewer should look at:
test_price.py's summary block (if fails: … sys.exit(1)) had to be moved to the end of the file, because section 15 was appended after it — which would have left every new check's failure unreported and the suite exiting 0. That is fixed and the file now exits non-zero on a section-15 failure (verified by every mutation above returningrc=1), but it is the kind of thing worth a second pair of eyes.🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
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