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
231 lines
12 KiB
Markdown
231 lines
12 KiB
Markdown
# 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`, and `B` and `A` are
|
||
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_PRODUCT`
|
||
* `WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT] = 2.5`
|
||
|
||
**They stay.** The reasoning, stated here so the next reader finds intent
|
||
rather than an apparent dead branch:
|
||
|
||
1. 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.
|
||
2. It is the **AC2 seam**. The whole point of `capabilities().product` is that
|
||
a new supplier is a new class and nothing else: declare `REALTIME`, 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.
|
||
3. 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_of` and `widen_factor` are graded on a
|
||
`ProviderCapabilities` the test file builds;
|
||
* and section 10 registers `_NewSupplier`, a class defined **outside
|
||
`price.py`** that declares `REALTIME` and 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 + A` with per-site
|
||
coefficients is the real answer for a Belgian dynamic customer and is a
|
||
separate card. `DOCS.md` references it as planned and is explicit about what
|
||
`generic_dayahead` can and cannot express in the meantime — in particular
|
||
that it cannot represent a negative offset, and that
|
||
`price_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_eddy` an 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.
|