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
12 KiB
TARIFF-07 — three plugins that never fetched a price, and the one mechanism they leave behind
Status: DECIDED 2026-09-13 — withdraw engie_dynamic,
totalenergies_dynamic and eneco_eddy. Keep PriceProduct.REALTIME and the
widening machinery. Refuse a retired id by name rather than aliasing it.
Filed with TARIFF-07. Follows TARIFF-08, which found the same class of defect one layer down — a parser graded against its own guess — and left the question of these three open.
1. The endpoints, verified rather than taken on report
The card said the three endpoints were dead. They were re-checked by hand on 2026-09-13 before anything was deleted, because "the endpoint is dead" is exactly the kind of claim this repository keeps finding to be second-hand:
| id | endpoint | observed |
|---|---|---|
engie_dynamic |
https://api.engie.be/dynamic/be |
HTTP 404, body {"message":"no Route matched with those values","request_id":"..."} — a Kong gateway with no route configured |
totalenergies_dynamic |
https://api.totalenergies.be/prices/dynamic |
TLS failure. The handshake completes and the server presents CN=pge-apigee-dev.direct-energie.com; api.totalenergies.be is not in the SAN list, so the connection is closed before any request is sent |
eneco_eddy |
https://api.eneco.be/eddy/prices |
HTTP 404, content-length: 1, server: Microsoft-IIS/10.0 — an empty body from a live host that does not serve this path |
All three failed. Had any one of them answered, this card would have stopped and asked, because an endpoint that answers changes the decision from "withdraw" to "fix the parser".
2. Why this is not "unfinished", and why no replacement can be written
An unfinished plugin has a right endpoint and a wrong parser. These have neither, and the reason is structural:
- ENGIE's dynamic price is
(B × eSpot_15) − A, andBandAare PER-CONTRACT. They live behind the customer's klantenzone login. No unauthenticated public URL can return a given customer's price, so the endpoint this plugin wanted cannot exist for anybody to build. - There is no regulator index to read the coefficients from. CREG's tariff-card pages 404; VREG was unreachable. Every coefficient found in research came from a supplier's own PDF, which is a document, not a feed.
- "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. The plugin was wrong about the product, the product type and the URL simultaneously.
So the three were not withdrawn for being broken. They were withdrawn for describing tariffs that no public endpoint can serve.
3. What a site configured on one of these has actually been running
⚠️ This is the half that decides what the migration is. §11.4 turns a
failed fetch into a silent fall back: static profile, plan provisional, no
arbitrage. That is the correct degradation, and it says nothing at all about
the cause. Every fetch these three ever made raised SourceUnavailable.
Therefore a site configured on one of them has been running on
static_profile — or on nothing at all, where no profile was set — since
the day the id was entered, and nothing on its status page has ever differed
from a working provider that simply never found a cheap hour.
The migration is therefore not "keep their prices working". There were no prices. It is "tell them their prices never worked", which is why the answer is a refusal and not a fallback.
Why not a silent alias to generic_dayahead
It is the friendlier-looking option and it is wrong. These published retail
prices; generic_dayahead reads wholesale. Aliasing needs the site's own
B and A, which §2 has just established nobody has. An alias would have to
invent the numbers a battery is priced against — the same defect as a
parser graded against its own guess, one layer further from anything that
reports. The scalar price_markup_eur_kwh cannot stand in for B either: it
is an addend, not a multiplier, and it is float(0,1) so it cannot even hold a
negative A.
4. ⚠️ The config.yaml schema is deliberately WIDER than the registry
price_provider is a list(off|…) enum, and the Supervisor validates the
options file against it before the add-on starts.
So the obvious edit — delete the three ids from the enum along with the classes — has a consequence that runs exactly opposite to this card's purpose:
operator's config says price_provider: eneco_eddy
|
+-- id NOT in the schema enum
|
+-- Supervisor rejects the options file
|
+-- add-on never starts
|
+-- validate.py never runs
|
+-- the named refusal is NEVER SEEN
The operator gets an opaque expected one of ... from a layer they did not
configure, on exactly the sites the withdrawal exists to inform.
Decision: keep all three ids in the schema enum. PROVIDERS shrinks to
two; the enum stays at six. The cost is that the dropdown still offers three
dead ids to a new operator — and that is the cheaper failure, because
choosing one is refused at the next startup by name, with the endpoint, the
observation and the alternative, which is strictly more than a schema
rejection would say. Both translations label them withdrawn in the option
description.
app/price.py's RETIRED_PROVIDERS is the list. test_price.py section 1
asserts the schema offers exactly PROVIDERS | RETIRED_PROVIDERS | {"off"} —
derived, not typed — and register() refuses to re-register a retired id, so
the registry and the retired table cannot both claim one and the refusal cannot
be taken away by an @register somebody adds back.
5. ⚠️ PriceProduct.REALTIME and the widening path are KEPT with no shipped declarer
eneco_eddy was the sole declarer of PriceProduct.REALTIME. Deleting it
orphans:
_WIDE = frozenset({PriceProduct.REALTIME})Uncertainty.REALTIME_PRODUCTWIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT] = 2.5
They stay. The reasoning, stated here so the next reader finds intent rather than an apparent dead branch:
- A reviewer finding a mechanism with zero callers would reasonably delete it. That is good instinct applied to a missing fact, and this file is the fact.
- It is the AC2 seam. The whole point of
capabilities().productis that a new supplier is a new class and nothing else: declareREALTIME, get the wide regime, edit no framework code. Deleting the enum member would mean the first genuine imbalance product has to rebuild that seam — and rebuilding it is precisely the "editing the optimiser to add a supplier" that AC2 forbids. - It costs nothing. Two enum members and a dict entry.
⚠️ NO SHIPPED PLUGIN DECLARES PriceProduct.REALTIME UNTIL ONE DOES. Both
shipped plugins (generic_dayahead, manual_csv) are day-ahead. §9.2's row 2
is unoccupied. That is not the same as it having no mechanism, and the
difference is what this section exists to record.
It is pinned, not merely documented. test_price.py section 5 asserts:
- that no shipped plugin declares
REALTIME— so if one ever does, the check goes red and the right fix is to edit this record, not to delete the seam; - that the widening still works from a declaration alone —
volatility_regime,uncertainty_ofandwiden_factorare graded on aProviderCapabilitiesthe test file builds; - and section 10 registers
_NewSupplier, a class defined outsideprice.pythat declaresREALTIMEand is asserted to land in the wide regime. That class is now the only live declarer in the tree, which makes the seam something this suite exercises end to end rather than a constant it reads.
6. What else went, and one thing that did not
_points_from_json() went with the three. It read a {"data": [{...}]}
row-per-point document that no feed anybody here has ever reached
publishes — the shape was invented alongside these plugins, and the only
bodies ever fed to it were fixtures written from the same guess. TARIFF-08
found that loop on the response side for generic_dayahead; this is the rest
of it. needs_credentials went too: the three were its only setters and
nothing read it.
The shape survives in one place and in one direction: row_shape_body() in
test_price.py, feeding generic_dayahead so that section 9 grades a row
document as a failure for it rather than a second dialect it might sniff.
It is named for what it is.
⚠️ One check had to be rebuilt rather than deleted, and saying so matters.
test_price.py section 4b proved that code_leaks()'s docstring strip is
load-bearing by finding a shipped docstring that a raw text scan would
false-positive on. The docstring that did that was engie_dynamic's — it
spelled price_markup_eur_kwh in full. With it gone, no shipped docstring
spells a full framework key, so the check would have passed on a tree with
the strip deleted: a check that had quietly stopped being one, which is the
third failure mode in MUTATION.md. The stimulus is now constructed in the
test file (_DocumentedPlugin), and the shipped set is asserted clean
separately.
7. Mutations driven
Every check added or rebuilt here was driven red. Harness:
/home/homelab/.cache/t07-mut/, which rebuilds the tree from the worktree and
purges __pycache__ per MUTATION.md's rule.
| mutation | Δ | red on | |
|---|---|---|---|
| M1 | delete the retired branch from _price_problems() |
−2785 B | 7 checks in test_price.py |
| M2 | move the retired branch after the unknown-id branch | +0 B | the same 7 |
| M3 | make been_running constant (drop the empty-static_profile case) |
−212 B | 2 |
| M4 | drop the "WHAT TO DO" half of the refusal | −874 B | 1 |
| M5 | RETIRED_PROVIDERS = {} |
−1099 B | 5, then a traceback |
| M6 | drop the three ids from config.yaml's enum — §4's trap |
−47 B | 2 |
| M7 | delete register()'s retired guard |
−1101 B | 2, then a traceback |
| M8 | _WIDE = frozenset() |
−23 B | 3 + 4 across two suites |
| M9 | WIDEN_FACTOR[REALTIME_PRODUCT] 2.5 → 1.5 |
+0 B | 1 + 1 |
⚠️ M2 and M9 have a byte delta of zero, which MUTATION.md names as the
risky case. The harness rebuilds the tree from source on every run and purges
__pycache__, so there is no inherited cache to read; and stale bytecode
manufactures survivors, never reds, so a red from a +0 B mutation is not
at risk from that defect. M2's grep confirmed the branch had moved on disk.
⚠️ M5 and M7's red counts are LOWER BOUNDS. Both runs ended in a traceback
with checks after it ungraded — FCST-06's rule: read the output, not the count.
M7's traceback is itself informative: with the guard gone the re-registration
succeeds, PROVIDERS is polluted with a class that has no fixture, and the
suite dies downstream. That is the failure the guard prevents, arriving loudly.
8. What this card did not do
- It did not pick a version number. The change is backward-incompatible — a
config legal on the current version is refused on the next — and the
CHANGELOG says so prominently, in the shape PKG-07 established for
slew_w. Which number it belongs to depends on whether the current image has already been built and pulled, which is a fact about the live box that this branch cannot observe. Whoever deploys decides. - It did not implement the formula provider.
B × spot + Awith per-site coefficients is the real answer for a Belgian dynamic customer and is a separate card.DOCS.mdreferences it as planned and is explicit about whatgeneric_dayaheadcan and cannot express in the meantime — in particular that it cannot represent a negative offset, and thatprice_markup_eur_kwh: float(0,1)could not hold one anyway. - It did not rewrite released CHANGELOG history. The two historical entries
that called
eneco_eddyan imbalance product are annotated in place with a correction naming this card, rather than silently edited. The record of what the project believed is part of the record.