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
9.9 KiB
FCST-04 — what "yesterday" is, and what the fill is allowed to reach
Four decisions, recorded because each had a defensible alternative that a later reader would otherwise re-open from scratch.
1. The fill runs on a series read only. A dead feed still goes to §11.4.
fill_from_yesterday() accepts a no_data read and will happily build a
100 %-carried series from yesterday alone. resolve_tariff() does not let it:
a feed that published nothing still falls to _degrade() and §11.4's own row —
"No price data → fall back to the static profile, mark the plan provisional,
no arbitrage."
Why not prefer yesterday's real prices to a coarse day/night profile? Because it buys nothing the optimiser can use, and it silently replaces a fallback the operator configured.
Measured, not assumed — and stated at the reach it actually has:
- This one reaches control. A fully-carried series is 100 % provisional, so
TariffPlan.arbitrage()finds fewer than two firm hours and refuses on §8.3's firm-pair rung. That is the verdict the optimiser reads:arbitrage.pycallsplan.arbitrage(...)onfresh_tariff_plan(), and it is the only path by which a provider plan moves energy. The §11.4 static fallback refuses on the same rung, for the same reason. - This one does not, and saying so is the point.
TariffPlan.cheap_now()also returns False on a fully-carried series, becausefirm_points()is empty — but it has no production caller on the provider plan.self.cheapcomes fromstatic_status(), which builds its own static plan and callscheap_now()on that. So it is a property of the type, checked intest_static_tariff.py, and it is not evidence about the shipped control path. The conclusion below holds on the first bullet alone.
The two paths therefore differ only in which numbers the status page shows.
So the choice is between two identical control behaviours, and the tie-breaker
is that static_profile is a documented option an operator fills in for this
case. Overriding it for a cosmetic difference is a behaviour change nobody
asked for.
⚠️ The consequence, stated so it is not discovered later as a bug:
fill_from_yesterday()'s no_data branch is not reached from production.
It stays checked in test_price_forecast.py. Turning it on is a product
decision — it would want a rule about how old a carried profile may be before it
stops being offered at all — and not a wiring one.
2. "Yesterday" is a date, not the newest record.
newest() is the obvious accessor and it is the one that decays. An add-on down
from Friday to Monday would answer Friday's prices to a fill whose only
vocabulary is provisional: true — a flag that says "this is a guess", never
"this guess is three days old". Nothing downstream can tell the difference and
nothing reports it.
So yesterday(stamp) computes the Brussels date before stamp's Brussels date
and looks up that record. Anything else is no_data with a reason naming
both the date wanted and the dates held.
The cost is real and accepted: after an outage of more than a day, the first morning back has no fill. That is the honest state — there is no yesterday — and the horizon is short for one morning rather than wrong for several.
3. What is recorded is the provider's read, and the mechanism is ordering.
Feeding the filled series back into the store is how this rots: day 2 carries
day 1's guesses, day 3 carries day 2's carry of them, and after a week of a dead
feed the "yesterday" being carried is a fossil of the last real publication.
Every point still carries provisional=True, so the flag never lies — what
decays is the number, behind a flag that is telling the truth.
⚠️ No value-level check can catch that, and claiming one would be worse than claiming none. A carried point and a published-provisional point (a real-time product's current interval, §9.2) are the same three fields carrying the same flag. The difference is provenance, and provenance is not in the value.
The defence is therefore an ordering, not a validation:
carry()is the only public entry point. It records its argument, then returns the fill.- The filled series exists only as that return value. The object never sees it again.
_remember()is private and called from exactly one place.test_price_history.pyparses every shipped module underapp/,tools/,scenarios/andestop/and fails if any of them uses the name_remember— an attribute, a bare name, adef, or the string constant agetattrwould need. By name rather than by substring, so a comment that merely mentions the rule does not fail it and a call intools/cannot pass unseen.- and it pins the order two ways: the line numbers of the two calls in
carry(), and — the one that matters — the behaviour. A feed carrying yesterday alongside today, into an empty store, must fill the whole horizon; reversed, the lookup finds nothing and the plan is only the hours the feed published. Driven, so a reversal is red.
⚠️ The first version of that ordering check graded nothing, and this file
said it did. It compared positions in ast.walk, which is breadth-first:
self._remember(...) is a statement and self.yesterday(...) is an argument
of fill_from_yesterday(...), one level deeper, so _remember came out first
whichever statement was written first. Measured — shipped and reversed both
scored the same. A decorative check that a document cites as a guarantee is
worse than no check, which is why the correction is recorded here rather than
quietly made.
That ordering has a second benefit worth keeping: a feed that returns yesterday alongside today refreshes yesterday's record from live data before it is consumed as the profile.
What this ticket does not claim
It does not make a trade possible that yesterday's prices justify. Carried hours are dropped from the trade set by §8.3, and that is not a limitation to be fixed later — it is the rule.
The one change that does move energy came from the windowing, not the
carrying: the filled series covers [since, until), so hours that have already
elapsed are no longer in the spread. A cheap night that has gone used to be the
series' cheapest hour, and a cycle cannot buy in an hour that is past — so the
trade was silently never taken. test_price_history.py section 2 grades that by
finding the cheapest firm hour with and without the fill and showing it moves.
⚠️ And section 1's identity check now grades identity. It used to compare
eur_per_kwh between _before and _after, which come from two separate
resolve_tariff() calls — each parsing the feed body into its own objects, so
is could never have held between them however the fill were written. FCST-03's
whole anti-copy argument is filled.points[i] is fetched.points[j], and floats
cannot grade it. The value comparison is kept and now says what it is (the fill
moved the window, it did not touch a price); identity is graded on a read the
file constructs and still holds a reference to.
4. A sub-hourly feed passes through whole; the fill only decides which hours are missing.
⚠️ This section originally said eneco_eddy is "the Belgian imbalance
product". That was false and TARIFF-07 withdrew the plugin: Eneco's dynamic
product is "Zon & Wind Dynamisch" and is indexed to Belpex/EPEX day-ahead,
not to imbalance, and api.eneco.be/eddy/prices never existed. The mechanism
below is unaffected, because the real quarter-hourly feed is the one still
shipped.
Belgian day-ahead settles per quarter — the 15-minute MTU took effect
on 2025-10-01, so generic_dayahead reads 96 points a day rather than 24 — and
price.py's _validate_raw() accepts any strictly-increasing spacing, so a
published hour may legitimately hold four points.
FCST-04's first caller keyed the published set as {_utc_hour(p.start): p for p in r.points}. It reads perfectly ordinary and it keeps the last point of
each hour. Measured on a 96-point quarter-hourly feed — all firm, fully
published, nothing to carry: the plan reached the optimiser as 15 points,
the arbitrage spread fell from 0.143 to 0.014 EUR/kWh, and the cheapest
"hour" became a :45 point whose at() window runs into the next hour. That is
not the windowing effect above; it is a feed losing 84 % of its own prints, and
no provisional flag would ever have reported it.
The alternative was rejected: refusing sub-hourly feeds at _validate_raw()
would have made the count correct by breaking the one supported product that
legitimately publishes per quarter.
What was done instead keeps FCST-03's existing contract rather than weakening it: every published point inside the window passes through by identity, and a carried point is added only for a whole hour with no published coverage at all. A carried hour is still a single hourly point whatever the feed's own spacing is, because yesterday's profile is only known per wall-clock hour — and carried hours are provisional, so they are out of the trade set anyway.
⚠️ _audit() got stricter, not looser, and that was the condition on doing
this at all. The old post-condition refused any repeated _utc_hour — a count
that cannot express four points in one hour. It is replaced by two rules that
subsume it:
- a published point must be one of that hour's published points, by identity, and may be emitted only once;
- a carried point may stand only in an hour that does not appear in the
published set at all, must be flagged
True, and may not repeat.
So a duplicated carried hour — the DST mispricing the count existed to catch —
still raises; so does the same published point emitted twice; and a carried
point sharing an hour with a real print now raises too, which the count never
looked at. Each of those three is driven by its own mutation in
test_price_forecast.py.