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
+70 -10
View File
@@ -118,8 +118,14 @@ NOT READY banner already names the problem, (b) does not apply.
It is warned about below, not refused.
7. A price configuration that cannot mean what it says. [(a)]
Three cases, all in `_price_problems()`: an unknown `price_provider` id
(refused rather than defaulted to 'off', per the §14 warning box);
Four cases, all in `_price_problems()`: an unknown `price_provider` id
(refused rather than defaulted to 'off', per the §14 warning box); a
RETIRED id - one this add-on really did ship and has been accepting ever
since, whose plugin never once fetched a price because its endpoint does
not exist (TARIFF-07), which is case (a) in its purest form: §11.4's
silent fall back to the static profile IS the layer the operator cannot
see, and it has been overriding their chosen source since the day they
set it;
`price_provider` set alongside `price_now_entity`/`price_avg_entity`,
which is TWO PRICE SOURCES ON ONE SITE where the Home Assistant pair
silently wins; and `manual_csv` with no `price_csv_path`, which turns the
@@ -322,7 +328,7 @@ from .inverter import roster_entries
from .maintenance import MAINT_OPTION_DEFAULTS
from .p1 import is_enabled, validate_meter_options
from .peakguard import PEAK_OPTION_DEFAULTS
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS
from .static_tariff import ProfileInvalid, parse_static_profile
from .watchdog import SIGN_OPTION_DEFAULTS
@@ -1242,17 +1248,71 @@ def _price_problems(opts: dict) -> list[str]:
without a price feed is an add-on that a Belgian fixed-tariff site - a large
share of the installed base, per §9.2 - cannot run at all.
Four refusals and two log lines (TARIFF-03 added one refusal, one parse and
two notices), and every refusal is still case (a) from this module's
docstring: a source the operator SET being overridden by a layer they cannot
see. The additions are `static_profile` alongside the Home Assistant price
pair - the same silent-loser shape as provider-plus-pair, and a LIVE one -
and a `static_profile` that does not tile the day, which would leave hours
with no price at all.
Five refusals and two log lines (TARIFF-03 added one refusal, one parse and
two notices; TARIFF-07 added the retired-id one), and every refusal is still
case (a) from this module's docstring: a source the operator SET being
overridden by a layer they cannot see. The additions are `static_profile`
alongside the Home Assistant price pair - the same silent-loser shape as
provider-plus-pair, and a LIVE one - and a `static_profile` that does not
tile the day, which would leave hours with no price at all.
⚠️ THE RETIRED-ID BRANCH IS NOT A FRIENDLIER SPELLING OF THE UNKNOWN-ID
ONE, AND MUST NOT COLLAPSE INTO IT. They answer different questions. An
unknown id is a TYPO: the operator meant something else and the fix is to
spell it right. A retired id is a CHOICE THE ADD-ON ACCEPTED - it was in
the schema, it was in the dropdown, it resolved to a class, and every
startup since said nothing. What that operator needs is not "this id is
wrong" but "this id has never once returned a price, and here is what your
site has actually been running instead" - because §11.4 made the failure
silent and NOTHING on their status page has ever distinguished it from a
working provider that never finds a cheap hour. The retired branch is
therefore FIRST, and it names the id, the endpoint, the observation, the
tariff that has really been in force, and the move to make.
"""
problems: list[str] = []
pid = str(opts.get("price_provider", "") or "off").strip() or "off"
if pid in RETIRED_PROVIDERS:
# ⚠️ BEFORE the unknown-id branch, and the order is load-bearing: a
# retired id is ALSO not in PROVIDERS, so the generic message would
# win on a reordering and the operator would be told they mistyped
# something they chose on purpose.
# ⚠️ AND THE SECOND SENTENCE CHANGES WITH `static_profile`, because
# "you have been on the static profile" is FALSE on a site that never
# set one. §11.4 falls back to the static profile; with no profile to
# fall back to, the site has had no tariff at all - a worse state, and
# one this message must not paper over.
been_running = (
f"the static day/night tariff in static_profile"
if str(opts.get("static_profile", "") or "").strip()
else "NOTHING AT ALL - static_profile is empty too, so there was "
"no tariff to fall back to either")
problems.append(
f"price_provider is {pid!r}, which WAS a shipped plugin and has "
f"been WITHDRAWN (TARIFF-07). This is not a typo and you did not "
f"mistype it - the add-on offered this id, accepted it, and has "
f"said nothing at every startup since. "
f"⚠️ IT HAS NEVER FETCHED A PRICE, HERE OR ANYWHERE: "
f"{RETIRED_PROVIDERS[pid]}. Every fetch it ever made ended in "
f"SourceUnavailable, and §11.4 turns that into a SILENT fall back "
f"- so what this site has actually been running since the day the "
f"id was set is {been_running}. Nothing you could see would have "
f"differed from a working provider that simply never found a cheap "
f"hour, which is why you are being told now rather than left on "
f"it. WHAT TO DO: set price_provider to 'generic_dayahead' for the "
f"wholesale EPEX BE day-ahead curve, to which the add-on adds your "
f"price_markup_eur_kwh and price_taxes_eur_kwh - that APPROXIMATES "
f"a Belgian dynamic tariff and cannot reproduce your supplier's "
f"B x spot + A formula, so the level will be off even when the "
f"shape is right; or 'manual_csv' to enter all-in prices you "
f"compute yourself; or 'off' to run on static_profile "
f"deliberately. There is deliberately NO alias to your old "
f"supplier: aliasing would need your contract's own two "
f"coefficients, which live behind your klantenzone login and are "
f"in no public index, so the add-on would have to invent the "
f"numbers it prices your battery against.")
return problems # nothing below can be judged against a dead id
if pid != "off" and pid not in PROVIDERS:
problems.append(
f"price_provider is {pid!r}, which is not a shipped plugin. The "