Files
goodwe-addon/goodwe_controller/DECISIONS/TARIFF-07-withdrawing-three-plugins.md
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

12 KiB
Raw Permalink Blame History

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.