Files
goodwe-addon/goodwe_controller/DECISIONS/FCST-04-the-day-that-is-carried.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

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.py calls plan.arbitrage(...) on fresh_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, because firm_points() is empty — but it has no production caller on the provider plan. self.cheap comes from static_status(), which builds its own static plan and calls cheap_now() on that. So it is a property of the type, checked in test_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.py parses every shipped module under app/, tools/, scenarios/ and estop/ and fails if any of them uses the name _remember — an attribute, a bare name, a def, or the string constant a getattr would need. By name rather than by substring, so a comment that merely mentions the rule does not fail it and a call in tools/ 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.