TARIFF-07: three plugins that never fetched a price are withdrawn, and a site configured on one is told so
checks / unit (pull_request) Successful in 7m12s

engie_dynamic, totalenergies_dynamic and eneco_eddy are removed from PROVIDERS
and from every surface that listed them. They did not stop working - they never
worked. Re-verified by hand on 2026-09-13 before deleting anything: 404 with
Kong's "no Route matched with those values"; a TLS handshake that fails because
api.totalenergies.be is absent from the certificate's SAN list; 404 with an
empty body. All three are also structurally impossible - the coefficients are
per-contract and behind a customer login, there is no regulator index, and
"Eddy" is not an Eneco product at all.

⚠️ BACKWARD-INCOMPATIBLE. A config legal on the current version is refused on
the next. That is the point of the change: §11.4 turned every failed fetch into
a silent fall back, so a site on one of these ids has been running on
static_profile - or on nothing - since the day it was set, with nothing on its
status page ever differing from a working provider that never found a cheap
hour. validate.py now refuses a retired id with a message naming the id, the
endpoint, what that endpoint answers, which tariff has really been in force,
and what to set instead. No version number is picked here; that depends on a
fact about the live box.

⚠️ config.yaml's price_provider enum deliberately KEEPS all three ids. The
Supervisor validates the options file against that enum before the add-on
starts, so removing them would make an existing config schema-invalid and the
refusal above would never be reached on exactly the sites that need it.
PROVIDERS is two; the enum stays six; test_price.py asserts the schema offers
exactly PROVIDERS | RETIRED_PROVIDERS | {"off"}, derived rather than typed, and
register() refuses to re-register a retired id.

PriceProduct.REALTIME, _WIDE, Uncertainty.REALTIME_PRODUCT and WIDEN_FACTOR's
2.5 are KEPT with no shipped declarer - it is the AC2 seam, and a reviewer
finding a mechanism with zero callers would reasonably delete it. Recorded in
DECISIONS/TARIFF-07-withdrawing-three-plugins.md and pinned by a check.

False claims corrected: every "Belgian imbalance product" claim about
eneco_eddy (DOCS.md, price_forecast.py, price_history.py, static_tariff.py,
FCST-04, CHANGELOG - the two released entries annotated in place rather than
rewritten); the quarter-hourly examples now cite Belgian day-ahead, which
really does settle per quarter since the 15-minute MTU; and
export_price_eur_kwh's "no shipped supplier publishes a dynamic injection
curve", which is false - Eneco publishes 0,1 x BELPEX-H - 1,188 and ENGIE
(B x eSpot_15) - A, same two-coefficient shape with its own coefficients and
its own VAT treatment, so the scalar is a simplification and not the market.

DOCS.md's provider table is two rows and now says honestly what a Belgian
dynamic customer should do today, including that generic_dayahead cannot
express B x spot + A and that price_markup_eur_kwh: float(0,1) could not hold a
negative offset. The formula provider is referenced as planned, not built.

_points_from_json() went with the plugins it served: it read a shape no feed
anybody here has ever reached publishes. The one place it survives is a
negative stimulus, named row_shape_body().

Nine mutations driven, all red; table in the decision record. 29/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:
2026-09-13 02:46:15 +02:00
co-authored by Claude Opus 5
parent 8d09b30f16
commit f620f7cf80
15 changed files with 1045 additions and 329 deletions
+95 -13
View File
@@ -1369,7 +1369,7 @@ does, and only after you have read why.
| option | meaning |
|---|---|
| `price_provider` | `off` (default) or one of the five plugins below |
| `price_provider` | `off` (default) or one of the two 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 |
@@ -1386,11 +1386,76 @@ 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 |
| `engie_dynamic` | day-ahead | energy + Engie's markup | taxes |
| `totalenergies_dynamic` | day-ahead | energy + TotalEnergies' markup | taxes |
| `eneco_eddy` | **real-time / imbalance** | everything, all-in | nothing |
| `manual_csv` | day-ahead | everything, all-in | nothing |
⚠️ **Three plugins were withdrawn in TARIFF-07, and if your configuration names
one of them, read this.** `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy` are gone. They did not stop working — **they never worked.** Each
pointed at an endpoint that does not exist and never did:
| id | its endpoint | what that endpoint actually answers |
|---|---|---|
| `engie_dynamic` | `https://api.engie.be/dynamic/be` | **404**, gateway error `no Route matched with those values` |
| `totalenergies_dynamic` | `https://api.totalenergies.be/prices/dynamic` | **TLS failure** — that hostname is not in the server's certificate |
| `eneco_eddy` | `https://api.eneco.be/eddy/prices` | **404**, empty body |
Every fetch any of them ever made failed, and a failed fetch falls back to the
static tariff **silently** — so a site set to one of these has been running on
`static_profile`, or on nothing at all, since the day it was configured, with
nothing on the status page saying so. The add-on now **refuses to start** on one
of these ids and tells you all of the above by name. That is deliberate: a
silent fallback is what hid this for as long as it lasted.
There is no automatic migration and there cannot be one. These were *retail*
tariffs of the form `B x spot + A` with **per-contract** coefficients that live
behind your supplier's customer login; there is no public index to read them
from. Any alias would have to invent the numbers your battery is priced against.
(`eneco_eddy` was doubly wrong: **"Eddy" is not an Eneco product at all** —
Eneco's dynamic product is *Zon & Wind Dynamisch*, and it is indexed to
Belpex/EPEX **day-ahead**, not to imbalance.)
#### So what should a Belgian dynamic-tariff customer do today?
**Honestly: `generic_dayahead` gets you close, and you should know exactly how
close.** It reads the wholesale EPEX BE day-ahead curve and adds your
`price_markup_eur_kwh` and `price_taxes_eur_kwh` as **flat** scalars. Your real
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:
* **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
*shape* of your curve is right but its *amplitude* is not — the peaks and
troughs the optimiser trades against are compressed relative to your bill.
* **No negative offset.** `A` is frequently **negative** on injection and can be
on consumption. `price_markup_eur_kwh` is `float(0,1)` in the schema — it
**cannot hold a negative number at all**, so that contract shape is currently
inexpressible rather than merely approximate.
What that means in practice: arbitrage decisions made on `generic_dayahead` are
made against a curve that is *correlated* with your bill, not equal to it. For
deciding **which hours** are cheap that is usually good enough; for deciding
**whether a cycle pays for itself** — which is what `cycle_cost_threshold_eur`
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:
1. **`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
contract's formula and hand the add-on the answers. Nothing is approximated,
but nothing is automatic either.
If you are on a **fixed** or **day/night** tariff — most Belgian households —
neither of those applies: use `static_profile` and leave `price_provider: off`.
You cannot get this wrong by editing the markup: each plugin *declares* what its
feed contains and the add-on adds only the rest. Set a markup under a plugin
that already carries one and it is **ignored, with a line in the log saying so**
@@ -1404,10 +1469,13 @@ whether a plugin described its supplier's feed truthfully — that is a claim
about somebody else's tariff, and no code can verify it. If you write a plugin,
the `sources` declaration is the part a reviewer has to read.
`eneco_eddy` is a real-time/imbalance product rather than a day-ahead one. It is
flagged as high-volatility, which widens arbitrage thresholds once the
optimiser lands. That comes from the plugin's own declaration, not from its
name.
**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
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`.
**`generic_dayahead` and the shape of the public feed.** It reads Fraunhofer
ISE's Energy-Charts API (`https://api.energy-charts.info/price?bzn=BE`, no key
@@ -1519,9 +1587,10 @@ applies to those hours, and asking the plan for one raises rather than
answering. An hour with no price is not a free hour, and the fill does not
invent a neighbour, a mean or a zero for it.
**A quarter-hourly feed keeps all four of its prints.** `eneco_eddy` settles
per quarter, and the fill decides *which whole hours need carrying* — never what
a published hour may contain. Every point the provider published inside the
**A quarter-hourly feed keeps all four of its prints.** Belgian day-ahead
settles per quarter — the 15-minute MTU took effect on 2025-10-01, so
`generic_dayahead` reads 96 points a day — and the fill decides *which whole
hours need carrying*, never what a published hour may contain. Every point the provider published inside the
horizon is passed through untouched; a carried point is added only for a whole
hour the feed did not print at all.
@@ -1571,8 +1640,21 @@ volume being full exactly as it applies to the volume being absent.
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
often near zero, which by itself strongly favours self-consumption over export.
It is a single number rather than a curve because no supported supplier
publishes a dynamic injection tariff; negative values are accepted.
Negative values are accepted — you can be paid to *not* export at midday in
summer.
⚠️ It is a single number rather than a curve, and the reason given here until
TARIFF-07 — *"no supported supplier publishes a dynamic injection tariff"* — was
**false**. Suppliers do. Eneco publishes `0,1 x BELPEX-H − 1,188` on the same
tariff card as its consumption formula (identical on the January and the
September 2026 cards), and ENGIE publishes one in the same shape,
`(B x eSpot_15) − A`. What is true is narrower: an injection curve is the **same
two-coefficient formula** as the consumption curve but with **its own**
coefficients and **its own VAT treatment** — injection is VAT-exempt where
consumption is at 6% — so it is a second per-contract formula rather than a copy
of the first, and the add-on cannot evaluate either one yet. Until the formula
provider ships, **this scalar is a simplification you enter by hand — an average
of what you are actually paid — and not a reflection of the market.**
**The old Home Assistant price sensors.** `price_now_entity` and
`price_avg_entity` still work and are unchanged: the add-on compares them and