Files
goodwe-addon/goodwe_controller/DECISIONS/TARIFF-07-withdrawing-three-plugins.md
T
adminandClaude Opus 5 f620f7cf80
checks / unit (pull_request) Successful in 7m12s
TARIFF-07: three plugins that never fetched a price are withdrawn, and a site configured on one is told so
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
2026-09-13 02:46:15 +02:00

231 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.