Compare commits

...
18 Commits
Author SHA1 Message Date
adminandClaude Opus 5 f620f7cf80 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
2026-09-13 02:46:15 +02:00
admin 8d09b30f16 Merge pull request 'TARIFF-14: the window a point covers is the point's, not an assumed hour' (#80) from TARIFF-14 into release/1.0
_Series.at() hardcoded a one-hour coverage window and returned the FIRST point whose start fell inside it. Belgium moved to a 15-minute MTU on 2025-10-01 and the live feed returns 96 points at 900 s, so on the real series at() answered with the wrong point for 45 of every 60 minutes - and then drifted, because from the second hour on the first match was an earlier point still inside its own one-hour window. Reproduced against shipped code: asking for 03:00 returned the 02:15 point.

It reached the trade decision directly. arbitrage._act() calls _point_at(plan, now) -> series.at(now), then tests here.start == cheapest.start / == dearest.start to decide whether this interval is the buy or the sell one. With a stale and drifting point that identity test almost never matches the extremum, so the strategy idled through exactly the interval it exists to trade. It also tested here.provisional and the section 17 negative-price guard on the wrong point's price.

Nothing caught it because no test drove at() on a sub-hourly series: the DST tests count points on 23- and 25-hour days but never ask for a quarter-hour, and every price fixture before TARIFF-08 was the invented hourly row shape. static_tariff.py emits hourly points, so static sites are immune - the defect was reachable only by the dynamic path TARIFF-08/09 had just made work.

Each point's window now runs to the next point's start, CAPPED AT ONE HOUR. The cap is not cosmetic: uncapped, the rule would have stretched the point before a three-hour FCST-04 hole across the whole hole, introducing hole-filling as a side effect of fixing a lookup. The last point takes the span of the point before it, because a flat one-hour tail answers at(00:30) tomorrow with today's 23:45 price - reaching for a neighbour across a day boundary, which section 11.4 forbids.

That tail choice was independently validated by TARIFF-12, which merged during this work. A served plan can now be up to TARIFF_MAX_AGE_S (5400 s) old, and price_horizon_h permits a 1 h horizon - so now can walk past the end of a short series. Under the shipped rule at() raises Absence and the site falls to the static profile; under the rejected flat tail the same stale plan would have gone on trading against an expired :45 quarter for up to 45 minutes. Re-read the rule if TARIFF_MAX_AGE_S is ever widened.

covers() shared the bug exactly - it has no rule of its own and delegates to at() - so one change fixes both. window() did not: it grades a start against a half-open range and never asks how long a point lasts. at() now depends on sortedness AND ESTABLISHES IT ITSELF, sorting once in _Series.__init__, because nothing else guaranteed it: _validate_raw() covers only the load_prices() path, _parse_points() only the store read, and PriceRead.series() never enforced anything. A series handed its points reversed now answers identically, as a check.

Evidence: 29/29 green on the merged tree. Eight mutations, all red. The killing one restores the shipped rule (+68 B) and is red on 24 named checks across both files, including the one named THE PROPERTY THE DEFECT BROKE - an arbitrage-level check driving the shipped decide() over a 96-point day whose cheapest is a :45 and dearest a :15, asserting as its own check that the old and new rules return different points. The tail mutation is a ZERO-BYTE edit and is caught. It is stated honestly that five of the eight leave test_arbitrage.py green, because those are at()-level properties no arbitrage fixture reaches.

Named and not fixed: price_forecast._walk() steps section 8.3's gate in whole hours, so on a sub-hourly series it reads one point per hour and never sees the :15/:30/:45 flags. Unreachable today - FCST-04 carries whole hours and the feed publishes whole days - but that is an assumption about producers, not a property of the gate.
2026-09-13 01:56:04 +02:00
glenn schrooyenandClaude Opus 5 028c3e53fb Merge release/1.0 (TARIFF-12) into TARIFF-14
checks / unit (pull_request) Successful in 7m13s
Second base move while this card was open: 559f0eb -> a6e64fb. Only
CHANGELOG.md conflicted; test_arbitrage.py auto-merged (TARIFF-12 used
section 8b, this card uses 10, so the two do not collide).

CHANGELOG: both entries under the single ## Unreleased in MERGE ORDER -
TARIFF-12's row and section precede this branch's, because it landed
first. No version number is picked; PKG-06/07's note stands untouched.

WHAT TARIFF-12 CHANGES FOR THIS CARD - written out in decision note §8
rather than left silent, because the question is a fair one.

A served plan can now be up to TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
= 5400 s old, where before it was at most one refresh interval, and
at() is what answers "what does it cost right now" out of that older
plan.

* PER-POINT SPAN: unaffected, structurally. Every window is derived
  from the neighbours' ABSOLUTE start instants - datetimes in a frozen
  dataclass in a tuple, which cannot move - and _cover_table() runs
  once at construction over data that cannot change. A plan's AGE is a
  property of fetched_at/tariff_at, never of a window. The index cannot
  go stale, which is a small positive under TARIFF-12: a kept plan now
  lives longer and there is nothing in it that needed recomputing.

* ONE-HOUR CAP: unaffected. It bites only at a hole; walking further
  into a series crosses more windows, each resolved on its own
  neighbours.

* THE TAIL: reachable for the first time, and §2's choice survives it.
  At the shipped 48 h horizon the tail is ~46.5 h beyond anything a
  90-minute-stale plan can be asked about. But price_horizon_h is
  int(1,72), so a 1 h horizon is a SUPPORTED configuration, and a
  90-minute serve-stale window against a 1 h series puts `now` past the
  end. Under the chosen rule a quarter-hourly 1 h series ends at :45,
  its window closes on the hour, at() raises Absence, _act() idles and
  §11.4 sends the site to the static profile. Under the REJECTED flat
  one-hour tail the same stale plan would have traded against the :45
  print for up to 45 minutes after the interval it describes had ended.
  So TARIFF-12 does not weaken the decision - it is the first thing
  that makes it reachable, and it selects the same answer. The rule
  should be re-read if TARIFF_MAX_AGE_S is ever widened again.

* TariffPlan.from_feed is a pure predicate on `origin` and TARIFF-12
  keeps or replaces a WHOLE PLAN OBJECT. It never splices two reads
  into one series, so it cannot hand at() an unsorted or duplicated
  point list.

RE-GRADED ON THIS BASE, not carried over: all 29 test files green;
app/price.py byte-identical to the TARIFF-10 merge (90d8ca31f7c0109d,
85953 B) because TARIFF-12 does not touch it; M1 re-driven at +68 B,
red on the same 24 named checks across both files; M3 re-driven at
ZERO BYTES, red on the same 4. Each purged before the run and reverted
with the fingerprint re-checked, from a card-specific harness at
/home/homelab/.cache/t14-mut/ with a mktemp backup inside it.

This branch contributes zero diff to app/main.py and app/static_tariff.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 01:47:17 +02:00
admin a6e64fb66e Merge pull request 'TARIFF-12: a degradation is not a refresh' (#79) from TARIFF-12 into release/1.0
run_tariff_refresh()'s docstring promised 'A FAILURE DOES NOT CLEAR THE PLAN' and guarded the wrong thing. resolve_tariff() deliberately never raises on a feed failure - load_prices() returns no_data rather than letting a traceback into the planning tick - so a dead feed returned a degraded TariffPlan, the try SUCCEEDED, and the good plan was overwritten. The except branch that promised otherwise was reachable only by unexpected exceptions; a supplier outage, the case the docstring names first, never reached it. With static_profile unset - the shipped default for a dynamic site - a complete 48-hour plan was replaced by origin='none', 0 points.

A 40-hour soak drove a 90-minute outage and measured it: the plan was gone at the FIRST failed fetch, ~27 min after the last good one, with TARIFF_MAX_AGE_S (5400 s) governing nothing. There was no serve-stale window; a transient blip had the same effect as a long outage.

The fix keeps a plan that is still fresh, via fresh_tariff_plan() - reused rather than transcribed, and pinned on the AST. The §11.4 static fallback does NOT replace a fresh provider plan: every point _degrade() builds is provisional, so firm_points() is empty and arbitrage and cheap_now are already off, while a day-ahead price for a given hour does not change once published - a 30-minute-old observation is exactly as true about 19:00 as one fetched now. The fallback is strictly less actionable and no more current. Deliberately NOT symmetrical: a standing plan that is itself a degradation IS replaced, or a static site's plan would freeze at the window it was first generated for. tariff_at does not move on a kept plan, so TARIFF_MAX_AGE_S now genuinely governs and a kept plan stays distinguishable from a fresh one.

Rebased onto TARIFF-10 and re-verified rather than assumed. The interaction was confirmed by driving it: nothing on this path reads arbitrage_enabled, and the static-profile case was driven twice consecutively because a guard that latched there would freeze a site's plan for the life of the install. One honesty fix followed from TARIFF-10 - the kept-plan log said 'arbitrage stops with it', which is false on a site that fetches without trading, and now reads 'arbitrage, where it is armed, stops with it'.

Evidence: 29/29 green on the merged tree, including TARIFF-10's new reachability section. Eleven mutations, all re-driven on the merged tree because cached reds from the old base are not evidence about the new one; eleven red, no survivors, every revert fingerprinted after the run as well as before.

Two process findings recorded rather than quietly fixed. Two drafts of one check were red against a CORRECT tree because they substring-matched the docstring that states the property - a text search over a function grades its prose as much as its behaviour, which is habit 4 in a third costume; the check now scans AST symbols and non-docstring constants, with positive controls. And the mutation harness was overwritten mid-session by a concurrent agent's harness sharing the scratchpad, pointing it at a different worktree - MUTATION.md habit 18's second bullet almost verbatim. It surfaced as an error rather than a silent wrong result, and the per-run revert fingerprints are what prove the worktree was never left mutated.
2026-09-12 21:39:24 +02:00
glenn schrooyenandClaude Opus 5 435e1b6f7c Merge release/1.0 (TARIFF-10) into TARIFF-14
checks / unit (pull_request) Successful in 7m10s
CI on PR #80 was NOT a test failure. The workflow's "grade the merge, not
the branch" step refused the PR because it does not apply, and said so
itself: "REFUSED: refs/pull/80/head does not merge into release/1.0.
NOTHING HAS BEEN TESTED. This is not a test failure - no test ran."
Conflicts were CHANGELOG.md and test_price.py; app/price.py auto-merged.

CONFLICTS RESOLVED

* CHANGELOG.md - both entries belong under ## Unreleased. TARIFF-10's
  table row and section come first because it landed first. No version
  number is picked here; PKG-06/07's note stands untouched.

* test_price.py - BOTH cards appended a section "13". Nothing is
  dropped: TARIFF-10's stays 13, this card's is renumbered 14, and
  TARIFF-10's temp-directory teardown stays at the end of the file. The
  two share no fixture, option dict or helper name.

WHAT TARIFF-10 CHANGES FOR THIS CARD - nothing in the reasoning, and the
check is recorded rather than asserted (decision note §7).

In app/price.py the two do not meet: TARIFF-10 added provider_id() and
provider_configured() beside price_option() and routed load_prices()
through them - the option-reading layer, touching nothing in _Series,
at(), covers(), window() or _cover_table(). The merged at() is
byte-identical to the one the mutations were driven against.

Behaviourally TARIFF-10 makes run_tariff_refresh() run with arbitrage
OFF, so a provider-configured site now has a standing plan - and
therefore a _Series - where before it had none. It does NOT add an at()
consumer to the 30 s tick: static_status() builds a STATIC plan (hourly,
immune), main.py calls only plan.words on the fetched plan, and
price_forecast.price_permits()/begin_phase() have no caller in main.py
at all. So TARIFF-10 neither creates nor closes any exposure this card
is about - it enlarges the defect's reach the moment such a site arms
trading, which strengthens the fix without changing it.

RE-GRADED ON THE MERGED TREE, not carried over

* all 29 test files green;
* all EIGHT mutations re-driven, purged before each run, each reverted
  with the fingerprint re-checked. Same verdicts and same red-check
  counts as before the merge: M1 (the shipped one-hour scan, +68 B) red
  on 24 named checks across both files; M3 (the ZERO-BYTE tail rule) red
  on 4; M2/M4/M6 on 1 each, M5 on 2, M7 on 16, M8 on 14.
* Clean app/price.py on the merged tree: 90d8ca31f7c0109d, 85953 B.
* None of TARIFF-10's 650 new lines of checks moves under any of the
  eight - they grade resolve_tariff()'s reachability from a
  configuration, not the lookup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-12 21:38:28 +02:00
Claude f5ea39784d Merge release/1.0 (TARIFF-10) into TARIFF-12
checks / unit (pull_request) Successful in 7m16s
CHANGELOG.md was the only conflict: both entries belong under the same
`## Unreleased` heading, TARIFF-10's first because it merged first. No
version number picked.

app/main.py auto-merged, and was re-read because of it. TARIFF-10 changed
WHICH installs run `run_tariff_refresh()` (`arbitrage.enabled(opts)` ->
`tariff_refresh_wanted(opts)`); this card changed what that task does on
a failed fetch. Nothing in the keep/replace reasoning changed, and the
two newly reachable configurations are now DRIVEN in test_arbitrage.py
8b rather than reasoned about:

  * a provider configured with trading DISARMED is the case the guard
    was designed for, and needs no second fixture because nothing on
    this path reads `arbitrage_enabled` at all - asserted over the AST's
    symbols and non-docstring constants, after two drafts of that check
    went red on a CORRECT tree for matching the word "arbitrage" in a
    docstring and in a log line;
  * `price_provider: off` + a `static_profile` resolves to the
    CONFIGURED product (`origin: "static"`), so `standing.from_feed` is
    false on every refresh and the guard never latches - driven twice in
    a row, because a guard that latched there would freeze a static
    site's plan at its first window for the life of the install.

Also: the kept-plan log line said "arbitrage stops with it", which after
TARIFF-10 is false on a site that fetches without trading; it now says
"arbitrage, where it is armed, stops with it". The keep decision is
strictly downstream of `resolve_tariff()`, so the §8.3 carry into
price_history - TARIFF-10's whole point - is unaffected either way.

All 29 suites green on the merged tree, including TARIFF-10's new
test_price.py section 13. All eleven mutations RE-DRIVEN on the merged
tree rather than carried over: eleven red, no survivors, every revert
fingerprinted. M11 is new - making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B) is red on five named checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-12 21:31:27 +02:00
glenn schrooyenandClaude Opus 5 7f2c4e20e1 TARIFF-14: the point whose own interval covers the instant
checks / unit (pull_request) Failing after 2s
`_Series.at()` asked `s <= u < s + 1 h` of every point IN LIST ORDER and
returned the FIRST match. On the hourly shape every fixture in this
repository carried before TARIFF-08 that is right by accident. Belgium
moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
returns 96 points at 900 s, where it is wrong twice over - measured
against the shipped code at 5059e28:

    series: 02:00=0.10  02:15=0.11  02:30=0.12  02:45=0.13  03:00=0.14

    ask +15min -> 02:00  0.10   <-- WRONG (wanted 02:15)
    ask +60min -> 02:15  0.11   <-- WRONG (wanted 03:00)
    ask +75min -> 02:30  0.12   <-- WRONG (wanted 03:15)

Inside the first hour the :00 point wins because its hour has not run
out. From the second hour on the answer is not even the top of the hour
- the first point still inside its own hour wins - so the error
compounds with depth into the series.

`arbitrage._act()` tests `here.start == v.cheapest.start` /
`== v.dearest.start` to decide whether this interval is the buy one or
the sell one. That identity almost never held, so the strategy idled
through exactly the interval it exists to trade, and read §8.3's
`here.provisional` and §17's `price < 0` off the same wrong point.
`static_tariff.py` emits hourly points, so static sites were never
affected; this is reachable only down the dynamic path TARIFF-08/09
made work.

THE RULE: a point covers from its own start until the next point's
start, and never more than one hour. Quarter-hourly takes 900 s,
hourly is unchanged, and a MIXED series - FCST-04's carry-forward puts
hourly carried points beside quarter-hourly firm ones, spacing
[900, 3600] in 70 % of soak samples - takes each point's OWN gap.

The one-hour cap is load-bearing: FCST-04 leaves an hour OUT when
nothing published it and yesterday has no value, and `at()` raising
Absence for it is what sends the caller to the static profile (§11.4).
Uncapped, "until the next point's start" would stretch the point before
a three-hour hole across the whole hole and answer every instant in it
with a stale price.

THE LAST POINT is given the same span as the point before it (one hour
when it is the only point). A flat one-hour tail would answer 00:30
TOMORROW with today's 23:45 price. The cost - a mixed series ending at
the firm/carried seam loses its last 45 minutes to Absence - is argued
in DECISIONS/TARIFF-14-the-window-a-point-covers.md §2 rather than
buried; the direction is the safe one.

`at()` now reads a point's NEIGHBOURS, so it would depend on
sortedness. It establishes that itself: the lookup index is sorted by
UTC start once, at construction, so no caller's list order reaches it.
A series handed its 96 points in reverse answers identically, and that
is a check. `covers()` shared the defect exactly - it has no rule of
its own, it asks `at()` - and is fixed by the same change; `window()`
did not, and its docstring now says why.

Nothing about §9.3 or a point's `start` changed. PricePoint still has
three fields; the span is derived from the neighbours, never stored.

Graded by test_price.py section 13 (900 s, 3600 s, mixed, holes, the
tail, caller order, duplicate starts, covers()/window(), the no-data
contract) and end to end by test_arbitrage.py section 10, which drives
decide() over a 96-point quarter-hourly day whose cheapest and dearest
quarters are deliberately NOT on a whole hour, plus §17, §8.3 and the
mixed series. Eight mutations, all red, including a ZERO-BYTE one on
the tail rule; byte deltas and what the arbitrage file could not see
are in the decision note §6. All 29 test files green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-12 21:22:28 +02:00
admin 559f0eb022 Merge pull request 'TARIFF-10: a configured provider fetches prices, whether or not trading is armed' (#78) from TARIFF-10 into release/1.0
run_tariff_refresh() was created only under `if arbitrage.enabled(opts)`, and it is the sole caller of resolve_tariff(), which is the sole caller of price_history.carry(). arbitrage_enabled defaults to false, so on a shipped install with a provider configured nothing was ever fetched, no plan resolved, /data/price_history.json never written, FCST-04's Price history row read `empty` forever, and TARIFF-09's warning never fired. Every price check constructs its own resolve_tariff() call, so the suites exercised a path production never reached.

Measured, not inferred: a 40-hour soak ran two instances side by side - the shipped default made zero feed requests and never created its store across all 802 samples, while an identical instance with arbitrage armed resolved a 132-point plan from the same feed. DOCS.md:1348 has claimed the fixed behaviour all along.

The product owner's decision is Option A: a configured provider fetches. Trading stays separately gated, and that is graded directly rather than inferred - the real control loop is driven on a resolved provider plan with arbitrage_enabled the only option moving: off gives +180 W self-consumption, on gives -5000 W at the cheapest hour and +5000 W elsewhere. The armed rows are what make the unarmed row evidence.

ONE ACCEPTANCE CRITERION WAS DELIBERATELY NOT IMPLEMENTED LITERALLY, and the deviation is right. The criterion said no fetch task at all when price_provider is off, whatever arbitrage_enabled says. That breaks a configuration validate.py explicitly accepts - provider off, static_profile set, arbitrage armed - by leaving tariff_plan at None forever, silently disarming a strategy the UI reports as running. Driven both ways. The guard is therefore `provider_configured(opts) or arbitrage.enabled(opts)`; the criterion's stated harm is still fully met, because with the provider off resolve_tariff() answers out of the static profile and opens no socket, and the true shipped default starts no task at all. DECISIONS section 3 records the trade.

cheap_now() is NOT wired, deliberately. On a provider-only site static_status() returns False on every cycle, so adopting it would take the maintenance cycle's paid grid top-up from never to sometimes - a change to what spends money, and it needs its own decision rather than riding along on this one.

Evidence: 29/29 green. 13 mutations over 14 runs, each on a fresh tree copy, purged, :ro, -B. The shipped defect at -4 B is red on 7 named checks. The reachability check grades reachability from a CONFIGURATION rather than from the `if` statement - the `if` is exactly what someone meant to write. The instrument itself was mutated too, both +0 B, both red. One real survivor was found and closed in the same commit: provider_id() dropping .strip() left four price suites green. And one fixture defect was found and fixed - five minutes of P1 telegrams made the peak guard blind depending on where the wall clock sat in the billing quarter, so the armed baseline failed to trade on some runs.
2026-09-12 21:07:55 +02:00
Claude c81289527f TARIFF-12: a returned degradation is not a refresh
checks / unit (pull_request) Successful in 7m12s
`run_tariff_refresh()` promised in its own docstring that "a failure does
not clear the plan", and enforced it with `except Exception` - a branch a
supplier outage never reaches. `load_prices()` states the reason in its
own words: it "RETURNS, NEVER RAISES, for every failure a site can
actually have", because a degradation arriving as a traceback into the
planning tick would stop the controller regulating over an expired
certificate. So a dead feed RETURNS a §11.4 plan, the `try` succeeds, and
the assignment below it overwrote the good plan unconditionally. With
`static_profile` unset - the shipped default, and the normal state of a
dynamic-tariff site - `_degrade()` returns `TariffPlan(no_data, None,
"none")`: a complete 48-hour plan replaced by nothing, at the FIRST
failed fetch, ~27 minutes after the last good one. `TARIFF_MAX_AGE_S`
(5400 s) never governed anything and there was no serve-stale window at
all.

Fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's HIGH
(`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), and
now `except Exception` only, for a path that never raises.

The question is now asked once, after the attempt, from the evidence
that exists - FCST-06's answer, one module over. The evidence is
`TariffPlan.from_feed` (did this resolution observe the configured feed)
and the shipped `fresh_tariff_plan()`, reused rather than transcribed. A
degradation replaces a standing observation only once that observation
has aged out, so `TARIFF_MAX_AGE_S` is now the serve-stale window it
reads like.

A §11.4 static fallback does not replace a fresh provider plan either,
although it carries real prices: every point `_degrade()` builds is
`provisional=True`, so `firm_points()` is empty, `arbitrage()` refuses
and `cheap_now()` is False - everything a consumer may act on is already
off - while a day-ahead price for a given hour does not change once
published. The rule is deliberately not symmetrical: a standing plan
that is itself a degradation IS replaced, or an outage would freeze the
operator's static profile at the window it was first generated for.

`tariff_at` does not move on a kept plan. It is the moment of the fetch
that produced the plan standing, `fresh_tariff_plan()` judges the age
from it, and an unmoved timestamp is the only thing that tells a kept
plan from a freshly fetched one.

test_arbitrage.py section 8b drives the whole chain with nothing stubbed
but the socket: the transport raises the way aiohttp does, load_prices()
returns no_data, _degrade() builds the §11.4 plan, and the shipped
run_tariff_refresh() decides. A check that patched resolve_tariff to
RAISE would have passed against the defect. Ten mutations, ten reds, no
survivors; 29/29 suites green.

Out of scope and unchanged: the persisted price store still does not
back the plan during an outage (FCST-03), and the status banner and the
Arbitrage row's lag are separately carded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-12 21:06:45 +02:00
adminandClaude Opus 5 e6c45a61e7 TARIFF-10: configuring a price provider is what fetches prices
checks / unit (pull_request) Successful in 7m19s
amain() started the background tariff refresh only when arbitrage_enabled was
on, and that option ships false. run_tariff_refresh is the only caller of
resolve_tariff(), which is the only caller of price_history.carry() - so on a
shipped install with a price provider configured nothing was ever fetched: no
plan resolved, /data/price_history.json never created, the Price history status
row reading `empty` for the life of the install, and TARIFF-09's short-horizon
warning unable to fire because there was no fetch to warn about. Measured over
a 40 h two-instance soak on one feed: 0 requests and `present: false` across all
802 samples, against a resolved 132-point plan on the instance with arbitrage
armed.

git log -S puts the refresh task, its arbitrage.enabled() guard AND the DOCS.md
sentence saying a configured provider still fetches in the same commit - OPT-04,
bdd7f18; config.yaml has carried the claim since TARIFF-02, 852fc7c. The prose
and the code disagreed from the moment both existed and nothing compared them.
config.yaml and both translations additionally carried a claim that had rotted
the other way - "nothing consumes the price series yet", untrue since OPT-04
shipped the consumer. All four surfaces now say what the add-on does, and a
check pairs DOCS against the DRIVEN behaviour so they cannot drift again.

The gate is tariff_refresh_wanted() = provider_configured(opts) or
arbitrage.enabled(opts), and the `or` is a STRICT WIDENING: every configuration
that started the task before still starts it. The second term is not there for
symmetry. price_provider: off + a static_profile + arbitrage_enabled: true is a
configuration validate.py explicitly ACCEPTS and it trades - measured, at
cycle_cost_threshold_eur 0.05 on a 0.20/0.284 day/night profile,
ARBITRAGE_CHARGE -5000 W. Gating on the provider alone would leave that site's
tariff_plan at None forever and answer "no tariff plan has been resolved yet" on
every cycle, silently disarming a strategy the UI says is running.

Nothing acquires a periodic HTTPS call. With price_provider off, resolve_tariff()
answers out of the static profile without opening a socket (driven and counted:
0 requests), and the true shipped default - no provider, no trading - starts no
task at all. What decides whether a request leaves the machine is price_provider
and only that. The literal reading of the ticket's AC2 - drop the `or` - is one
token and is recorded in DECISIONS §3 with what it costs, for the owner.

AND NOTHING TRADES THAT DID NOT TRADE BEFORE. cycle()'s
`if arbitrage.enabled(self.o):` is untouched, and that is graded directly rather
than inferred from the gate having moved: Controller.cycle() is driven on a
resolved provider plan with the peak guard asserted clear, and arbitrage_enabled
is the only option that moves between the rows - false/false give +180 W
("loop -> 180 W") on both the cheap-now and dear-now plans, true/true give
-5000 W and +5000 W ("arbitrage -> ...").

TariffPlan.cheap_now() is deliberately NOT wired, decided rather than inherited
(DECISIONS §4). A provider-backed plan now exists on every configured site, so
Cheap window could read it - and Cheap window feeds the maintenance cycle's grid
top-up, the one place a price moves energy without trading being armed. On a
provider-only site static_status() returns False on every cycle, so adopting it
would take a paid grid top-up from never to sometimes. self.cheap stays on
static_status(); what adoption would take is listed rather than left implied.

THE CHECK THAT WOULD HAVE CAUGHT THIS is test_price.py section 13, and it grades
REACHABILITY FROM A CONFIGURATION rather than the shape of an `if` - the `if` was
exactly what somebody meant to write. amain()'s guards are EVALUATED against real
option dicts, whatever they are spelled as; the call graph under app/ is walked
to see whether the tasks those guards start arrive at resolve_tariff(), wherever
it has moved to; and then the fetch is DRIVEN with a counting transport, because
a graph is an argument and a counted request is an observation. What the
instrument cannot see is printed on every run, largest hole first.

Evidence: 29/29 green. 13 mutations in 14 runs - M10 re-run as M10b once it was
closed - each applied to a fresh cp -a of the clean worktree rather than
reverted (MUTATION.md habit 18), purged, :ro, -B. The
shipped defect - the gate back at arbitrage.enabled(opts), -4 B - is red on
seven named checks. Dropping the `or` (-27 B) is red on the strict-widening row;
removing cycle()'s trading gate (+8 B) is red on both AC3 rows. The instrument
is mutated too (habit 16): _reaches() always True and _task_sites() returning []
are both +0 B and both red.

Two findings are recorded rather than rounded off. provider_id() dropping
.strip() (-8 B) was a real SURVIVOR across four price suites - nothing fed a
padded provider id - and is closed in this commit by the padded-id pair; and the
first draft of the AC3 fixture fed five minutes of P1 telegrams, so whether the
peak guard was blind depended on where the wall clock sat inside the billing
quarter and the armed baseline failed to trade on some runs. It is 900 s now,
and the guard's clear state is a check rather than an assumption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-12 20:58:48 +02:00
admin 88c59a9918 Merge pull request 'TARIFF-09: ask the feed for the horizon the operator configured' (#77) from TARIFF-09 into release/1.0
generic_dayahead.fetch() accepted since and until and ignored both, requesting the bare URL - so the feed returned one Brussels day against a 48 h horizon. Everything past today was carry-forward, provisional, and dropped from the trade set by 8.3, which means the optimiser never saw tomorrow as firm at any hour of the day. That is not what FCST-04's fill was built for: it covers the pre-publication window until the ~13-14 h CET auction, after which a healthy feed should hand over tomorrow in full.

Measured before, 2026-09-10T09:00+02:00 on the live feed: 15 firm hours and 33 carried of 48, firm local days = [2026-09-10] only, buy and sell both on the same day. After, against a real two-day body: 39 firm and 9 carried, firm local days = two, buy 13:45 on 09-09 and sell 19:45 on 09-10. At 23:00 the difference is the whole verdict - before, 1/47 and arbitrage REFUSED at spread 0.0464; after, 25/23 and allowed. The 9 hours still carried are the tail running into a third Brussels day, which no day-ahead market publishes.

The range is derived on the BRUSSELS calendar, both ends graded and both driven red by mutation - the API takes dates while since/until are instants, so deriving either end from the UTC date loses the last one or two hours of every day. That is _bdate()'s lesson from FCST-04 one layer up, except that here the hours are never fetched at all rather than misfiled.

price_feed_url is an EXACT URL, used verbatim, nothing appended - and the deciding argument came from the codebase rather than from taste: generic_dayahead declares a second egress host, web-api.tp.entsoe.eu, whose date parameters are not start/end. An override to the other DECLARED host is exactly where merging would append parameters that host ignores and produce the short horizon anyway. The cost is made visible by a fetch-time WARNING naming the consequence and the start..end it would have asked for, with a control asserting the default path stays silent, and dated_feed_url() refuses to append to a base that already carries a query string, so blind appending is structurally unreachable.

The plugin contract is deliberately NOT enforced yet. Obliging the three unverified-endpoint plugins of TARIFF-07 to pass a range would be satisfied by an invented parameter name - TARIFF-08's defect moved from the response to the request and shipped as a guarantee. The roster of plugins that consume the range is pinned as a literal instead, so the next supplier has to decide.

Evidence: 29/29 green. 11 mutations, no survivors, each applied to a fresh copy of the clean tree rather than reverted. Includes the Brussels/UTC boundary pair, one at +1 B, and one at +0 B that changes a single price digit inside the committed capture and is caught by the re-derived sha256. Two live fetches only, 66 s apart, respecting the feed's rate limit; the second returned bytes identical to TARIFF-08's capture, so the pre-publication case is a second recorded URL for the same bytes with the identity re-derived rather than asserted.

MUTATION.md habit 20: a fixture grades the response, and nothing about the REQUEST is reachable from one. canned() had recorded the URL since TARIFF-02 and no check ever asserted on it, which is how a plugin that never asked for a date range went unnoticed for four tickets.
2026-09-10 10:44:15 +02:00
Claude Opus 5 17b7b447c4 TARIFF-09: ask the day-ahead feed for the horizon it was handed
checks / unit (pull_request) Successful in 7m10s
`generic_dayahead.fetch()` accepted `since` and `until` and requested the
bare `?bzn=BE`, which answers ONE Brussels day. Everything past today was
then filled from yesterday's profile by FCST-04, flagged provisional, and
§8.3 drops provisional hours from the trade set -- so the optimiser never
saw tomorrow as firm, at any hour of any day, and nothing reported it.

Measured on 4257e38 at 2026-09-10T09:00+02:00, 48 h horizon, live feed,
yesterday seeded through the shipped read path:

    60 firm points (15 whole hours) + 33 carried hours of 48 requested
    carried: 24 h on 2026-09-11, 9 h on 2026-09-12
    arbitrage(0.10): buy 11:45 and sell 19:45 -- BOTH on 2026-09-10
    firm local days = [2026-09-10]

The ceiling was structural: with a one-day window, at hour H a 48 h horizon
is at most 24-H firm and 24+H carried. At 23:00 that is 1 and 47, and the
remaining firm spread (0.0464 EUR/kWh) no longer clears the default 0.10
threshold, so arbitrage is refused outright. FCST-04's fill covers the
window until the ~13:00-14:00 CET auction; it was never meant to cover
every hour of every day.

The data exists and the endpoint serves it: verified 2026-09-10T07:45:49Z,
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns 192 quarter-hours across
two local days. Committed unmodified beside TARIFF-08's captures, with the
URL, retrieval time and sha256 recorded and RE-DERIVED by the replay.

THE RANGE IS DERIVED ON THE BRUSSELS CALENDAR, not the UTC one. 22:30 UTC
in summer is already tomorrow in Belgium, so a UTC-derived start asks for
the day the controller has just left and drops the day it is standing in,
for the last one or two hours of every day. That is `_bdate()`'s lesson one
layer up with a worse consequence: `_bdate()` misfiles an hour, this never
fetches it. `until` is exclusive, so local midnight belongs to the day
before it; an inverted window collapses to one day rather than a backwards
range. Both ends are graded and both are driven red by mutation.

After, same code path and window, a feed serving both days: 39 firm hours
and 9 carried (was 15 and 33), firm across TWO Brussels days, and the
permitted cycle buys on one day and sells on the next.

`price_feed_url` IS THE EXACT URL AND THE PLUGIN APPENDS NOTHING. `start`/
`end` are this feed's spelling and the provider declares a second host
whose parameters are different, so merging would produce a request nobody
typed and the short horizon anyway. The cost is real, so it is not silent:
a WARNING on the shipped logger at fetch time names the consequence and the
start..end that would otherwise have been asked for. `dated_feed_url()`
refuses to append to a base that already carries a query string, so the
blind-append shape cannot come back by edit.
DECISIONS/TARIFF-09-the-range-and-the-override.md argues the alternative.

Before publication nothing regresses and it is graded, not assumed: at
09:46 CEST the two-day request came back byte-identical to the one-day
body, and that stays a `series` of what exists with FCST-04 filling the
rest. A `no_data` there would switch a working site's arbitrage off every
morning -- the mutation that adds that rule is red by name.

The plugin contract is still NOT enforced: three shipped plugins point at
endpoints nobody has verified, and a rule obliging them to pass a range
would be satisfied by an invented parameter name -- TARIFF-08's defect
moved from the response to the request. Which plugins consume the range is
pinned as a literal instead.

11 mutations, all red, no survivors; each applied to a fresh copy of the
tree rather than reverted, through tools/mutation_run.sh. One is +0 B (a
single price digit inside the committed capture) and the re-derived sha256
catches it. MUTATION.md habit 20 records why a fixture could never have
reached this: a captured body is the answer to a URL, and no property of a
body says which URL was asked for. All 29 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 10:35:12 +02:00
admin 4257e38e5a Merge pull request 'TARIFF-08: read the feed the add-on actually ships pointed at' (#76) from TARIFF-08 into release/1.0
_points_from_json() expected doc['data'] to be a list of {start, price_eur_mwh}. api.energy-charts.info/price?bzn=BE returns a columnar document - parallel unix_seconds and price arrays - so the shipped parser raised SourceUnavailable('the feed carried no data list') against every real response. generic_dayahead had never parsed one. With the three fabricated supplier URLs of TARIFF-07, that left manual_csv as the only working provider in a five-provider table.

It survived to release because every fixture fed the invented shape: test_price.py, test_price_history.py and test_static_tariff.py all built {'data':[{'start':...,'price_eur_mwh':...}]}, so the suite validated the parser against the parser's own assumption - a closed loop that cannot notice the assumption is wrong. Section 11.4 then made the failure silent: a parse error degrades to the static profile, which is correct and says nothing about the cause.

Verified live 2026-09-10, end to end: fetch -> parse -> assemble() -> TariffPlan returns 96 quarter-hourly points at 900 s, origin provider, product DAY_AHEAD, arbitrage allowed with a firm spread of 0.4645 EUR/kWh. First time the pipeline has completed against a real feed. Belgium moved to a 15-minute MTU on 2025-10-01, so FCST-04's H1 fix - the fill was decimating sub-hourly points to one per hour - was never theoretical; it is the shape of the live feed, and it is what makes this deliverable.

The columnar shape is read by a SECOND function, not a sniff, and feeding the row shape to generic_dayahead is now a graded no_data so the sniff cannot return quietly. Equal length refused in both directions with both lengths in the reason; the unit refused if not EUR/MWh, because MAX_PLAUSIBLE_EUR_KWH only bounds prices that are too large and a EUR/kWh switch would otherwise be invisible; nulls dropped with a count, an all-null body named as no_data; a deprecated feed warns while it still works.

Two real bodies captured as fixtures - unmodified bytes, with URL, retrieval time, unit, licence-as-returned and sha256 recorded, and the checksums RE-DERIVED by the replay rather than asserted. The invented fixtures are renamed synthetic_row_feed at every call site so the closed loop cannot re-form. Only one of four network providers has a capture, and a registry-derived check says so in its own sentence: the other three have nothing to capture, which is TARIFF-07.

Evidence: 29/29 green. 12 mutations, no survivors, purged tree and -B per TEST-15. One first-draft line was decoration and the mutation said so - the parser called brussels() per instant while assemble() already renders, so removing it changed nothing. The line was deleted rather than fenced with a check, and the mutation re-aimed at assemble() where the rule lives. MUTATION.md habit 19.

Follow-up filed as TARIFF-09: generic_dayahead ignores the since/until it is handed, so the feed supplies 24 h of a 48 h horizon and tomorrow is never firm.
2026-09-10 09:14:50 +02:00
adminandClaude Opus 5 7a5281e97f Merge release/1.0 (FCST-06) into TARIFF-08
checks / unit (pull_request) Successful in 7m10s
One conflict, in CHANGELOG.md's `## Unreleased` block, exactly as expected
from two branches landing beside each other: both `_this branch_` table rows
and both sections are kept, FCST-06's first. Nothing else conflicted -
FCST-06's `test_price_history.py` section 5d and this branch's columnar
`feed_body()` merged cleanly - and all 29 test files are green on the result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 09:06:39 +02:00
adminandClaude Opus 5 6eba18968c TARIFF-08: the day-ahead feed is read in the shape it actually publishes
checks / unit (pull_request) Failing after 3s
`generic_dayahead` could not read one byte of `api.energy-charts.info`, and
never had been able to. `_points_from_json()` asks for `doc["data"]`, a list of
`{start, price_eur_mwh}` objects; that document does not exist. Fetched live on
2026-09-10 the feed returns COLUMNAR parallel arrays and no `data` key at all -
`unix_seconds` (96 UTC epoch seconds, 900 s apart), `price` (96 floats),
`unit: 'EUR / MWh'`. Driving the shipped parser against that body:
`SourceUnavailable: the feed carried no 'data' list`. It is the only shipped
provider whose endpoint anybody has reached, so dynamic pricing did not work.

AND §11.4 MADE IT SILENT, which is why it survived to release: a parse error
becomes no_data, becomes the static profile, becomes arbitrage off - the
CORRECT degradation, saying nothing about the cause. A site would have sat on
the fallback indefinitely.

AND THE SUITE COULD NOT SEE IT, because every fixture was built from the
parser's own assumption. test_price.py, test_price_history.py and
test_static_tariff.py each built `json.dumps({"data": [...]})`, a shape invented
here and published by nobody, so the checks graded the parser against the
parser's guess - a closed loop no coverage inside it could ever have escaped.
MUTATION.md habit 19 records it as the fourteenth instance of habit 4 and the
first where the fixture and the code under test shared a SHAPE, not a number.

WHAT CHANGED

* `_points_from_columnar_json()` - a SECOND function, not a sniff. A parser that
  decides which document it received is one that can decide wrong, and a
  wrong-but-parseable response then becomes a price. Feeding the row shape to
  `generic_dayahead` is now a graded no_data.
* Unequal arrays are refused in both directions, with the two lengths in the
  reason. `zip()` stops at the shorter list silently, and a truncated `price`
  array would have produced a real series missing the evening peak - traded on
  as complete, where no data at all switches arbitrage off.
* A unit that is not EUR/MWh is refused. The plugin converts with a fixed
  0.001, and `MAX_PLAUSIBLE_EUR_KWH` only bounds prices that are too LARGE.
* `unix_seconds` are read as UTC instants explicitly; §9.3's Brussels rendering
  stays in `assemble()` rather than being copied into the plugin, where a
  mutation could not tell it from a no-op (M4 proved exactly that on a first
  draft, and the line was removed rather than fenced).
* A published null is dropped and logged with a count; an all-null body is
  no_data named by this parser rather than by the boundary validator.

FIXTURES

Two unmodified response bodies under `scenarios/`, captured by hand on
2026-09-10: quarter-hourly (2026-09-10, 96 points at 900 s) and hourly
(2025-09-10, 24 at 3600 s - the same endpoint before Belgium's 15-minute MTU).
`energy-charts-be-price-captures.json` records the URL, retrieval time, unit,
licence-as-returned and a sha256 of each, and the replay RE-DERIVES the
checksums, so a fixture edited to make a check pass stops being a capture. Data
CC BY 4.0, Bundesnetzagentur | SMARD.de, via Energy-Charts (Fraunhofer ISE),
published without changes - the same BE-zone licence check DATA-02 established.

The surviving invented fixtures are renamed `synthetic_row_feed` at every call
site. `engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy` are untouched
and have no capture: their URLs are unverified and withdrawing them is
TARIFF-07's open decision. Nothing should read their green checks as evidence
that those feeds parse.

MUTATIONS - 12, all red, no survivors. Purged tree, mounted `:ro`, `python3 -B`
(TEST-15). M1 drop the equal-length refusal; M2 make it one-sided; M3 drop the
unit check; M4 `assemble()` stops rendering at the Brussels offset; M5 read
`unix_seconds` as a naive local clock; M6 zero-fill a null instead of dropping
it; M7 drop the all-null refusal; M8 sniff for the row shape; M9 drop the
deprecated warning; M10 drop the dropped-nulls warning; M11 edit one price in
the committed capture; M12 point `generic_dayahead` back at the row parser -
red across all three price suites.

END TO END, LIVE, 2026-09-10: `resolve_tariff()` returns a `TariffPlan`,
`origin='provider'`, 96 quarter-hourly points, spacing [900] s, no decimation,
first point 2026-09-10T00:00:00+02:00 at 0.30608 EUR/kWh all-in. FCST-04's H1
fix was never theoretical. All 29 test files green.

STILL OPEN: `generic_dayahead` asks for no date range, so the default URL
returns one published day - a full 48 h horizon is reached only through §8.3's
carry-forward fill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 08:57:32 +02:00
admin 958f5cd218 Merge pull request 'FCST-06: cannot-persist is one question, asked after the attempt' (#75) from FCST-06 into release/1.0
The module claimed 'a store that cannot persist records nothing' and enforced it against `not self.ok` only. A store with ok=True whose _save() fails - a full volume, a read-only remount - kept answering yesterday() from memory until the next restart, which is the silently-forgetting cache the docstring rejects, reached by the condition the rule did not name.

Reproduced against real volumes rather than a monkeypatch: /data bind-mounted read-only (EROFS) and a full tmpfs (ENOSPC), with a healthy-/data control asserted either side. Before: yesterday() returned a 24-point series while the disk held {}.

Fixed in the code, not the docstring - weakening the doc would have documented a cache that forgets without saying so as intended.

The design answer is the part worth keeping: 'can this store persist' CANNOT be answered before the attempt, because a directory that was writable a moment ago is not a write that completed, so os.access or a probe write is a third symptom test wearing a predicate's name. It is answered after, once: _save() -> bool, true only if the file now holds what memory holds. The `not self.ok` test in _remember() is GONE rather than joined by a second one, and a check asserts its absence from the source. This is the same shape as FCST-04's M4 and FCST-05's HIGH - a rule enforced on the conditions someone thought of - and it is the first of the three to be closed by removing a test rather than adding one.

What is undone is the MERGE, not the store: a volume that fills at 14:00 leaves the yesterday written at 13:30 still answering. The snapshot is a deepcopy - measured, a shallow copy let a store holding 12 persisted points answer 24.

Evidence: 29/29 green. Six mutations, no survivors, purged tree each run with the mutated line greped off disk. One of them exposed an instrument defect now recorded in MUTATION.md: it printed 1 FAIL when the true figure was 3, because an f-string operand called len(read.points) and .points on a no_data raises Absence by design, killing the harness mid-section. One check earlier and it would have read as a survivor. A mutation's red count is a lower bound until the run is seen to reach the end of its section.
2026-09-10 08:52:09 +02:00
adminandClaude Opus 5 c45893ffad FCST-06: "cannot persist" becomes one question, asked once, about the outcome
checks / unit (pull_request) Successful in 7m14s
price_history.py's docstring states its rule unconditionally - "A store that
cannot persist now records nothing, and both of its answers say the same
thing" - and FCST-04 enforced it against `not self.ok` only, which is one
SYMPTOM: the durable volume is missing. `ok` is about the DIRECTORY, and a
read-only remount and a full disk both leave it True. Reproduced on 5059e28
against two real volumes, a /data bind-mounted read-only and a full tmpfs:

    last_error = 'could not write ([Errno 30] Read-only file system)'
    words      = '2026-01-14 (24 points over 24 h); 2026-01-15 (24 points
                  over 24 h) [could not write (...)]'
    yesterday()= series, 24 points
    ON DISK    = {}

`words` and `yesterday()` were describing two different stores, and the 24
points were gone on the next restart with nothing saying so - the
silently-forgetting cache that rule exists to refuse, reached by the condition
it did not name. No shipped path reaches it: /data is the Supervisor's durable
volume and this module is its only writer. The defect is that a guarantee the
module states about itself did not hold in one condition.

Route taken: `_remember()` refuses to retain what it could not write, and the
docstring's claim stands as written. It is now ONE derived predicate rather
than two places testing two symptoms. "Can this store persist?" cannot be
answered BEFORE the attempt - a directory that existed and was writable a
moment ago is not a write that completed - so it is answered after it, once,
by the only thing holding the evidence: `_save()` returns whether the file now
holds what memory holds. No volume is one False, an OSError is another.
`_remember()` keeps only what that one answer says was kept, and the
`not self.ok` test in `_remember()` is GONE rather than joined by a second one.
FCST-04's M4 and FCST-05's HIGH were the same shape, so a third cause now needs
no third branch.

What is undone is the MERGE, not the store. The state restored is the last one
that IS on disk, so a site whose volume fills at 14:00 goes on answering the
yesterday it wrote at 13:30 - that record survives a restart, so answering it
is not a promise the store cannot keep. It stops adding to it. The snapshot is
copy.deepcopy: a one-level copy shares the day record the merge rebinds
`points` on, and measured with one, a store holding 12 persisted points
answered 24 after a failed merge.

Not fatal, deliberately: §6 and this module's opening - a broken cache must not
be a broken controller, and an add-on that will not start because /data filled
up is a worse defect than this one. `_save()` reports and returns; the shipped
resolve_tariff() path still gives a provider plan with no §11.4 degradation.
What changed is that the failure is now acted on rather than only logged.

test_price_history.py section 5d drives it on two REAL failures with different
syscalls and different errnos - a directory where the file goes (os.replace,
EISDIR) and a volume moved out from under an open store (mkstemp, ENOENT) -
because the rule is enforced on the outcome and one cause would grade a
coincidence. DOCS.md's "where there is no /data" sentence is widened to the
condition the code now holds to.

MUTATION.md gains what M4 exposed: a mutation whose red is a TRACEBACK reports
however many checks ran before it and grades none after it. M4 first printed
1 FAIL; the true figure is 3. An f-string operand read `len(read.points)`, and
`.points` on a no_data raises `Absence` by design - why()'s rule arriving in
what a check PRINTS rather than in what it asserts. `npts()` is now beside
why(), and a red count is a lower bound until the run is seen to reach the end
of its section.

6 mutations, purged tree, every one RED on named checks, no survivors:
M1 self._days = before -> dict(self._days)          +10 B  9 red
M2 _save() OSError handler returns True             -1 B   9 red
M3 _save() returns True on `not self.ok`            -1 B   3 red
M4 self._days = before -> self._days = {}           -4 B   3 red
M5 copy.deepcopy -> dict                            -9 B   3 red
M6 `if not self.ok: return` back in _remember()     +43 B  1 red

All 29 test files green. Nothing deployed; no option added; config.yaml and
app/ behaviour on a healthy /data untouched. No version number picked - that
is the open decision the Unreleased block names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 08:43:17 +02:00
admin 5059e28860 Merge pull request 'FCST-05: one byte stops stopping the add-on, and the write path gets the read path's standard' (#74) from FCST-05 into release/1.0
Both defects were found by the QA pass on merged FCST-04, and both are the same shape: a corrupt price cache taking the controller with it.

HIGH: _load() caught (JSONDecodeError, OSError) and UnicodeDecodeError is neither - it is a ValueError. One non-UTF-8 byte in /data/price_history.json escaped to Controller.__init__, which is unguarded, and the add-on did not start. The except is now (ValueError, OSError) - the content family beside the bytes family - rather than the two members known today, because a third ValueError subclass nobody thought of is exactly how this arrived. The new fixture writes real bytes and asserts the old fixture's string IS valid UTF-8, so the pair cannot collapse back into one.

MEDIUM: a malformed `start` under the day the feed was writing raised out of _remember() on every refresh forever - the add-on stayed up while the plan retired after 90 min and arbitrage stayed off, with no self-heal, and `words` reported the store healthy throughout. Read and write paths now share one validator, and the merge key is the validated instant (which also fixes a lexicographic sort putting the 25-hour day's second 02:00 first).

Also: the row counts what it says it counts, and two false claims of strengthening in _audit's docstring are corrected - a differential over the realisable domain returns 0 weaker, 0 stronger, and that differential is now a committed check with FCST-03's audit frozen beside it. PROC-01: a right decision with a fabricated reason is still a defect.

_audit completeness was NOT attempted, and the reason is measured rather than asserted: the rule as stated takes the horizon checks red on the shipped fill and a healthy feed, because `published` is the whole read while the fill emits only [since, until). The rule is wrong, not merely absent; making it right needs the window in the signature. Separate card.

Evidence: 29/29 green. 12 mutations, all red, each on a fresh tree copy fingerprinted against clean. One survivor was found and closed during the work - two rungs reported into one last_error, so a -1 byte deletion on the half of the fix that tells an operator the cache self-healed survived the whole suite. TEST-16's masking pattern, inside a check written to close a false-health defect. Recorded in MUTATION.md.
2026-09-10 01:13:41 +02:00
28 changed files with 6442 additions and 357 deletions
+603 -1
View File
@@ -14,6 +14,599 @@ if it has, it needs 0.10.0. Whoever deploys decides.
|---|---|
| _this branch_ | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart |
| _this branch_ | FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard |
| _this branch_ | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
| _this branch_ | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
| _this branch_ | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
| _this branch_ | TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch |
| _this branch_ | TARIFF-12 - a supplier outage stops deleting the plan on its first failed fetch, and TARIFF_MAX_AGE_S starts governing |
| _this branch_ | TARIFF-14 - the price a trade is decided against is the price of the interval being traded |
| _this branch_ | TARIFF-07 - three plugins that never fetched a price are withdrawn, and a site configured on one is told so **⚠️ BACKWARD-INCOMPATIBLE** |
⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. `price_provider` set to any of those three is now **refused at
startup** — `SystemExit(78)` (`EX_CONFIG`), the add-on does not start — where
it was accepted before. That is as backward-incompatible as it gets, and it is
**the point of the change** rather than a side effect of it: see the next
paragraph for why starting was the worse outcome. Same shape as 0.8.0's
`slew_w` rule, and the same instruction follows — **read the live options and
check `price_provider` against the withdrawn list BEFORE `ha addons update`.**
That check is NOT performed by this branch and cannot be.
⚠️ **THE REFUSAL IS THE MIGRATION, BECAUSE THERE WAS NOTHING TO MIGRATE.** All
three pointed at endpoints that **do not exist and never did** — verified by
hand on 2026-09-13: a 404 from a gateway with no route, a TLS hostname the
certificate does not cover, and a 404 with an empty body. Every fetch any of
them ever made failed, and §11.4 turns a failed fetch into a **silent** fall
back to the static profile. So a site configured on one of these has been
running on `static_profile` — **or on nothing at all, where none was set** —
since the day the id was entered, with nothing on its status page ever
differing from a working provider that never found a cheap hour. The refusal
names the id, the endpoint, what that endpoint actually answers, which tariff
has really been in force, and what to set instead. **Starting quietly is what
hid this; it is not an option that was on the table.**
⚠️ **THERE IS NO ALIAS AND THERE WILL NOT BE ONE.** These were retail tariffs
of the form `B × spot + A` with **per-contract** coefficients that live behind
the customer's supplier login; CREG's tariff-card pages 404 and VREG was
unreachable, so there is no public index to read them from. An alias to
`generic_dayahead` — which reads *wholesale* — would have to invent the numbers
a battery is priced against. A formula provider is the planned answer and is a
separate card.
### TARIFF-07 - three plugins that never fetched a price, withdrawn
**`engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy` are gone from
`PROVIDERS` and from every surface that listed them.** They did not stop
working. **They never worked.** Verified by hand on 2026-09-13, before anything
was deleted:
| id | endpoint | observed |
|---|---|---|
| `engie_dynamic` | `https://api.engie.be/dynamic/be` | **404**, Kong `no Route matched with those values` |
| `totalenergies_dynamic` | `https://api.totalenergies.be/prices/dynamic` | **TLS failure** — cert is `CN=pge-apigee-dev.direct-energie.com`, host not in the SAN list |
| `eneco_eddy` | `https://api.eneco.be/eddy/prices` | **404**, empty body |
They are also **structurally impossible**, not merely unfinished. ENGIE's
`(B × eSpot_15) − A` coefficients are per-contract and sit behind the
klantenzone login, so no public unauthenticated endpoint could return a given
customer's price; there is no regulator index to read coefficients from; and
**"Eddy" is not an Eneco product at all** — Eneco's dynamic product is *Zon &
Wind Dynamisch*, indexed to Belpex/EPEX **day-ahead**, not imbalance.
**The schema keeps all three ids on purpose, and that is the whole reason the
refusal is reachable.** `price_provider` is a `list(off|…)` enum and the
Supervisor validates the options file against it *before the add-on runs*. Drop
a retired id from the enum and an existing config naming it becomes
schema-invalid: the operator gets an opaque `expected one of ...`, the add-on
never starts, and `validate.py`'s message is never seen — 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 so the
refusal cannot be taken away by an `@register` somebody adds back.
**`PriceProduct.REALTIME` and the widening path survive with no shipped
declarer.** `eneco_eddy` was their sole declarer, so `_WIDE`,
`Uncertainty.REALTIME_PRODUCT` and `WIDEN_FACTOR`'s 2.5 now have none. They are
kept: it is the AC2 seam — declare `REALTIME`, get the wide regime, edit no
framework code — and the first real imbalance product would otherwise have to
rebuild it. ⚠️ **No shipped plugin declares `PriceProduct.REALTIME` until one
does**, `test_price.py` section 5 pins that so the emptiness reads as intent
rather than a dead branch, and section 10 keeps the path live from a class
declared outside `price.py`. See
`DECISIONS/TARIFF-07-withdrawing-three-plugins.md`.
⚠️ **FOUR FALSE CLAIMS ARE CORRECTED, AND THEY ARE WHY THIS CARD EXISTS.**
* `eneco_eddy` was called a "Belgian imbalance product" in `DOCS.md`,
`app/price_forecast.py`, `app/price_history.py`, `app/static_tariff.py`,
`DECISIONS/FCST-04-the-day-that-is-carried.md` and twice in this file. It is
**false** — Eneco's dynamic product is day-ahead-indexed. The two historical
entries here are **annotated in place** rather than rewritten; the record of
what the project believed is part of the record.
* The quarter-hourly examples those surfaces used `eneco_eddy` for are now
**Belgian day-ahead itself**, which really does settle per quarter since the
15-minute MTU of 2025-10-01 — `generic_dayahead` reads 96 points a day. The
mechanism was always right; only the example was invented.
* `export_price_eur_kwh`'s docstring claimed *"no shipped supplier publishes a
dynamic injection curve"*. **Also false.** Eneco publishes
`0,1 × BELPEX-H − 1,188` on the same card as its consumption formula,
identical on the January and September 2026 cards, and ENGIE publishes
`(B × eSpot_15) − A`. What is true: an injection curve is the **same
two-coefficient shape** with **its own** coefficients and its own VAT
treatment — injection is VAT-exempt, consumption is at 6% — so the scalar is
a **simplification an operator enters by hand, not a reflection of the
market**. `DOCS.md` says the same.
* `_points_from_json()` is deleted with the plugins it served. It read a
`{"data": [...]}` document **no feed anybody here has ever reached
publishes** — invented alongside these three, and graded only against
fixtures written from the same guess. TARIFF-08 found that loop on the
response side for `generic_dayahead`; this is the rest of it.
**`DOCS.md` now answers the question a Belgian dynamic customer actually has.**
The provider table is two rows. The new prose is explicit that
`generic_dayahead` gives wholesale EPEX BE plus flat markup and taxes scalars,
which **approximates** a retail dynamic tariff and **cannot express
`B × spot + A`** — it has no multiplier, and it cannot represent a negative
offset, which `price_markup_eur_kwh: float(0,1)` could not hold in any case. A
formula provider is named as the planned answer and is a separate card.
⚠️ **One check had to be rebuilt rather than deleted.** `test_price.py` 4b
proved `code_leaks()`'s docstring strip is load-bearing by finding a shipped
docstring a raw scan false-positives on — and that docstring was
`engie_dynamic`'s. With it gone the check would have **passed on a tree with
the strip deleted**: a check that had quietly stopped being one. The stimulus
is constructed in the test file now, and the shipped set asserted clean
separately.
Nine mutations driven, all red; the table is in the decision record. Two carry
a **+0 B** delta and two end in a traceback whose red count is therefore a
lower bound — both flagged there, per `MUTATION.md`.
### TARIFF-10 - a configured provider is fetched, whether or not you trade
**`amain()` started the background tariff refresh only when
`arbitrage_enabled` was on, and that option ships `false`.**
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
only caller of `price_history.carry()` - so on a shipped install with a price
provider configured **nothing was ever fetched**: no plan resolved,
`/data/price_history.json` never created, the *Price history* status row
reading `empty` for the life of the install, and TARIFF-09's short-horizon
warning unable to fire because there was no fetch to warn about.
Measured over a 40 h soak of two instances side by side on the same feed:
| | shipped default + provider | same, arbitrage armed |
|---|---|---|
| feed requests | **0** | fetching |
| `/data/price_history.json` | `present: false` on **all 802 samples** | written |
| resolved plan | none | 132 points |
⚠️ **`DOCS.md` has described the fixed behaviour since the commit that wrote
the gate.** OPT-04 (`bdd7f18`) added the refresh task, the
`arbitrage.enabled()` guard on it, and the sentence *"turning a provider on
still fetches, validates and publishes prices and changes no charging
decision"* — all in one change. `config.yaml` has said the same since
TARIFF-02 (`852fc7c`). The documentation and the code have therefore disagreed
for as long as both existed, and nothing compared them. `config.yaml` and both translations carried the same
claim, plus a second one that had gone stale in the other direction (*"nothing
consumes the price series yet"*, untrue since OPT-04). All four now say what
the add-on does.
**The gate is `tariff_refresh_wanted()`, and it is a STRICT WIDENING.**
```
provider_configured(opts) or arbitrage.enabled(opts)
```
Every configuration that started the refresh before still starts it. The second
term is not redundant and is not there for symmetry: `price_provider: off` with
a `static_profile` and `arbitrage_enabled: true` is a configuration
`validate.py` explicitly **accepts**, and it trades - measured, at
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 EUR/kWh day/night profile,
`ARBITRAGE_CHARGE -5000 W`. Gating the refresh on the provider alone would
leave that site's `tariff_plan` at `None` forever and answer *"no tariff plan
has been resolved yet"* on every cycle - silently disarming a strategy the UI
says is running.
⚠️ **Nothing acquires a periodic HTTPS call from this.** With `price_provider`
off, `resolve_tariff()` answers out of the static profile without opening a
socket; and the true shipped default - no provider, no trading - starts **no
task at all**. What decides whether a request leaves the machine is
`price_provider`, and only that.
⚠️ **AND NOTHING TRADES THAT DID NOT TRADE BEFORE.** `cycle()`'s
`if arbitrage.enabled(self.o):` is untouched. Graded directly rather than
inferred from the gate having moved: `Controller.cycle()` is driven on a
resolved provider plan whose cheapest firm hour is now, with the peak guard
asserted clear, and `arbitrage_enabled` is the single option that moves between
the rows.
| `arbitrage_enabled` | cheapest hour is now | commanded |
|---|---|---|
| `false` | yes | **+180 W** (`loop -> 180 W`) |
| `false` | no | **+180 W** (`loop -> 180 W`) |
| `true` | yes | -5000 W (`arbitrage -> -5000 W`) |
| `true` | no | +5000 W (`arbitrage -> 5000 W`) |
**`TariffPlan.cheap_now()` is deliberately NOT wired, and that is now a
decision rather than an inheritance.** A provider-backed plan exists on every
configured site from this commit, so *Cheap window* - which feeds the
maintenance cycle's grid top-up, i.e. the one place a price reaches something
that **moves energy** without trading being armed - could now read it. It does
not: `self.cheap` still comes from `static_status()`, and on a provider-only
site (`static_profile` empty) that is `False` on every cycle, so no paid grid
top-up is ever forced. Wiring it would take that from *never* to *sometimes*.
`DECISIONS/TARIFF-10-fetching-is-not-trading.md` §4 carries the argument and
the list of what adopting it would take.
**The check that would have caught this** is `test_price.py` section 13, and it
grades **reachability from a configuration**, not the shape of an `if`. The
guards in `amain()` are *evaluated* against real option dicts, whatever they are
spelled as; the call graph under `app/` is walked to see whether the tasks those
guards start can arrive at `resolve_tariff()`, wherever it has moved to; and
then the fetch is **driven** with a counting transport, because a graph is an
argument and a counted request is an observation. The shipped defect - the gate
back at `arbitrage.enabled(opts)`, **-4 B** - is red on seven named checks.
### TARIFF-12 - a returned degradation is not a refresh
**`run_tariff_refresh()` promised in its own docstring that "a failure does not
clear the plan", and enforced it with `except Exception` - a branch a supplier
outage never reaches.** `load_prices()` states the reason in its own words: it
"RETURNS, NEVER RAISES, for every failure a site can actually have", because a
degradation arriving as a traceback into the planning tick would stop the
controller regulating over an expired certificate. So a dead feed **returns** a
§11.4 plan, the `try` succeeds, and the assignment below it overwrote the good
plan unconditionally. With `static_profile` unset - the shipped default, and the
normal state of a dynamic-tariff site - `_degrade()` returns
`TariffPlan(no_data, None, "none")`: **a complete 48-hour plan replaced by
nothing.**
Measured in a 40-hour soak, `static_profile` empty, a 90-minute outage driven:
| Brussels | refresh | plan |
|---|---|---|
| 17:26:12 | 55 | `dynamic_dayahead from generic_dayahead, 141 h, provisional` |
| **17:56:12** | **56** | **`no tariff - generic_dayahead: ClientConnectorError: Cannot connect to host`** |
| 19:26:12 | 59 | restored, one refresh after the feed returned |
`origin: none`, `n_points: 0` for all 30 outage samples. The plan was gone ~27
minutes after the last good fetch, **at the first failure** - so
`TARIFF_MAX_AGE_S` (5400 s) never governed anything, and there was no
serve-stale window at all: a transient blip cost exactly what a long outage did.
**The question is now asked once, after the attempt, from the evidence that
exists** - FCST-06's answer, one module over. The evidence is
`TariffPlan.from_feed` (did this resolution observe the configured feed) and the
shipped `fresh_tariff_plan()` (is an observation still current). A degradation
replaces a standing observation only once that observation has **aged out**, so
`TARIFF_MAX_AGE_S` is now the serve-stale window it reads like.
**A §11.4 static fallback does not replace a fresh provider plan either**,
although it carries real prices. Every point `_degrade()` builds is
`provisional=True`, so `firm_points()` is empty, `arbitrage()` refuses and
`cheap_now()` is False - everything a consumer may act on is already off - while
a day-ahead price for a given hour does not change once published. The fallback
is less actionable and no more current; it takes over when the observation
retires, which is what §11.4 is for. The rule is **not** symmetrical: a standing
plan that is itself a degradation is replaced by the next one, or an outage
would freeze the operator's static profile at the window it was first generated
for.
**`tariff_at` does not move on a kept plan.** It is the moment of the fetch that
produced the plan standing, `fresh_tariff_plan()` judges the age from it, and an
unmoved timestamp is the only thing that tells a kept plan from a freshly
fetched one. Re-stamping it would be the refresh asserting the feed answered
when it did not - the "freeze at its last value and look current forever" hazard
the same docstring names.
**What did NOT change.** The persisted price history still does not back the
plan during an outage: FCST-03 exists to stop stale prices being acted on, and
"recent" is not "current". Nothing about the control loop, the arbiter, or the
30 s tick - `cycle()` still reads two attributes and awaits no fetch.
`test_arbitrage.py` section 8b drives the whole chain with **nothing stubbed but
the socket**: the transport raises the way aiohttp does, `load_prices()` returns
`no_data`, `_degrade()` builds the §11.4 plan, and the shipped
`run_tariff_refresh()` decides. A check that patched `resolve_tariff` to *raise*
would have passed against the defect. Eleven mutations, eleven reds, no
survivors - see `DECISIONS/TARIFF-12-a-degradation-is-not-a-refresh.md`.
**Merged after TARIFF-10, which widened *which installs run this task*, and the
two configurations it newly keeps alive are graded rather than reasoned about.**
A provider with trading disarmed is the case this guard was designed for and is
covered by every driven check here, because nothing in `run_tariff_refresh()`
reads a trading option at all - asserted on the AST symbols rather than on the
source text, after two drafts of that check went red on a correct tree for
saying "arbitrage" in a docstring and in a log line. `price_provider: off` + a
`static_profile` resolves to the CONFIGURED product (`origin: "static"`), so
`standing.from_feed` is false on every refresh and the guard never latches: the
plan is regenerated for the current window exactly as it always was. Both are
driven, and a mutation that makes the guard depend on `arbitrage.enabled()` is
red on five named checks.
### TARIFF-14 - the trade was decided against the wrong price
**On the only price feed that works, `_Series.at()` returned the wrong point,
and the error grew with depth into the series.** It asked
`s <= u < s + 1 h` of every point **in list order** and returned the **first**
match. On the hourly shape every fixture in this repository carried before
TARIFF-08 that is right by accident; Belgium moved to a **15-minute MTU on
2025-10-01** and the live day-ahead feed returns **96 points at 900 s**, where
it is wrong twice over. Measured against the shipped code at `5059e28`:
```
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14 ...
ask + 0min -> 02:00 0.10
ask +15min -> 02:00 0.10 <-- WRONG (wanted 02:15)
ask +45min -> 02:00 0.10 <-- WRONG
ask +60min -> 02:15 0.11 <-- WRONG (wanted 03:00)
ask +75min -> 02:30 0.12 <-- WRONG (wanted 03:15)
```
Inside the first hour the `:00` point wins because its hour has not run out.
**From the second hour on the answer is not even the top of the hour** - the
first point still inside its own hour wins - so the error compounds.
**What it cost is not "a slightly wrong price".** `arbitrage._act()` tests
`here.start == v.cheapest.start` / `== v.dearest.start` to decide whether this
interval is the buy one or the sell one. That identity almost never held, so
**the strategy idled through exactly the interval it exists to trade**,
reporting *"this hour is neither the buy hour nor the sell hour"* - and read
§8.3's `here.provisional` and §17's `price < 0` off the same wrong point.
`static_tariff.py` emits hourly points, so **static sites were never affected**;
this was reachable only down the dynamic path TARIFF-08/09 made work.
**The rule now, in one sentence: a point covers from its own start until the
next point's start, and never more than one hour.**
| | before | after |
|---|---|---|
| 900 s | `at(02:15)` -> the 02:00 point | -> the 02:15 point |
| 3600 s | `at(02:30)` -> the 02:00 point | **unchanged** |
| **mixed** `[900, 3600]` | each point one hour wide | **each point takes its own gap** |
The mixed case is not an edge case: FCST-04's carry-forward emits **hourly
carried points beside quarter-hourly firm ones**, and the soak measured spacing
`[900, 3600]` in **70 % of samples**. A window derived from one global spacing
would be wrong in the normal steady state.
**The one-hour cap is load-bearing.** FCST-04 leaves an hour *out* of the
series when nothing published it and yesterday has no value either, and
`at()` raising `Absence` for that hour is what sends the caller to the static
profile (§11.4). Uncapped, "until the next point's start" would stretch the
point before a three-hour hole across the whole hole and answer every instant
in it with a stale price - filling holes, introduced by the fix for a different
defect.
**The last point's window is a decision and it is written down.**
`DECISIONS/TARIFF-14-the-window-a-point-covers.md` §2 argues it: the last point
is given **the same span as the point before it** (one hour when it is the only
point). A flat one-hour tail would answer **00:30 tomorrow with today's 23:45
price**, which is reaching for a neighbour across a day boundary. The cost of
the choice - a mixed series ending at the firm/carried seam loses its last 45
minutes to `Absence` - is stated there rather than buried, and the direction is
the safe one.
**`at()` now reads a point's neighbours, so it would depend on the series being
sorted - and it establishes that itself** rather than trusting the three
producers that happen to guarantee it today. The lookup index is sorted by UTC
start once, at construction; a series handed its 96 points in reverse order
answers identically, which is a check. `covers()` shared the defect exactly
(it has no rule of its own - it asks `at()`) and is fixed by the same change;
`window()` did not (it grades a point's **start** against a range and asks
nothing about duration), and its docstring now says so.
**Nothing about §9.3 or a point's `start` changed.** `PricePoint` still has
three fields; the span is derived from the neighbours, not stored.
Graded by `test_price.py` section 14 - 900 s, 3600 s, **mixed**, holes, the
tail, caller order, `covers()`/`window()` - and end to end by
`test_arbitrage.py` section 10, which drives `decide()` over a 96-point
quarter-hourly day whose cheapest and dearest quarters are deliberately **not**
on a whole hour. Eight mutations, all red, including a **zero-byte** one on the
tail rule; the table with byte deltas and the checks that were NOT reached is
in the decision note §6.
### TARIFF-09 - the feed is asked for the range the caller wanted
**`generic_dayahead` took `since` and `until` and requested the bare feed URL,
which answers one Brussels day.** Everything past today was then filled from
yesterday's profile by FCST-04, flagged provisional, and §8.3 drops provisional
hours from the trade set - so **the optimiser never saw tomorrow as firm, at any
hour of any day.** Measured on `4257e38` at 2026-09-10 09:00 CEST over the
shipped 48 h horizon, live feed, yesterday seeded:
| | 48 h horizon, one-day feed |
|---|---|
| firm | **15 whole hours** (60 quarter-hour points) |
| carried, provisional | **33 hours** - 24 on D+1, 9 on D+2 |
| permitted cycle | buy 11:45, sell 19:45 - **both on 2026-09-10** |
| firm local days | `[2026-09-10]` |
The ceiling was structural, not incidental: with a one-day window, at hour *H*
of the local day a 48 h horizon is at most **24−H hours firm and 24+H carried**.
At 23:00 that is **1 firm hour and 47 carried**, and the remaining firm spread
(0.0464 EUR/kWh) no longer clears the default 0.10 round-trip threshold at all -
so arbitrage was refused outright. This is **not** what FCST-04's fill is for:
that covers the window until the ~13:00-14:00 CET auction, after which a healthy
feed publishes tomorrow in full.
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns 192 quarter-hours across two
local days, committed unmodified beside TARIFF-08's captures. The plugin simply
never asked.
**The range is now derived from `since`/`until` on the BRUSSELS calendar**, as
two inclusive dates. Not the UTC calendar: 22:30 UTC in summer is already
tomorrow in Belgium, so a UTC-derived start asks for the day the controller has
just left and drops the day it is standing in, for the last one or two hours of
every day. That is `price_history._bdate()`'s lesson one layer up, with a worse
consequence - `_bdate()` misfiles an hour, this never fetches it. `until` is
exclusive, so a horizon ending exactly at local midnight asks for one day fewer
than the naive reading gives, and an inverted window collapses to a single day
rather than a backwards range.
Same code path, same window, a feed serving both days:
| | before | after |
|---|---|---|
| firm hours of 48 | 15 | **39** |
| carried, provisional | 33 | 9 |
| firm local days | 1 | **2** |
| permitted cycle | one calendar day | **buys on one day, sells on the next** |
The 9 remaining carried hours are the tail of the horizon that runs into a third
Brussels day, which no day-ahead market publishes. FCST-04 still fills them and
still flags them; what changed is what is firm, never what may be acted on.
**Before publication nothing regresses, and that is graded rather than
assumed.** Asking for tomorrow before the auction returns fewer days than
requested - measured, at 09:46 CEST the two-day request came back byte-identical
to the one-day body - and that stays a `series` of what exists, with FCST-04
filling the remainder. A `no_data` there would switch a working site's arbitrage
off every morning.
⚠️ **`price_feed_url` is the EXACT URL and the plugin appends nothing to it, so
an overriding site keeps the short horizon.** That is a decision, not an
oversight: `start`/`end` are this feed's spelling, the provider declares a second
host whose parameters are different, and rewriting a URL an operator typed is
how a request nobody intended gets made. **It is not silent** - the plugin logs a
`WARNING` at fetch time naming the consequence and the `start..end` it would
otherwise have asked for. See
`DECISIONS/TARIFF-09-the-range-and-the-override.md`.
The plugin contract - `fetch(self, opts, since, until, transport)` hands every
plugin the range - is still **not** enforced, because three shipped plugins point
at endpoints nobody has verified and a rule obliging them to pass a range would
be satisfied by an invented parameter name. Which plugins consume the range is
pinned as a literal in `test_price.py` instead, so the next supplier has to
decide rather than inherit the silence.
### FCST-06 - the second way to be a store that cannot persist
**A price cache that could not write went on answering a yesterday the file did
not contain.** `price_history.py`'s docstring states its rule unconditionally -
*"A store that cannot persist now records nothing, and both of its answers say
the same thing"* - and FCST-04 enforced it against `not self.ok`, which is one
**symptom**: the durable volume is missing. `ok` is about the DIRECTORY, and a
read-only remount and a full disk both leave it `True`. Reproduced on `5059e28`
against two real volumes - a `/data` bind-mounted read-only and a full tmpfs:
| | with a `/data` that cannot be written |
|---|---|
| `last_error` | `could not write ([Errno 30] Read-only file system)` |
| `words` | `2026-01-14 (24 points over 24 h); 2026-01-15 (24 points over 24 h) [could not write (...)]` |
| `yesterday()` | **series, 24 points** |
| on disk | `{}` |
`words` and `yesterday()` were describing two different stores, and the 24
points were gone on the next restart with nothing saying so - the
silently-forgetting cache that rule exists to refuse, reached by the condition
it did not name. **No shipped path reaches it**: `/data` is the Supervisor's
durable volume and this module is its only writer. The defect is that a
guarantee the module states about itself did not hold in one condition.
**"Cannot persist" is now ONE derived predicate, asked once, about the
outcome.** It cannot be answered *before* the attempt - a directory that existed
and was writable a moment ago is not a write that completed - so `_save()`
returns whether the file now holds what memory holds: `False` with no durable
volume, `False` on an `OSError` from the write, `True` otherwise. `_remember()`
keeps only what that one answer says was kept, and **the `not self.ok` test in
`_remember()` is gone** rather than joined by a second one. FCST-04's M4 and
FCST-05's HIGH were the same shape - a rule enforced on the conditions somebody
thought of - so a third cause now needs no third branch.
**What is undone is the MERGE, not the store.** The state restored is the last
one that *is on disk*, so a site whose volume fills at 14:00 goes on answering
the yesterday it wrote at 13:30 - that record survives a restart, so answering
it is not a promise the store cannot keep. It simply stops adding to it. The
snapshot is a `copy.deepcopy`: a one-level copy shares the day record the merge
rebinds `points` on, and measured with one, a store holding 12 persisted points
answered **24** after a failed merge.
⚠️ **A failed write is still not fatal, and that was a constraint rather than an
oversight.** §6 and this module's opening: a broken cache must not be a broken
controller. An add-on that will not start because `/data` filled up is a worse
defect than the one being fixed, so `_save()` reports and returns - the failure
stays in the log, in `last_error`, in `words` and in `yesterday()`'s reason -
and the shipped `resolve_tariff()` path still returns a provider plan with no
§11.4 degradation. What changed is that it is now *acted on*.
`test_price_history.py` section 5d drives it on two real failures with different
syscalls and different errnos - a directory where the file goes (`os.replace`,
EISDIR) and a volume that moves out from under an open store (`mkstemp`,
ENOENT) - because the rule is enforced on the outcome and one cause would grade
a coincidence. See `DECISIONS/FCST-06-cannot-persist-is-one-question.md`.
### TARIFF-08 - no network price provider had ever parsed a live response
**`generic_dayahead` could not read one byte of `api.energy-charts.info`, and
had never been able to.** `_points_from_json()` asks for `doc["data"]`, a list
of `{start, price_eur_mwh}` objects. That document does not exist. Fetched live
on 2026-09-10, the feed returns **columnar** parallel arrays and no `data` key
at all:
```
top-level keys: ['deprecated', 'license_info', 'price', 'unit', 'unix_seconds']
unix_seconds: 96 UTC epoch seconds, first 1788991200, spacing 900 s
price: 96 floats, first 216.08
unit: 'EUR / MWh'
```
Driving the shipped parser against that body: `SourceUnavailable: the feed
carried no 'data' list`. Since `generic_dayahead` is the only shipped provider
whose endpoint anybody has reached, **dynamic pricing did not work at all**.
⚠️ **AND §11.4 MADE THAT SILENT, WHICH IS WHY IT LASTED.** A parse error becomes
`no_data`, becomes the static profile, becomes arbitrage off - the *correct*
degradation, and it says nothing whatsoever about the cause. A site would have
sat on the fallback indefinitely while the status page reported only that the
feed was down.
⚠️ **THE SUITE COULD NOT SEE IT BECAUSE EVERY FIXTURE WAS BUILT FROM THE
PARSER'S OWN ASSUMPTION.** `test_price.py`, `test_price_history.py` and
`test_static_tariff.py` each built `json.dumps({"data": [{"start": ...,
"price_eur_mwh": ...}]})` - a shape invented here, published by nobody. The
checks graded the parser against the parser's guess: a closed loop that cannot
notice the guess is wrong, and no amount of coverage inside it would have. The
loop is broken by **bytes nobody in this repository wrote**: two unmodified
response bodies are committed under `scenarios/`, one quarter-hourly
(2026-09-10, 96 points at 900 s) and one hourly (2025-09-10, 24 points at
3600 s - the same endpoint before Belgium's 15-minute MTU), with URL, retrieval
time, licence-as-returned and a sha256 recorded beside them in
`energy-charts-be-price-captures.json` and **re-derived** by the replay, so a
fixture edited to make a check pass stops being a capture. The remaining
invented fixtures are renamed `synthetic_row_feed` at every call site.
**The columnar document is read by its own function, `_points_from_columnar_json()`.**
Not by teaching the row parser to sniff for both: a parser that decides which
document it received is a parser that can decide wrong, and a wrong-but-
parseable response then becomes a price. Two shapes, two functions, and each
plugin names the one its feed publishes. Feeding the row shape to
`generic_dayahead` is now a graded `no_data`, so the sniff cannot come back
quietly.
⚠️ **UNEQUAL ARRAYS ARE REFUSED RATHER THAN ZIPPED.** `zip()` stops at the
shorter list and says nothing, so a truncated `price` array would have produced
a **real price series with its tail missing** - and the tail of a Belgian day is
the evening peak. That is worse than no series: `no_data` switches arbitrage
off, whereas a short series is traded on as though complete, and nothing
downstream can tell them apart. Refused in both directions, with the two lengths
in the reason.
⚠️ **A UNIT CHANGE IS REFUSED FOR A REASON `_validate_raw()` CANNOT COVER.** The
plugin converts with a fixed `0.001`; if the feed ever published EUR/kWh under
the same keys, every price would come out a **thousand times too small** and
`MAX_PLAUSIBLE_EUR_KWH` would not fire, because it only bounds prices that are
too large. DATA-02's fetcher already refused on this field; the shipped path
does now too.
**`unix_seconds` are UTC instants, said so explicitly**
(`datetime.fromtimestamp(t, timezone.utc)`); §9.3's rendering at the Brussels
offset stays where it already lived, in `assemble()`, rather than being copied
into the plugin where a mutation could not tell it from a no-op. A published
`null` is dropped and logged with a count - a missing quarter is not a free
quarter - while an all-null body is `no_data` named by this parser rather than
by the boundary validator.
**End to end against the live feed, 2026-09-10:** `resolve_tariff()` returns a
`TariffPlan` with `origin='provider'`, **96 quarter-hourly points**, spacing
`[900]` s and no decimation, first point `2026-09-10T00:00:00+02:00` at 0.30608
EUR/kWh all-in. FCST-04's H1 fix was never theoretical - 900 s is the shape of
the real feed.
⚠️ **WHAT THIS DOES NOT FIX.** `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy` are untouched: their URLs are unverified, no real body can be
captured for them, and withdrawing them is TARIFF-07's open decision. Their
fixtures are now named `synthetic_row_feed` so nothing reads them as evidence
that those feeds parse. And `generic_dayahead` still ignores `since`/`until`
and asks for no date range, so the default URL returns one published day - a
full 48 h horizon is reached only through §8.3's carry-forward fill.
### FCST-05 - what FCST-04's own contract still did not hold
@@ -125,7 +718,12 @@ spread it would have created was never compared against anything.
leaves the local hours it never held **out of the series** - §11.4 for those
hours, and asking for one raises rather than answering. Correct behaviour,
and the wording is now the behaviour.
* ⚠️ **A sub-hourly feed keeps every print.** `eneco_eddy` is the imbalance
* ⚠️ **A sub-hourly feed keeps every print.** [⚠️ **The claim that follows is
FALSE and TARIFF-07 corrected it: `eneco_eddy` was not an imbalance product,
was not an Eneco product at all, and its endpoint never existed. The real
quarter-hourly feed is Belgian DAY-AHEAD since the 15-minute MTU of
2025-10-01, which is what `generic_dayahead` reads. The behaviour described
below is unchanged.**] `eneco_eddy` is the imbalance
product and settles per **quarter**; the fill walks whole hours to decide
what needs carrying, and that is not a rule about what a published hour may
contain. Every published point inside the horizon passes through by identity,
@@ -3341,6 +3939,10 @@ somebody else's tariff. What is mechanised is reachability; what stays human is
truthfulness.
**The product type is declared, not inferred from the plugin's name.**
[⚠️ **FALSE, corrected by TARIFF-07: `eneco_eddy` was never a real-time /
imbalance product — Eneco's dynamic product is day-ahead-indexed — and the
plugin has been withdrawn. The declaration mechanism described here is
unchanged and is now exercised only from outside `price.py`.**]
`eneco_eddy` is a real-time/imbalance product and gets widened arbitrage
thresholds; that reaches a consumer through `capabilities().product` and
`volatility_regime()`, never through a name comparison. `test_price.py` asserts
@@ -75,8 +75,9 @@ Every point still carries `provisional=True`, so the **flag never lies** — wha
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 (`eneco_eddy`'s
current quarter, §9.2) are the same three fields carrying the same flag. The
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:
@@ -139,8 +140,16 @@ file constructs and still holds a reference to.
## 4. A sub-hourly feed passes through whole; the fill only decides which hours are missing.
`eneco_eddy` is the Belgian imbalance product and settles **per quarter**, and
`price.py`'s `_validate_raw()` accepts any strictly-increasing spacing — so a
⚠️ **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
@@ -0,0 +1,125 @@
# FCST-06 — "cannot persist" is one question, and it is asked about the outcome
Two decisions. The first is the one the ticket put up for judgement; the second
is the one that decides what a store answers *after* it has failed to write.
---
## 1. One derived predicate, not two places testing two symptoms — yes
`price_history.py`'s module docstring states, unconditionally:
> ⚠️ AND THAT RULE IS CARRIED OUT IN `_remember()`, NOT ONLY IN `_save()`. …
> A store that cannot persist now records nothing, and both of its answers say
> the same thing.
It was enforced against `not self.ok`, and `ok` is **about the directory**: it
says `/data` was there when the store opened. A read-only remount and a full
volume both leave it `True`. So the second way to be a store that cannot
persist — `ok=True`, the directory present, `_save()` failing — was outside the
one condition the rule named, and in that state `words` printed two full days
beside the write error while `yesterday()` answered 24 real points, from memory,
until a restart dropped them without a word.
**The ticket's question was whether "cannot persist" should be one derived
predicate rather than two places each testing a different symptom. It should,
and the reason is not tidiness — it is that the enumeration cannot be
completed.** FCST-04's M4 enforced the rule on the symptom that was in front of
it. FCST-05's HIGH was the same shape — an `except` clause naming the one
content-failure member somebody had met (`json.JSONDecodeError`) instead of the
`ValueError` family — and its fix was to name the family. This is the third instance in
one module, and it is the last place in this file where the rule can be spelt
twice. A fourth cause — an `OSError` subclass, a filesystem that accepts the
`mkstemp` and refuses the `os.replace`, a container whose volume is unmounted
mid-refresh — needs no fourth branch now, because none of them is asked about.
### Where the one question can be asked, which is the whole design
**"Can this store persist?" cannot be answered before the attempt.** A
directory that exists and was writable a moment ago is not a write that
completed; there is no pre-check that is not itself a guess, and adding one
(`os.access`, a probe write) would be a *third* symptom test — precisely what
the ticket rules out. So the predicate is an **outcome**:
_save() -> bool # True only if the file now holds what memory holds
Both ways of not persisting are the same answer from the same call: no durable
volume returns `False` without touching the disk, an `OSError` out of the write
returns `False` after recording why. `_remember()` reads that one answer and
keeps only what it says was kept. **The `not self.ok` test in `_remember()` is
gone** — not moved, gone — because there is nothing left for a second test to
add, and a check in `test_price_history.py` 5d asserts its absence from the
source.
⚠️ **That last check is deliberately a source check, and habit 4 is why it is
allowed to be.** The claim "there are not two places testing two symptoms" is a
claim about the *shape of the code*, not about a behaviour — the behaviour is
identical whether the rule is enforced once or twice. Every other check in 5d
is driven.
### What was NOT done, and why
- **Not a `can_persist()` helper.** It would have to answer before the attempt,
which is the thing that cannot be done honestly, and it would be a second
place to keep in step with the first.
- **Not a probe write at `_open()`.** It would make `ok` mean something new, it
would write to `/data` on every start, and a volume that is writable at 08:00
and full at 14:00 would still defeat it. A predicate that can go stale is a
symptom test wearing a predicate's name.
- **Not making a failed write fatal.** §6, and this module's own opening: a
broken cache must not be a broken controller. An add-on that will not start
because `/data` filled up is a worse defect than the one being fixed. The
store degrades; it does not raise. `_save()` reports and returns.
---
## 2. The MERGE is undone, not the store
When `_save()` answers no, `_remember()` restores the state the read started
from — **not** an empty one.
The alternative considered was to clear `self._days` outright, on the argument
that a store which cannot write is a store that should stop answering. It is
wrong, and measurably so: the state being restored is the last one that **is on
disk**, so answering it is not a promise the store cannot keep. A site whose
volume fills at 14:00 goes on answering the yesterday it wrote at 13:30 —
through the restart, because that record is in the file. What it stops doing is
*adding* to it.
Clearing would throw away a good yesterday that survives a restart in order to
punish a write that failed, and it would make the morning fill worse on exactly
the day the operator is already dealing with a full disk.
That gives the file the invariant its rule was always describing, stated once
in the class docstring:
> `self._days` **never holds a point the file does not.**
⚠️ Not *"mirrors the file"* — `_usable_days()` drops records the file still
holds, and that asymmetry is deliberate. The only direction that can **invent a
yesterday** is the one named.
⚠️ **And the snapshot is `copy.deepcopy`, not `dict(self._days)`.** A one-level
copy shares the day *records*, and the merge rebinds `points` on the ones it
touches — so the "restored" store would answer the merge that never reached the
file. Measured: with a shallow copy, a store holding 12 persisted points
answered **24** after a failed merge. It would also be correct only for as long
as nobody makes the merge mutate a `points` list or a row in place, which is the
same "correct for the conditions someone thought of" this ticket is about. The
store holds at most `KEEP_DAYS + 2` records and writes 48 times a day; a copy
that is right by construction is worth more than the allocations.
---
## What this does not change
- **No shipped path reaches the defect.** `/data` is the Supervisor's durable
volume and this module is its only writer, so on a real install `_save()`
succeeds and nothing above is on the hot path. The fix is that a guarantee the
module states about itself now holds in the condition it did not.
- **The failure was already visible and still is** — `_LOG.error("could not
persist price history: %s")` and `last_error`, reported by both `words` and
`yesterday()`'s reason. What changed is that it is now *acted on*.
- **Nothing about the prices.** The series served is the feed's own, every
carried point is still flagged provisional, and a broken cache is still a
short horizon rather than a §11.4 degradation.
@@ -0,0 +1,230 @@
# 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.
@@ -0,0 +1,141 @@
# TARIFF-09 — asking the feed for the horizon, and what `price_feed_url` means
Two decisions. The first had no defensible alternative once it was measured;
the second did, and the alternative is argued below rather than dismissed.
---
## 0. The measurement this card exists for
`generic_dayahead.fetch()` accepted `since` and `until` and requested the bare
`https://api.energy-charts.info/price?bzn=BE`. That URL names no range, and the
feed answers **one Brussels day**.
Measured on the merged tree (`4257e38`) at 2026-09-10 09:00 CEST, 48 h horizon,
live feed, yesterday seeded through the shipped read path:
```
60 firm points (15 whole hours) + 33 carried hours of 48 requested
carried: 24 h on 2026-09-11, 9 h on 2026-09-12
arbitrage(0.10): allowed, spread 0.4645, buy 11:45 and sell 19:45 -- BOTH on 2026-09-10
firm local days = [2026-09-10]
```
Everything past today was carry-forward, flagged provisional, and §8.3 drops
provisional hours from the trade set. **The optimiser never saw tomorrow as
firm, at any hour.** The ceiling was structural rather than incidental: with a
one-day window, at hour *H* of the local day a 48 h horizon is at most
**24−H hours firm and 24+H carried** — 15/33 at 09:00, and **1/47 at 23:00**,
where the remaining firm spread (0.0464 EUR/kWh) no longer even clears the
default 0.10 round-trip threshold and arbitrage is refused outright.
That is not what FCST-04's fill was built for. FCST-04 covers the
pre-publication window until the ~13:00–14:00 CET auction, after which a
healthy feed hands over tomorrow in full.
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns **192 quarter-hours across
two local days**, committed unmodified as
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`. The
plugin simply never asked.
After the change, same code path, same window, a feed serving both days:
**39 firm hours and 9 carried**, firm across **two** Brussels days, and the
permitted cycle buys on one day and sells on the other. Graded in
`test_price_history.py` section 1c against both feed states.
---
## 1. The range is derived on the **Brussels** calendar, from `since`/`until`
`since` and `until` are **instants**; the API takes **dates**. Those are not
the same question, and the conversion is where the day gets lost.
`brussels_date_range()` returns two inclusive `date`s:
* the first is `brussels(since).date()`. **Not `since.date()`, and not the UTC
date.** 22:30 UTC in summer is already tomorrow in Belgium, so a UTC-derived
start asks for the day the controller has just left and drops the day it is
standing in — for the last one or two hours of *every* day. This is
`price_history._bdate()`'s lesson one layer up, with a worse consequence:
`_bdate()` misfiles an hour, this never fetches it.
* the last is `brussels(until).date()`, **stepped back a day when `until`
falls exactly on local midnight**, because `until` is exclusive and that
instant is the first tick of a day nobody asked for. Without it every
whole-hour horizon requests one extra day — harmless on this feed, but it
makes the request a claim the caller never made.
* an empty or inverted window collapses to a single day rather than producing
a backwards range, which no feed has a defined answer for.
Both ends are graded, and both are driven red by mutation — including the two
that swap `brussels()` for the UTC date (`test_price.py` section 2c).
---
## 2. `price_feed_url` is the **exact URL**, used as given. The plugin appends
nothing.
The alternative — merge our `start`/`end` into whatever query string the
operator supplied — was considered and rejected. Three reasons, in the order
they decided it:
1. **`start`/`end` are energy-charts' spelling, and this provider declares a
second host.** `generic_dayahead.capabilities().egress_hosts` is
`("api.energy-charts.info", "web-api.tp.entsoe.eu")`. That second host's
day-ahead date parameters have not been verified by this card and are not
guessed at here — but they are not `start`/`end`. So an override to the
*other declared host* — a case the declaration itself anticipates — is
exactly where merging appends two parameters that host ignores, producing
the short horizon anyway, with a URL nobody typed as the receipt.
2. **Merging means re-serialising a URL somebody else wrote.** Parsing and
rebuilding reorders parameters, collapses duplicates and re-escapes values.
"Override the feed" is what the option already says it means, and an
override that is silently rewritten is not one.
3. **Blind appending is not on the table at all.** `dated_feed_url()` refuses
to add a query string to a base that already has one, so
`?bzn=BE?bzn=BE&start=…` cannot be produced by this module even by a later
edit that folds the parameters back into the constant.
⚠️ **The cost is real and it is therefore NOT a doc footnote.** An overriding
operator gets exactly the short horizon this card exists to fix. So the plugin
emits a `WARNING` on the shipped `goodwe.price` logger at **fetch time**, naming
the consequence — one Brussels day, the rest carried and flagged, §8.3 dropping
them from the trade set — and naming the `start..end` it *would* have asked
for. §11.4 is what makes a price defect quiet; an operator who has taken the
short horizon has to be able to find out from the log that they did.
The warning is graded, with a control that the default path stays silent, and
a mutation that downgrades it to `debug()` takes that check red.
**Fetch time rather than startup**, because that is where the URL actually in
use is known. The trade-off is one line per refresh (the tariff task, not the
30 s control tick). If the status page ever grows a price-source row, this is
the sentence it should carry.
---
## 3. The plugin contract: `fetch(self, opts, since, until, transport)`
The signature promises every plugin the range, and until this card **none of
the four networked plugins used it**. The obvious follow-up is to enforce the
promise. It is **not** enforced, and the reason is TARIFF-08's:
Three of the five shipped plugins point at endpoints nobody has ever verified
(TARIFF-07 is the open decision on withdrawing them). A rule obliging them to
pass a date range would be satisfied by an **invented parameter name** — which
is the parser-graded-against-its-own-guess defect moved from the response to
the request, and it would ship looking like a guarantee.
What exists instead is a **roster pin** in `test_price.py` section 2c: which
plugins reference their `since`/`until` is asserted as a literal, derived from
the registry and the AST. A sixth supplier, or a change to any of the three,
has to edit that line and make the decision rather than inherit the silence.
It is named as a source-shape pin, not as evidence that any feed parses;
`generic_dayahead`'s behaviour is graded separately by the URL it really
requests.
**Recommendation for whoever picks up TARIFF-07:** enforce it at the moment
those three endpoints are either verified or withdrawn, not before. If they are
withdrawn, `generic_dayahead` and `manual_csv` are the whole roster and the
rule is one line — `manual_csv` reads a whole file and is windowed downstream,
so it would be the single documented exemption.
@@ -0,0 +1,277 @@
# TARIFF-10 — fetching a price and trading on one are two decisions
**Status: DECIDED 2026-09-12 — Option A. If a price provider is configured,
prices are fetched. Trading stays a separate switch (`arbitrage_enabled`), and
this card moves nothing that moves energy.**
Filed with TARIFF-10. Follows [[TARIFF-09]] and [[FCST-04]]; §4 settles the
limitation FCST-04 recorded and did not decide.
---
## 1. The defect, and why it was invisible
`app/main.py`'s `amain()` read:
```
if arbitrage.enabled(opts):
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
```
`run_tariff_refresh` is the **only** caller of `resolve_tariff()`, which is the
**only** caller of `price_history.carry()`. `arbitrage_enabled` ships `false`.
So on a shipped install with a provider configured:
* no HTTPS request was ever made to the feed;
* no plan was ever resolved, so `self.tariff_plan` stayed `None` forever;
* `/data/price_history.json` was never created;
* the *Price history* status row read `empty` for the life of the install;
* TARIFF-09's short-horizon warning could not fire, because it is logged on a
fetch and there was no fetch.
Measured, not inferred — a 40 h soak, two instances, one feed:
| | shipped default + provider | same, arbitrage armed |
|---|---|---|
| feed requests | **0** | fetching |
| `/data/price_history.json` | `present: false`, **802/802 samples** | written |
| resolved plan | none | 132 points |
⚠️ **AND FOUR SURFACES ALREADY DESCRIBED THE FIXED BEHAVIOUR.** `DOCS.md`
§*Energy price providers*: *"With `arbitrage_enabled: false`, which is the
shipped default, turning a provider on still fetches, validates and publishes
prices and changes no charging decision."* `config.yaml` and both translations
said the same, and additionally carried a claim that had gone stale in the other
direction — *"nothing consumes the price series yet"*, untrue since OPT-04
shipped the consumer. The code was the only surface that disagreed with all of
them, and no check compared the two.
The dating is worth being exact about, because it says this was never a
regression. `git log -S` puts the refresh task, its `arbitrage.enabled()` guard
**and** the DOCS sentence in the *same commit* — OPT-04, `bdd7f18`;
`config.yaml`'s version of the claim goes back further, to TARIFF-02,
`852fc7c`. The prose and the code disagreed from the moment both existed.
## 2. Why a check on the `if` would not have caught it
The `if` was exactly what somebody meant to write, and its comment argued for
it honestly:
> ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on reads the
> resolved plan … so on a default install this task would be network traffic in
> aid of an attribute nobody reads.
That was true when OPT-04 wrote it and stopped being true when FCST-04 gave the
plan a *store* and a *status row*. A pin on the expression would have gone red
on the fix and green on the defect. What was wrong is the **set of
configurations from which a fetch is reachable**, so that is what
`test_price.py` section 13 grades:
1. the guards on every `asyncio.create_task` in `amain()` are **evaluated**
against real option dicts — whatever the guard is spelled as;
2. the call graph under `app/` is walked to see whether the coroutines those
guards start can arrive at `resolve_tariff()` — wherever it has moved to;
3. and then the fetch is **driven**, with a counting transport, a real
`Controller` and a real store path, because a reachability graph is an
argument and a counted request is an observation.
⚠️ **Step 3 grades the gate only because it starts the task the way `amain()`
would.** The driver asks `_fetchers(opts)` — the evaluated guard — before it
calls the coroutine. Measured: with the shipped defect restored **and** that one
line defeated (`if False and "run_tariff_refresh" not in names:`), the four
driven rows go **green** while only the structural rows stay red. Without it
they are decoration, which is MUTATION.md's habit 4 in a harness.
What the instrument **cannot** see is printed on every run: a coroutine started
by anything but `create_task`; a guard that is not an enclosing `if` in
`amain()`; anything outside `app/`; and — because the graph is keyed by **name**
— two same-named functions as one node, which makes every *reachable* answer an
**upper** bound and every *not reachable* answer the strong one.
## 3. The gate, and why it is an `or`
```
def tariff_refresh_wanted(opts: dict) -> bool:
return provider_configured(opts) or arbitrage.enabled(opts)
```
The first term is what this card adds. **The second term is the old gate, kept
verbatim, and it is not there for symmetry.**
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
configuration `validate.py` explicitly **accepts** (rule 7 refuses only the
no-prices-at-all case), and it **trades**. Measured on this branch:
```
opts: price_provider off, arbitrage_enabled true,
cycle_cost_threshold_eur 0.05,
static_profile 0.20 night / 0.284 day
plan : static_profile from static_profile, 48 h, 48 points
WITH the plan -> ARBITRAGE_CHARGE -5000.0 W
"cheapest firm hour at 0.2000 EUR/kWh, 0.0840 below the
dearest and clear of the 0.0750 this cycle had to cover"
WITHOUT the plan -> None "no tariff plan has been resolved yet"
```
Gating the refresh on the provider **alone** — which is the literal reading of
the ticket's second acceptance criterion — would leave that site's
`tariff_plan` at `None` forever and answer *"no tariff plan has been resolved
yet"* on every cycle. That is a strategy the UI says is running and that cannot
fire: the exact state `validate.py`'s OPT-04 rules exist to refuse, arrived at
from inside the code instead of from the configuration.
So the gate is a **strict widening**: every configuration that started the task
before still starts it, and `test_price.py` grades that as its own row rather
than leaving it to prose.
⚠️ **The AC's stated harm is still fully met, and it is worth being exact about
this rather than quietly declaring victory.** The criterion reads *"with
`price_provider: off`: no fetch task at all, whatever `arbitrage_enabled` says
— an operator with no provider must not acquire a periodic HTTPS call."* The
letter of it is not met in one configuration (provider off, trading armed),
because that configuration keeps the task it already had. The harm it names is:
* with `price_provider` off, `resolve_tariff()` returns `static_plan()` — pure
arithmetic, **no socket**. Driven and counted: **0 requests**.
* the true shipped default — no provider, no trading — starts **no task at
all**.
Nobody *acquires* a periodic HTTPS call from this card, and the only thing that
decides whether a request leaves the machine is `price_provider`. **If the
owner wants the literal reading instead, it is a one-token change** (drop the
`or` term) **and it costs the site in the measurement above its trading.** That
is the trade, stated rather than chosen silently.
## 4. `TariffPlan.cheap_now()` — available, and deliberately NOT consumed
**Decision: do not wire it in this card. `self.cheap` stays on
`static_status()`.**
FCST-04 recorded that `cheap_now()` has no production caller on the provider
plan: `main.read_inputs()` assigns `self.cheap, self.tariff_words =
static_status(o, now)`, which builds its **own** static plan and calls
`cheap_now()` on that. Under Option A a provider-backed plan now exists on every
configured site, so the limitation stops being structural and becomes a choice.
**It is not cosmetic, and that is the whole reason for the decision.**
`self.cheap` is read in `cycle()` by the maintenance cycle's cheap-window grid
top-up — the add-on's one shiftable load, and the one place a *price* reaches
something that **moves energy** on an install that has not armed trading.
⚠️ **And the direction it would move is not "a slightly different window".**
`static_status()` returns `(False, "no static profile")` whenever
`static_profile` is empty, so today a provider-only site — a plausible shape
for a dynamic-tariff install, and the shape this card makes work — has
`self.cheap = False` on **every** cycle and never forces a paid grid top-up at
all. Wiring `cheap_now()` would take that from *never* to *sometimes*. That is
a strictly larger behaviour change than this card's headline, and it belongs to
whoever owns the maintenance cycle rather than to the ticket that made it
reachable.
Three further reasons, in the order they would bite:
1. **Two sources, one attribute, and the loser is silent.** `validate.py`
already refuses `static_profile` alongside the Home Assistant price
entities *because* `read_inputs()` picks one and the other disappears. A
provider plan would be a third writer of the same attribute with no rule
saying which wins.
2. **The freshness question is not answered for this consumer.** The plan
retires after `TARIFF_MAX_AGE_S` and `fresh_tariff_plan()` returns `None`.
Arbitrage has an answer for that (refuse). The maintenance top-up has none:
"the plan expired" would have to mean *not cheap*, and *not cheap* on this
path means **wait for sun**, which is a real decision nobody has taken.
3. **The comparison basis changes, and so does the answer on a bad feed.**
`cheap_now()` is strictly-below-the-mean-of-the-**firm**-hours, and it is
`False` when THIS hour is provisional or is not in the series at all
(FCST-03's rule: this hour decides, not the whole plan). So a feed that
published nothing, or an hour the §8.3 fill could not cover, reads *not
cheap* where a `static_profile` reads whatever the clock says. Defensible
either way — and it is a change to the shipped maintenance cycle that needs
its own measurement rather than a side effect of this one.
**What adopting it would take**, so the next card does not start from scratch:
* a rule for which source wins when both a provider plan and a
`static_profile` are configured, enforced in `validate.py` the way the
entity/profile conflict already is;
* a decision on what an **expired** or **absent** plan means for the grid
top-up, stated as a default rather than left to `None`;
* `read_inputs()` splitting `self.cheap` from `self.tariff_words`, which are
currently one assignment from one function;
* a check that the maintenance top-up actually fires and does not fire on the
provider plan — driven through `cycle()`, not asserted on the attribute;
* and the *Tariff* status row, which FCST-04 pinned as a **pair** with
`DOCS.md` in `test_price_history.py` §9: it does not report the provider
plan, DOCS does not claim it does, and moving one without the other is
already red.
## 5. The *Arbitrage* status row, re-read and left alone
`main.status_payload()` renders `("Arbitrage", "off (arbitrage_enabled)" if
self.arb is None else self.arb.explain())`. `self.arb` is assigned only inside
`if arbitrage.enabled(self.o):` and `arbitrage.decide()` never returns `None`,
so `self.arb is None` **iff** the option is off. The string still names the
option that is actually off, and under Option A it gains a job it did not have:
on a site with a provider configured it is the row that tells an operator the
prices arriving on the *Price history* row above are moving nothing. Unchanged,
with a note at the call site saying it was checked.
## 6. What this card did not do
* It did not touch `run_tariff_refresh()`'s body (TARIFF-12 is there).
* It did not change `cycle()`'s `if arbitrage.enabled(self.o):`, the sizing,
the thresholds, the SoC bounds or the peak interlock.
* It did not wire the provider plan into the *Tariff* status row or into
`self.cheap` — §4.
* It did not pick a version number (PKG-06).
## 7. Mutation ledger
Every mutation applied to a **fresh `cp -a`** of the clean worktree rather than
reverted (habit 18: there is no revert to verify and no shared scratch path),
`__pycache__` purged, tree mounted `:ro`, run under `python3 -B`.
| id | mutation | Δ bytes | result |
|---|---|---|---|
| M1 | the shipped defect: gate back to `arbitrage.enabled(opts)` | **-4 B** | **RED**, 7 named checks |
| M2 | drop the `or arbitrage.enabled(opts)` term | -27 B | **RED**, 2 named checks (strict-widening + the static-profile row) |
| M3 | `tariff_refresh_wanted` -> always `True` | +8 B | **RED**, the AC2 reachability row |
| M4 | `provider_configured`: `"off"` counts as configured | -6 B | **RED**, the AC2 reachability row |
| M5 | `cycle()`'s trading gate -> `if True or arbitrage.enabled(self.o)` | +8 B | **RED**, both AC3 rows (`-5000 W` with trading off) |
| M6 | instrument: `_reaches()` always `True` | **+0 B** | **RED**, 4 checks incl. its own calibration row |
| M7 | instrument: `_task_sites()` returns `[]` | **+0 B** | **RED**, 10 checks incl. "it found 0 sites" |
| M8 | fixture: 5 minutes of telegrams instead of a whole quarter | **+0 B** | **RED**, the peak-guard precondition and both armed rows |
| M9 | M1 **plus** the harness's guard-driven start defeated | -4 B | the four DRIVEN rows go **GREEN** — see §2 |
| M10 | `provider_id()` drops `.strip()` | -8 B | **survivor**, then CLOSED — see below |
| M11 | `provider_configured()` -> `return True` | -32 B | **RED**, 4 checks |
| M12 | DOCS.md's sentence reverted to the old, vaguer claim | +0 B (docs) | **RED**, the AC4 pairing row (`fetches = True, DOCS says = False`) |
| M13 | the Dutch translation left behind while English moved | +0 B (docs) | **RED**, the both-languages row |
⚠️ **M10 was a real survivor and is recorded as one.** Dropping `.strip()` left
`test_price.py`, `test_price_history.py`, `test_static_tariff.py` and
`test_validate.py` all green: nothing in the repository fed a `price_provider`
with surrounding whitespace, and the `.strip()` predates this card — it was
carried into `provider_id()` from the three call sites it replaced. The
Supervisor's `list(off|…)` schema cannot produce `" off "`, but
`validate_startup_config()` is also `sim/inject.py`'s entry point and takes any
dict at all. Closed in the same commit by the padded-id pair in section 13;
re-run as **M10b**, red on two named checks.
⚠️ **M8 is the fixture defect this card walked into and then fenced.** The
first draft fed five minutes of telegrams. Whether that **covers** the current
billing quarter depends on where the wall clock sits inside it, so the peak
guard was blind and engaged on some runs and not others — and `decide()` refuses
outright while the guard is holding (§9.1 over §10.1). On those runs the armed
baseline did not trade, and "trading off never trades" would have been graded
on a fixture in which nothing could trade either way. It is now 900 s of
telegrams, and the guard's clear state is a **check** rather than an assumption.
## 8. What is still open
* The literal reading of AC2 (§3). One token, and it costs a measured trading
configuration its plan. **Owner's call.**
* `cheap_now()` (§4). Owner's call, with the adoption list above.
* The *Tariff* status row still shows the static-profile sentence on a
provider-backed site; the provider plan's own line goes to the log. Pinned as
a pair with `DOCS.md` by FCST-04 and untouched here.
@@ -0,0 +1,252 @@
# TARIFF-12 — a returned degradation is not a refresh
Three decisions. The first is what the ticket put up for judgement; the second
and third are what fall out of it and would otherwise be decided by accident.
---
## 0. What was actually wrong, stated once
`run_tariff_refresh()`'s docstring:
> ⚠️ **AND A FAILURE DOES NOT CLEAR THE PLAN.** … Zeroing the plan here would
> replace a coarse tariff with no tariff, which is the opposite of what §11.4
> asks for.
The guarantee was real. The condition it was enforced on was not:
```python
try:
plan = await resolve_tariff(...)
self.tariff_plan, self.tariff_at = plan, now # unconditional
except Exception as err:
_LOG.warning("tariff refresh failed (%s); keeping the plan ...")
```
`load_prices()` says in its own docstring that it **"RETURNS, NEVER RAISES, for
every failure a site can actually have"**, because a degradation arriving as a
traceback into the planning tick would stop the controller regulating over an
expired certificate. So a supplier outage — the case the docstring names first —
is a **return**: the `try` succeeds, `_degrade()` hands back a §11.4 plan, and
the good plan is overwritten. The `except` is reachable only by something
nobody expected, and nothing in the shipped path produces it.
This is the fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's
HIGH (`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), and now
`except Exception` only, for a path that never raises. **A guarantee enforced on
the condition someone thought of, rather than the condition that occurs.**
Reproduced before the fix, driving the shipped chain with nothing stubbed but
the socket — 48 h horizon, `static_profile` empty, one good fetch then a
`ClientConnectorError` 1800 s later, `TARIFF_MAX_AGE_S` = 5400 s:
```
after a good fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
after the failed fetch origin='none' n_points=0 'no tariff - generic_dayahead: ClientConnectorError: ...'
tariff_at moved: True
```
and after it:
```
after the failed fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
tariff_at moved: False
```
---
## 1. The question is asked ONCE, after the attempt, from the evidence that exists
FCST-06's answer, one module over, and for the same reason: **the enumeration
of failure conditions cannot be completed.** There is no pre-check that a feed
will answer which is not itself a guess, and adding one would be a second
symptom test beside the `except`. So the predicate is an **outcome**, read off
the object the attempt produced:
```python
standing = self.fresh_tariff_plan(now)
keep = (not plan.from_feed
and standing is not None and standing.from_feed)
```
Two facts, both of which exist by the time the question is asked:
* **`plan.from_feed`** — did *this* resolution observe the configured provider?
`TariffPlan.origin` already names the four ways a plan is obtained; this
collapses them to the one question a caller holding a previous plan has to
ask. It lives on `TariffPlan` rather than as an `origin == "provider"` at the
call site for `fresh_tariff_plan()`'s own stated reason — *a check has to be
able to assert on the shipped expression rather than on a transcription that
drifts* — and because `origin`'s vocabulary belongs to `static_tariff.py`.
* **`fresh_tariff_plan(now)`** — is a standing observation still current. It
already existed, it is already the one place the age is judged, and it is
**reused, not transcribed**. `test_arbitrage.py` 8b pins that reuse on the
AST; a copy of the comparison behaves identically until the day the two
drift, which is a claim about the code's shape, so a shape check is what
grades it (FCST-06's precedent for the one source check among driven ones).
### What was NOT done
* **Not `except Exception` widened, or a second `if` beside it.** That is the
defect's own shape — a place where the rule is spelt twice, so a fifth cause
needs a fifth branch. The `except` stays, relabelled *"not the outage path"*,
keeps the plan on the same terms, and is now honestly the
something-nobody-expected branch.
* **Not the persisted price store as a cache.** Out of scope by the product
owner's call, and it is the right call: FCST-03 exists precisely to stop
stale prices being acted on, and "recent" is not "current". Keeping the last
**fetched** plan until it ages out is a different promise from serving the
store, and that one needs its own ticket.
* **Not a `no_data`-only guard.** `not plan.has_prices` is the tempting
spelling and it is wrong — see decision 2. It leaves the §11.4 fallback free
to replace a fresh observation, and it is a 1-byte edit from the shipped
line, which is why it is in the mutation table rather than in a comment.
---
## 2. A §11.4 static fallback does NOT replace a fresh provider plan
The ticket asked this directly, because the fallback is *not* nothing: it is
the operator's own configured profile, with real prices, for the requested
window. The answer is still no, and the reason is not that it is worse data —
it is that **nothing may be acted on it, and it is no more current.**
* Every point `_degrade()` builds carries `provisional=True`. So
`firm_points()` is empty, `arbitrage()` finds fewer than two firm hours and
refuses, and `cheap_now()` is False. Both of the two things `TariffPlan`
exposes for a consumer to act on are already off.
* A day-ahead price for a given hour **does not change once published**. A plan
fetched 30 minutes ago is exactly as true about 19:00 as one fetched now; at
most `TARIFF_MAX_AGE_S` of its horizon has elapsed. The observation is not
stale in any sense the fallback is fresher than.
So replacing the observation with the fallback trades a plan with firm hours
for a plan with none, and buys no currency with it. The fallback takes over
when the observation **retires** — which is exactly the row §11.4 describes,
arrived at when there is no longer an observation to prefer.
### And the rule is deliberately NOT symmetrical
`standing.from_feed` is a term in its own right. A standing plan that is itself
a degradation **is** replaced by the next degradation, and its timestamp moves.
Without that term:
* an outage on a static-profile site would freeze the operator's profile at the
window it was first generated for, and the guard would be *"never overwrite"*
wearing a bound's name;
* and on a `price_provider: off` install — the shipped default — the plan would
flip between kept and re-derived every `TARIFF_MAX_AGE_S`, for no behavioural
gain, changing which reason the status page prints on a two-hour cycle.
Each of the three terms is the only false one in some check in 8b, which is
MUTATION.md habit 17's test for whether a suite can tell an `AND` from an `OR`.
---
## 3. `tariff_at` does not move on a kept plan
The docstring's other half is that a plan must not *"freeze at its last value
and look current forever"*. `tariff_at` is what makes that work: it is the
moment of the fetch that produced the plan standing, and `fresh_tariff_plan()`
judges the age from it.
Re-stamping it on a kept plan would be this task asserting the feed answered
when it did not. The plan would be re-certified as current at every refresh, an
outage of any length would never retire it, and `TARIFF_MAX_AGE_S` would be
**exactly as dead as it was before this card** — reachable only by the refresh
task itself dying. Leaving it alone is what makes the bound govern: the plan is
served for the age bound after its own fetch, and no longer.
It is also the whole of **"distinguishable from a freshly-fetched one"**. A
kept plan and a fetched plan are the same object shape; what separates them is
that one's timestamp advanced and the other's did not, and every consumer that
asks how old the plan is already reads that number. The kept case additionally
logs at WARNING, naming the degradation's own reason, the fetch it is keeping,
its age, and the deadline it retires at.
⚠️ **What this does not give the operator, and why it is not here.** The status
page has no row for "the last refresh failed" — the banner/level not moving
when the plan is lost is separately carded, and the ticket rules it out of
scope. This card's change makes that surface *more* truthful, not less: during
the first `TARIFF_MAX_AGE_S` of an outage there is now a real plan behind the
Arbitrage row instead of a no-plan refusal, so the row says something true for
longer. The no-plan state is entered later by up to 5400 s; it is not removed,
and the 30-minute lag reported on that row is untouched by anything here.
---
## 4. Merged after TARIFF-10 — what the wider gate changed, and what it did not
TARIFF-10 landed on `release/1.0` while this card was open. It changed **which
installs run this task** — `if arbitrage.enabled(opts)` became
`tariff_refresh_wanted(opts)`, i.e. `provider_configured(opts) or
arbitrage.enabled(opts)` — while this card changed **what the task does on a
failed fetch**. Git auto-merged the two hunks, which is exactly when the
interaction needs reading rather than trusting.
**Nothing in the decisions above changed.** Two configurations are newly
reachable, and both are now driven in `test_arbitrage.py` 8b rather than
reasoned about:
* **A provider configured with trading DISARMED** — TARIFF-10's headline case.
This is precisely the configuration the guard was designed for: the plans are
`origin == "provider"`, and a dead feed must not delete one that is still
inside `TARIFF_MAX_AGE_S`. It needs no second fixture, because **nothing on
this path reads `arbitrage_enabled` at all**, and that is asserted rather
than assumed — over the AST's symbols and the non-docstring string constants,
because two earlier drafts of that check were **red against a correct tree**:
the first matched the docstring paragraph that *says* `arbitrage_enabled` is
not read, the second matched the log line's "arbitrage, where it is armed".
A substring test over a function grades its prose as much as its behaviour,
which is habit 4 in a third costume. A fixture that set the option and
asserted no difference would have been the other defect — comparing a thing
against itself.
* **`price_provider: off` + a `static_profile`** — the configuration TARIFF-10's
`or` exists to protect, and the one where a latching guard would do real harm.
`resolve_tariff()` returns `static_plan(provisional=False)`, whose origin is
`"static"`: the CONFIGURED product, not a §11.4 row. So `standing.from_feed`
is false on every refresh, `keep` is false on every refresh, and the plan is
regenerated for the current window every 30 minutes exactly as before this
card. **Driven, twice in a row**, because a guard that latched here would
freeze a static site's plan at its first window for the life of the install —
and mutation M3, which drops the `standing.from_feed` term, is red on that
check as well as on the fallback one.
Two further facts settle the rest of the interaction:
* **This decision is strictly downstream of every side effect.** The §8.3 carry
into `price_history` happens inside `resolve_tariff()`, on the `series`
branch, before `keep` is computed — so which of two plans is kept can neither
add nor lose a recorded day, and TARIFF-10's *Price history* row is
unaffected either way.
* **The kept-plan log line was reworded.** It said "arbitrage stops with it",
which after TARIFF-10 is false on a site that fetches without trading; it now
says "arbitrage, **where it is armed**, stops with it".
All eleven mutations were **re-driven on the merged tree** rather than carried
over — the surrounding code changed, so a red against the old base is not
evidence about the new one. Eleven red, no survivors, every revert
fingerprinted. The added member is M11: making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B), red on five named checks.
⚠️ **And one process note that belongs in this file.** This card's mutation
harness was written to a shared scratchpad path and was **overwritten mid-run
by another agent's harness for a different card**, pointing at a different
worktree. That is MUTATION.md habit 18's second bullet almost verbatim. It was
caught because the harness fingerprints the tree after every revert and the
runs are what proved this worktree was never left mutated; the harness now
lives at a card-specific path. **A harness that edits source must not share a
name, a directory or a backup path with another one.**
---
## What this does not change
* **Nothing on the 30 s tick.** `cycle()` still reads two attributes and awaits
no fetch; the AST pin in `test_arbitrage.py` section 8 is unchanged and green.
* **Nothing about the prices themselves.** No series is invented, extended,
re-stamped or carried. The only question this card answers is *which of two
plans the controller holds*.
* **A `price_provider: off` install is bit-for-bit unaffected.** Its standing
plan is never `origin == "provider"`, so `keep` is false on every refresh it
will ever run, and the assignment happens exactly as it did before.
@@ -0,0 +1,349 @@
# TARIFF-14 — how long one price point covers, and what the last one does
One defect and one genuinely open decision. The defect had no defensible
alternative once it was measured; the decision — the **last** point's window —
did, and the alternative is argued below rather than dismissed.
---
## 0. The measurement this card exists for
`_Series.at()` asked `s <= u < s + 1 h` of every point **in list order** and
returned the **first** match. Run against the shipped code at `5059e28` on a
quarter-hourly series:
```
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14 ...
ask + 0min -> 02:00 0.10
ask +15min -> 02:00 0.10 <-- WRONG (wanted 02:15)
ask +30min -> 02:00 0.10 <-- WRONG
ask +45min -> 02:00 0.10 <-- WRONG
ask +60min -> 02:15 0.11 <-- WRONG (wanted 03:00)
ask +75min -> 02:30 0.12 <-- WRONG (wanted 03:15)
```
**Two failure modes, not one.** Inside the first hour the `:00` point wins
because its hour has not run out. From the second hour on the answer is not
even the top of the hour — the first point still inside its own hour wins — so
**the error compounds with depth into the series**.
Same code, same run, after the fix:
```
ask + 0min -> 02:00 0.10 ask +60min -> 03:00 0.14
ask +15min -> 02:15 0.11 ask +75min -> 03:15 0.15
ask +30min -> 02:30 0.12 ask +90min -> 03:30 0.16
ask +45min -> 02:45 0.13 ask+120min -> 04:00 0.18
```
An hourly series is **unchanged at every instant**, before and after — which is
the whole reason nothing caught this. Every price fixture in this repository
before TARIFF-08 was the invented hourly row shape, `static_tariff.py` emits
hourly points, and the DST checks count points on the 23- and 25-hour days
without ever asking `at()` for a quarter-hour. **The suite had been exercising
the one resolution on which the broken rule and the right one agree.**
Belgium moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
returns **96 points at 900 s**, so this is reachable only down the dynamic path
TARIFF-08/09 just made work. Static sites are immune.
## 0.1 What it cost, which is not "a slightly wrong price"
`arbitrage._act()` calls `_point_at(plan, now)` → `series.at(now)` and then
tests `here.start == v.cheapest.start` / `== v.dearest.start` to decide whether
this interval is the buy one or the sell one. That is an **identity test
between the matched point and the point the verdict was built from**, and on a
quarter-hourly series it almost never matched — so the strategy idled through
exactly the interval it exists to trade, reporting *"this hour is neither the
buy hour nor the sell hour"*. It also read `here.provisional` (§8.3) and §17's
`price < 0` off that same wrong point.
---
## 1. The rule
> **A point covers from its own start until the next point's start, and never
> more than one hour.**
* **900 s** → each point covers its own quarter.
* **3600 s** → each point covers its own hour. Identical to what shipped.
* **MIXED** → **each point takes its own gap.** FCST-04's carry-forward emits
hourly carried points beside quarter-hourly firm ones, and the soak measured
spacing `[900, 3600]` in **70 % of samples** — so a window derived from one
global spacing would be wrong in the **normal steady state**, not in an edge
case.
### 1.1 Why the one-hour cap is not decoration
FCST-04 **leaves holes on purpose**: an hour with no published price and no
yesterday value is left *out* of the series, and `price_forecast.py`'s module
note names `at()` raising `Absence` for it as the behaviour that sends the
caller to the static profile (§11.4 for that hour).
Uncapped, "until the next point's start" stretches the point **before** a
three-hour hole across the whole hole and answers every instant in it with a
stale price. Filling holes is the defect this module exists to refuse, and it
would have been introduced **by the fix for a different one**. Graded in
`test_price.py` 14d; the mutation that removes the cap is red there.
---
## 2. The decision: what the **last** point covers
There is no next start, and §9.3 says nothing about how wide the final interval
is — a `PricePoint` has **three fields** and must not learn a fourth, because
that is the wire format every store, replay, fixture and captured body in this
repository is written against. So the tail is a **choice**, and both candidates
are defensible.
**Chosen: the last point is given the same span as the point before it** (one
hour when it is the only point).
| | flat one hour | the span in front of it (**chosen**) |
|---|---|---|
| 96-point 900 s day, `at(23:50)` | answers 23:45 | answers 23:45 |
| the same day, `at(00:30)` **tomorrow** | **answers with today's 23:45 price** | `Absence` |
| 24-point hourly day, `at(23:50)` | answers 23:00 | answers 23:00 |
| mixed series ending at the firm/carried seam | answers for an hour | `Absence` after 15 min |
**The second row is why.** A flat one-hour tail reaches across a day boundary
and prices tomorrow morning with yesterday's last print — which is
"reaching for a neighbouring one", the single thing the `Absence` message has
always refused to do. The chosen rule never extends a point past a span the
series itself demonstrates.
### 2.1 The cost of the choice, stated rather than buried
On a **mixed** series whose last point is a carried **hourly** one sitting
behind quarter-hourly firm ones, the tail is **900 s**, and the last 45 minutes
of the horizon raise `Absence` for an hour something did carry a value for.
That is accepted, not tolerated, and the direction is the safe one:
* the caller falls back to the static profile (§11.4) instead of trading on a
span the series never demonstrated;
* the point is **carried**, so §8.3 forbids any irreversible action resting on
it regardless;
* it is at the far end of a 48 h horizon, where nothing is decided at 30 s.
Under-covering costs a missed decision. Over-covering prices a real interval
with a number that does not describe it. Those are not symmetric.
### 2.2 The degenerate case
A series of **one** point has no gap in front of it to copy and gets **one
hour** — the shipped behaviour, kept deliberately. `test_price.py` 14e states
it as its own check so it cannot decay into an accident.
---
## 3. Does `at()` now depend on sortedness? Yes — and it establishes it itself
The old rule read one point at a time and did not care about order. The new one
reads a point's **neighbours**, so it *would* depend on the series being
ascending.
It does not, because **the lookup index is sorted by UTC start at construction**
(`_cover_table()`, called once from `_Series.__init__`). No caller's list order
can reach `at()`.
That matters because the three things that "guarantee" order today each
guarantee it somewhere else:
| | what it guarantees | where |
|---|---|---|
| `_validate_raw()` | strictly increasing starts | the `load_prices()` path only |
| `price_history._parse_points()` | re-sorts by instant | the store read path only |
| FCST-04's fill | emits ascending | that one producer only |
| `PriceRead.series()` | **nothing** | every other caller, including tests |
Three producers agreeing today is not an invariant; it is a coincidence with
good references. `test_price.py` 14f drives a series handed its 96 points in
**reverse** order and asserts `at()` answers identically at every one of them —
red the moment the sort is removed.
**Duplicated starts** (refused by `_validate_raw()`, constructible by hand) give
the earlier list entry a zero-width window and the later one the real span. One
of the pair becomes unreachable through `at()`; **nothing raises**, at
construction or on the 30 s control path. Stated as a check rather than left to
be discovered.
---
## 4. `covers()` shared it. `window()` did not
* **`covers()`** has no window rule of its own — it calls `at()` — so it shared
the defect exactly and the same change fixes it. It is kept as a delegation
for that reason: a second copy of the rule is a second place for it to be
wrong.
* **`window(since, until)`** grades a point's **start** against a half-open
range and never asks how long a point lasts, so it carries no resolution
assumption at all. It is **not** a coverage query — a point that starts before
`since` and runs into it is absent from its result by design. That is now
said in its docstring and pinned by a check, because the next reader will ask.
It has **no callers** in `app/`.
---
## 5. What this card does NOT fix, with its shape
`price_forecast._walk()` — §8.3's irreversible-action gate — iterates
`committed_hours()`, which steps in **whole hours**, and asks `series.at(u)` at
each. On a quarter-hourly series that now reliably returns the hour's `:00`
point (it previously returned something earlier and arbitrary), so the gate
reads **one point per hour** and never looks at the `:15`/`:30`/`:45` flags.
A partially-provisional hour would therefore be gated on its first quarter
alone. **Two things keep it off every shipped install, and both are
circumstantial rather than structural:** `price_permits()`/`begin_phase()` have
**no caller in `app/main.py`** — the §12 price-justified entry point is built
and not yet wired — and FCST-04 carries **whole** hours while
`generic_dayahead` publishes whole days, so the four quarters of an hour are
firm or carried together anyway. Both are facts about the *callers and
producers*, not properties of the *gate*, so this is written down here rather
than left for the first sub-hourly feed or the first wiring-up to find. Fixing it means teaching
`committed_hours()` about resolution, which is a data-model question this card
deliberately does not open.
---
## 6. Mutations
Eight, each purged before the run (TEST-15), each reverted with the tree
fingerprint checked afterwards (MUTATION.md habit 18), backup at a `mktemp`
path per invocation.
⚠️ **Re-driven in full on the MERGED tree** after TARIFF-10 landed on
`release/1.0` (base `88c59a9` -> `559f0eb`). All eight give the same verdicts
and the same red-check counts as before the merge, which is the answer that
matters: TARIFF-10 added 650 lines of checks to `test_price.py` and none of
them moves under any of these edits, because they grade `resolve_tariff()`'s
*reachability from a configuration* and not the lookup. Clean `app/price.py` on
the merged tree = **`90d8ca31f7c0109d`, 85953 B** (pre-merge it was
`30d1a56f0117e571`, 83742 B; the difference is TARIFF-10's `provider_id()` /
`provider_configured()`, which this card does not touch).
| | mutation | Δ | red in |
|---|---|---|---|
| M1 | `at()` back to the shipped fixed one-hour scan over `self.points` | +68 B | **24 named checks**: `test_price.py` 14b ×4, 14c ×3, 14d, 14e ×3, 14f, 14g; `test_arbitrage.py` 10b ×6, 10c ×3, 10d ×2 |
| M2 | drop the one-hour cap | −68 B | `test_price.py` 14d |
| M3 | flat one-hour tail instead of the span in front | **+0 B** | `test_price.py` 14e ×3, 14g — 4 checks |
| M4 | trust the caller's list order — drop the sort | −61 B | `test_price.py` 14f |
| M5 | `covers()` grows its own hour-wide rule | +95 B | `test_price.py` 14e, 14g |
| M6 | `window()` becomes a coverage query | −37 B | `test_price.py` 14g |
| M7 | the window's end becomes inclusive | +1 B | `test_price.py` 14b ×4, 14c ×4, 14d, 14e ×4, 14f ×2, 14g — 16 checks |
| M8 | the window's start becomes exclusive | −1 B | `test_price.py` 14b ×4, 14c ×4, 14d, 14f ×2, 14g, 14h, plus the pre-existing section 6 check — 14 checks |
**M3 is the zero-byte one and it is the tail decision itself** — the case
MUTATION.md names as the risky size, caught by the check written for it.
**What the arbitrage file does NOT see, with sizes.** M2, M4, M5, M6 and M8
leave `test_arbitrage.py` green, and that is honest rather than a gap to round
off: they are `at()`-level properties (holes, caller order, `covers()`,
`window()`, the start boundary) that no arbitrage fixture reaches, because the
probes are seven minutes into a slot and the plans have no holes. The one that
matters end to end — M1, the shipped rule — is red in **both** files, on the
named check `THE PROPERTY THE DEFECT BROKE`.
---
## 7. TARIFF-10 landed on the base while this was open — what it changes here
Base moved `88c59a9` → `559f0eb` mid-card. The question is not whether the
branches merge; it is whether TARIFF-10 makes any sentence above **false**.
**In `app/price.py` the two do not meet.** TARIFF-10 added `provider_id()` and
`provider_configured()` beside `price_option()` and routed `load_prices()`
through them. That is the option-reading layer; it touches nothing in
`_Series`, `at()`, `covers()`, `window()` or `_cover_table()`. Git merged the
file without a conflict, and the merged `at()` is byte-identical to the one the
mutations above were driven against.
**Behaviourally it widens who has a series at all, and that cuts one way only.**
`run_tariff_refresh()` now runs on installs with `arbitrage_enabled` **off**, so
a provider-configured site has a standing plan — and therefore a `_Series` —
where before it had none. **It does not add a new `at()` consumer to the 30 s
tick**, which is the claim worth checking rather than assuming:
| caller of `at()`/`covers()` | reached by a non-trading install? |
|---|---|
| `arbitrage._point_at()` (`arbitrage.py:300`) | no — trading only |
| `static_tariff.TariffPlan.price_at()` / `.words` `here` (`:750`, `:819`) | on the tick these are reached through `static_status()`, which builds a **`static_plan`** — hourly points, immune. `main.py` calls only `plan.words` on the fetched plan, which counts points and never looks one up |
| `price_forecast._walk()` (`:562`, `:568`) | **`price_permits()` and `begin_phase()` have no caller in `app/main.py` at all** — §12's price-justified entry point is built and not yet wired |
So TARIFF-10 neither creates nor closes any exposure this card is about. What it
does do is make the defect's reach larger the moment such a site arms trading,
because the series it would trade against now exists on every configured
install rather than only on armed ones. That strengthens the case for the fix
and changes nothing in its reasoning.
**In `test_price.py` both cards appended a section 13.** Nothing is dropped:
TARIFF-10's stays **13**, this card's is renumbered **14** (which is also this
card's number), and the temp-directory teardown TARIFF-10 put at the end of the
file stays at the end of the file. The two sections share no fixture, no option
dict and no helper name; TARIFF-10's grades `resolve_tariff()`'s *reachability
from a configuration* and drives real fetches with a counting transport, and
**none of its checks moves under any of the eight mutations above** — measured,
not assumed.
---
## 8. TARIFF-12 landed too — a plan may now be 90 minutes old when it is asked
Base moved `559f0eb` → `a6e64fb` while this was open. TARIFF-12 stops a
returned §11.4 degradation from overwriting a standing plan that is still
inside `TARIFF_MAX_AGE_S`, so **a served plan can now be up to
`3 × TARIFF_REFRESH_S = 5400 s` old** where before it was at most one refresh
interval. `at()` is what answers *"what does it cost right now"* out of that
older plan, so the question is fair and the answer is written out rather than
assumed.
**Per-point span: unaffected, structurally.** Every window is derived from the
neighbouring points' **absolute `start` instants**. Those are `datetime`s inside
a frozen dataclass inside a tuple — they cannot move, and `_cover_table()` runs
once at construction over data that cannot change afterwards. A plan's *age* is
a property of `fetched_at` / `tariff_at`, never of a window. **The index cannot
go stale**, which is a small positive under TARIFF-12 rather than a risk: a kept
plan now lives longer, and there is nothing in it that needed recomputing.
**The one-hour cap: unaffected.** It bites only where a gap exceeds an hour,
i.e. at a hole FCST-04 left. Walking further into a series crosses more windows;
each is still resolved on its own neighbours.
**The tail: reachable for the first time, and the choice made in §2 is the one
that survives it.** This is the half worth spelling out.
* At the **shipped 48 h horizon** the tail is unreachable by staleness. A plan
is served for at most 90 minutes, so `now` is at most 90 minutes into a
~48 h series — the last point is still about 46½ hours away.
* But `price_horizon_h` is `int(1,72)` in `config.yaml`, so an operator **can**
configure a **1 h** horizon. A 90-minute serve-stale window against a 1 h
series puts `now` genuinely past the end. That is not hypothetical; it is a
supported configuration meeting a supported outage.
* Under the **chosen** rule a quarter-hourly 1 h series ends at `:45` and that
point's window closes at the top of the next hour: `at()` raises `Absence`,
`_point_at()` returns None, `_act()` idles with its "no price for this
instant" reason, and §11.4 sends the site to the static profile.
* Under the **rejected** flat one-hour tail the same stale plan would have gone
on trading against the `:45` print for **up to 45 minutes after the interval
it describes had ended** — pricing an outage with a number for a quarter that
is over.
So TARIFF-12 does not weaken the tail decision. It is the first thing that makes
it reachable on a shipped configuration, and it independently selects the same
answer. ⚠️ **Note which direction that runs**: the more stale a plan is allowed
to be, the more the tail rule is doing real work, so it should be re-read if
`TARIFF_MAX_AGE_S` is ever widened again.
**`TariffPlan.from_feed` cannot produce a series this code has not seen.** It is
a pure predicate on `origin` (`== "provider"`), and TARIFF-12's decision keeps or
replaces a **whole plan object**. It never splices two reads into one series, so
it cannot hand `at()` an unsorted or duplicated point list — the case §3 covers
defensively and that nothing shipped produces.
**Re-graded on this base.** All 29 files green. `app/price.py` is byte-identical
to the TARIFF-10 merge (`90d8ca31f7c0109d`, 85953 B) because TARIFF-12 does not
touch it. **M1** re-driven: +68 B, red on the same **24 named checks** across
both files. **M3** re-driven: **+0 B**, red on the same 4. This branch
contributes **zero diff** to `app/main.py` and `app/static_tariff.py`.
+187 -21
View File
@@ -1346,21 +1346,35 @@ does, and only after you have read why.
> release ships the five provider plugins, the canonical all-in format they
> emit, the config that selects one, and — new in OPT-04 — a price-arbitrage
> strategy that trades on them. With `arbitrage_enabled: false`, which is the
> shipped default, turning a provider on still fetches, validates and publishes
> prices and changes no charging decision.
> shipped default, **selecting a provider is what starts the fetch**: the feed
> is read every 30 minutes, validated, and the day recorded in
> `/data/price_history.json`, which the *Price history* status row reports and
> the add-on log names at every refresh. No charging decision changes.
>
> ⚠️ **That has been the documented behaviour since OPT-04 and was not the
> actual behaviour until TARIFF-10.** The background refresh was started only
> when `arbitrage_enabled` was on — and it ships `false` — so an install with a
> provider configured fetched nothing at all, never created the store, and read
> *Price history: empty* for as long as it ran. If you configured a provider and saw an empty
> Price history row, that is what you were looking at. Trading is unchanged and
> is still `arbitrage_enabled` alone.
>
> `static_profile` below needs no network at all, so its cheap window is
> computed on every cycle and reaches the maintenance cycle's grid top-up
> whether or not arbitrage is armed.
> whether or not arbitrage is armed. ⚠️ **And it is still the only source of
> that window.** A provider-backed plan now exists on every configured site,
> but *Cheap window* and the maintenance cycle's grid top-up read
> `static_profile` and nothing else — a deliberate limit, recorded with what it
> would take to change in `DECISIONS/TARIFF-10-fetching-is-not-trading.md`.
| option | meaning |
|---|---|
| `price_provider` | `off` (default) or one of the five plugins below |
| `price_provider` | `off` (default) or one of the two plugins below |
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
| `price_taxes_eur_kwh` | Levies and VAT, EUR/kWh. Same rule |
| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below |
| `price_csv_path` | Where `manual_csv` reads its file |
| `price_feed_url` | Override the plugin's default feed. Only a host the plugin itself declares is accepted |
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
| `price_horizon_h` | How far ahead to ask for, hours |
| `price_timeout_s` | Per-request timeout |
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
@@ -1372,11 +1386,76 @@ arbitrage decision wrong by that amount, in a direction nothing reports.
| plugin | product | the published figure contains | the add-on adds |
|---|---|---|---|
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
| `engie_dynamic` | day-ahead | energy + Engie's markup | taxes |
| `totalenergies_dynamic` | day-ahead | energy + TotalEnergies' markup | taxes |
| `eneco_eddy` | **real-time / imbalance** | everything, all-in | nothing |
| `manual_csv` | day-ahead | everything, all-in | nothing |
⚠️ **Three plugins were withdrawn in TARIFF-07, and if your configuration names
one of them, read this.** `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy` are gone. They did not stop working — **they never worked.** Each
pointed at an endpoint that does not exist and never did:
| id | its endpoint | what that endpoint actually answers |
|---|---|---|
| `engie_dynamic` | `https://api.engie.be/dynamic/be` | **404**, gateway error `no Route matched with those values` |
| `totalenergies_dynamic` | `https://api.totalenergies.be/prices/dynamic` | **TLS failure** — that hostname is not in the server's certificate |
| `eneco_eddy` | `https://api.eneco.be/eddy/prices` | **404**, empty body |
Every fetch any of them ever made failed, and a failed fetch falls back to the
static tariff **silently** — so a site set to one of these has been running on
`static_profile`, or on nothing at all, since the day it was configured, with
nothing on the status page saying so. The add-on now **refuses to start** on one
of these ids and tells you all of the above by name. That is deliberate: a
silent fallback is what hid this for as long as it lasted.
There is no automatic migration and there cannot be one. These were *retail*
tariffs of the form `B x spot + A` with **per-contract** coefficients that live
behind your supplier's customer login; there is no public index to read them
from. Any alias would have to invent the numbers your battery is priced against.
(`eneco_eddy` was doubly wrong: **"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.)
#### So what should a Belgian dynamic-tariff customer do today?
**Honestly: `generic_dayahead` gets you close, and you should know exactly how
close.** It reads the wholesale EPEX BE day-ahead curve and adds your
`price_markup_eur_kwh` and `price_taxes_eur_kwh` as **flat** scalars. Your real
Belgian dynamic contract is a **formula**:
```
consumption: price = B x spot + A <- B and A are in YOUR contract
```
The add-on cannot express that yet. Two gaps, and the second is the harder one:
* **No multiplier.** `B` is typically slightly above 1 (it scales the wholesale
price before anything is added). A flat markup cannot scale anything, so the
*shape* of your curve is right but its *amplitude* is not — the peaks and
troughs the optimiser trades against are compressed relative to your bill.
* **No negative offset.** `A` is frequently **negative** on injection and can be
on consumption. `price_markup_eur_kwh` is `float(0,1)` in the schema — it
**cannot hold a negative number at all**, so that contract shape is currently
inexpressible rather than merely approximate.
What that means in practice: arbitrage decisions made on `generic_dayahead` are
made against a curve that is *correlated* with your bill, not equal to it. For
deciding **which hours** are cheap that is usually good enough; for deciding
**whether a cycle pays for itself** — which is what `cycle_cost_threshold_eur`
compares against — it is an approximation, and one that errs by however far `B`
is from 1.
A **formula provider** (`B x spot + A`, with your own coefficients entered once)
is the planned answer and is a separate card. It is not implemented here. Until
it ships, the two supported honest options are:
1. **`generic_dayahead`** with your markup and taxes set as well as you can —
accepting the approximation above; or
2. **`manual_csv`**, where you compute the all-in prices yourself from your own
contract's formula and hand the add-on the answers. Nothing is approximated,
but nothing is automatic either.
If you are on a **fixed** or **day/night** tariff — most Belgian households —
neither of those applies: use `static_profile` and leave `price_provider: off`.
You cannot get this wrong by editing the markup: each plugin *declares* what its
feed contains and the add-on adds only the rest. Set a markup under a plugin
that already carries one and it is **ignored, with a line in the log saying so**
@@ -1390,10 +1469,65 @@ whether a plugin described its supplier's feed truthfully — that is a claim
about somebody else's tariff, and no code can verify it. If you write a plugin,
the `sources` declaration is the part a reviewer has to read.
`eneco_eddy` is a real-time/imbalance product rather than a day-ahead one. It is
flagged as high-volatility, which widens arbitrage thresholds once the
optimiser lands. That comes from the plugin's own declaration, not from its
name.
**Real-time / imbalance products still have a place in the machinery, but no
plugin declares one.** A plugin that declares `PriceProduct.REALTIME` is flagged
high-volatility and gets widened arbitrage thresholds; that comes from the
plugin's own declaration, never from its name. Both shipped plugins are
day-ahead, so nothing exercises that path on a real install today — it is kept
on purpose so the first genuine imbalance product does not have to rebuild it.
See `DECISIONS/TARIFF-07-withdrawing-three-plugins.md`.
**`generic_dayahead` and the shape of the public feed.** It reads Fraunhofer
ISE's Energy-Charts API (`https://api.energy-charts.info/price?bzn=BE`, no key
and no registration), which publishes a *columnar* document — parallel
`unix_seconds` and `price` arrays, in EUR/MWh, with the unit and the licence
stated in the response. Two things follow that are worth knowing before you
point `price_feed_url` somewhere else:
* The timestamps are **UTC instants**. They are rendered at the Brussels offset
that applies to each one, so the hour that happens twice every October is two
points rather than one overwritten one.
* Belgium moved to a **15-minute market time unit** on 2025-10-01, and the feed
followed. A published day is **96 points, not 24**. Nothing in the add-on
assumes either spacing, and nothing aggregates the quarters away.
* The request **names the days it wants**, derived from the horizon on the
**Brussels** calendar — `?bzn=BE&start=2026-09-09&end=2026-09-10`, two
inclusive local dates. Without that the feed answers a single day, and
everything past today would be carried forward from yesterday's profile and
flagged provisional — which §8.3 excludes from arbitrage, so tomorrow would
never be tradeable at any hour. The calendar is Brussels rather than UTC
because 22:30 UTC in summer is already tomorrow in Belgium.
**What you get before the auction publishes.** The day-ahead auction clears at
about 13:00–14:00 CET. Ask for tomorrow before that and the feed returns fewer
days than requested — that is normal, not a failure. The add-on keeps the
series of what exists and fills the remaining hours from yesterday's profile,
flagged provisional; arbitrage runs on the firm hours and ignores the flagged
ones. So a dynamic site's plan is part firm and part carried every morning and
fully firm once tomorrow is published, and neither state is an error.
⚠️ **If you set `price_feed_url`, you keep the short horizon.** The override is
sent exactly as you wrote it and the add-on adds no `start`/`end` to it, because
those are this feed's parameter names and an override may point somewhere with
different ones — rewriting a URL you typed is how a request nobody intended gets
made. The consequence is a **`WARNING` in the log on every price refresh**,
naming the range that would otherwise have been requested. If you only wanted a
different host and not a different horizon, the fix is to clear the option.
If the arrays ever come back different lengths, or the unit stops saying
EUR/MWh, the fetch is refused outright rather than trusted in part: a price
series missing its last hours is a series missing the evening peak, and it
would be traded on as though it were complete. A refused fetch is the §11.4
row — static profile, provisional plan, no arbitrage — and the reason is in the
log.
**A refused fetch does not take the plan you already have.** The last plan that
actually came from the feed keeps standing until it is 90 minutes old (three
missed refreshes), and only then does the §11.4 row above take over. The
add-on logs a `WARNING` naming the failure, the fetch it is keeping, its age
and the moment it retires; the plan's own age is **not** reset by the failed
refresh, so a kept plan still expires on schedule and is never presented as a
current one. One failed fetch is a blip, not a lost tariff.
**`manual_csv` — the offline fallback.** It needs no internet, no supplier
account and no HTTP stack at all, and it is the supported way to run this
@@ -1453,9 +1587,10 @@ applies to those hours, and asking the plan for one raises rather than
answering. An hour with no price is not a free hour, and the fill does not
invent a neighbour, a mean or a zero for it.
**A quarter-hourly feed keeps all four of its prints.** `eneco_eddy` settles
per quarter, and the fill decides *which whole hours need carrying* — never what
a published hour may contain. Every point the provider published inside the
**A quarter-hourly feed keeps all four of its prints.** Belgian day-ahead
settles per quarter — the 15-minute MTU took effect on 2025-10-01, so
`generic_dayahead` reads 96 points a day — and the fill decides *which whole
hours need carrying*, never what a published hour may contain. Every point the provider published inside the
horizon is passed through untouched; a carried point is added only for a whole
hour the feed did not print at all.
@@ -1488,15 +1623,38 @@ first fill is possible the day after the first refresh.
The same applies to a file that is *partly* broken: a day record this build
cannot read is dropped, the row says how many were dropped and why, and the rest
of the file still loads. And where there is no `/data` at all the store records
**nothing** — not even in memory. An in-memory yesterday would answer for one
run and vanish on the next restart, which is a cache that forgets without saying
so; `unavailable` and *no prices carried* are the honest pair.
of the file still loads. And a store that **cannot persist records nothing** —
not even in memory. That covers a missing `/data`, and it also covers a `/data`
that is there and cannot be **written**: a full volume, a read-only remount. An
in-memory yesterday would answer for one run and vanish on the next restart,
which is a cache that forgets without saying so; the row naming the reason and
*no prices carried* are the honest pair.
⚠️ **What a failed write does not do is throw away what is already in the
file.** A volume that fills at 14:00 leaves the yesterday written at 13:30 still
answering, because that record survives a restart — the store stops *adding* to
it, nothing more. And it never stops the add-on: a write that cannot complete is
logged and reported on the status row, never raised. A cache that cannot answer
is not allowed to be a controller that cannot regulate, and that applies to the
volume being full exactly as it applies to the volume being absent.
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
often near zero, which by itself strongly favours self-consumption over export.
It is a single number rather than a curve because no supported supplier
publishes a dynamic injection tariff; negative values are accepted.
Negative values are accepted — you can be paid to *not* export at midday in
summer.
⚠️ It is a single number rather than a curve, and the reason given here until
TARIFF-07 — *"no supported supplier publishes a dynamic injection tariff"* — was
**false**. Suppliers do. Eneco publishes `0,1 x BELPEX-H − 1,188` on the same
tariff card as its consumption formula (identical on the January and the
September 2026 cards), and ENGIE publishes one in the same shape,
`(B x eSpot_15) − A`. What is true is narrower: an injection curve is the **same
two-coefficient formula** as the consumption curve but with **its own**
coefficients and **its own VAT treatment** — injection is VAT-exempt where
consumption is at 6% — so it is a second per-contract formula rather than a copy
of the first, and the add-on cannot evaluate either one yet. Until the formula
provider ships, **this scalar is a simplification you enter by hand — an average
of what you are actually paid — and not a reflection of the market.**
**The old Home Assistant price sensors.** `price_now_entity` and
`price_avg_entity` still work and are unchanged: the add-on compares them and
@@ -1616,6 +1774,14 @@ towards the firmware's 30 s failsafe. The plan refreshes every 30 minutes and
retires itself if the refresh stops answering, at which point arbitrage stops
with it.
⚠️ **That refresh is not gated on this switch** (TARIFF-10). It runs when a
`price_provider` is configured *or* when arbitrage is armed, so turning
arbitrage off does not turn the price feed off — and a site with no provider
that arms arbitrage on a `static_profile` keeps the plan it has always had. The
only thing that decides whether a request leaves the machine is
`price_provider`: with it `off`, the refresh resolves the static profile in
memory and opens no connection.
### Site
| option | meaning |
+101
View File
@@ -142,6 +142,28 @@ red on 15 named checks. Same shape as TEST-16 and the same fix: **grade the
reason, not the verdict**, and when two rungs answer one stimulus, mutate them
together.
⚠️ **AND FCST-06 FOUND THE COUNT ITSELF LYING — LOW, NOT SILENT.** A mutation
whose red is a **traceback** does not report one red; it reports however many
checks ran before the traceback, and grades **none** of the ones after it. The
new section drove M4 (`self._days = before` → `self._days = {}`) and the run
printed **1 FAIL**. The true figure is **3**: an f-string in the next check read
`len(read.points)`, and `.points` on a `no_data` **raises `Absence` by design**,
so the harness died mid-section with the remaining checks ungraded. That is
`why()`'s and `derived()`'s rule — *"it answered nothing" and "it broke on the
way to answering" must not grade the same* — arriving in the **printed operand**
of a check rather than in its condition, which is the half that is easy to miss
because it looks like formatting. Two things follow:
* **a mutation report's red COUNT is only a lower bound until the run is seen to
have reached the end of its section.** Read the output, not the count;
* **the total accessor has to cover what a check PRINTS, not only what it
asserts.** `test_price_history.py` 5d added `npts()` beside `why()` for
exactly this, and re-driving M4 against it moved 1 FAIL to 3.
Worse, had the traceback landed one check earlier the mutation would have
produced **no named red at all** — a result that reads like a broken harness and
gets retried rather than read.
## The third way a check lies: its DATA cannot reach the failure it names
Stale bytecode manufactures survivors from a mutation that never ran. Masking
@@ -673,6 +695,84 @@ Cheap habits fall out of it, and all of them are now used in this repository:
that two of it cannot silently overwrite each other's work. A fixed path
in a tool that edits source is a shared mutable global with a filename.
19. ⚠️ **A FIXTURE IN A SHAPE NOBODY PUBLISHES GRADES THE PARSER AGAINST THE
PARSER — FOURTEENTH INSTANCE OF HABIT 4, AND THE FIRST WHERE THE FIXTURE
AND THE CODE UNDER TEST SHARED A *SHAPE* RATHER THAN A NUMBER.** TARIFF-08.
`price.py`'s `_points_from_json()` read `doc["data"]` as a list of
`{start, price_eur_mwh}` objects. `api.energy-charts.info` publishes
parallel `unix_seconds` / `price` arrays and has no `data` key at all, so
`generic_dayahead` raised `SourceUnavailable` on **every live fetch it ever
made** — and the suite was green, because all three price suites built
their bodies with `json.dumps({"data": [...]})`. The fixture was derived
from the parser's guess, so no mutation of the parser and no amount of
coverage inside that loop could ever have reached the failure. §11.4 then
turned it into a *correct* silent degradation: parse error -> `no_data` ->
static profile -> arbitrage off, with nothing naming the cause.
Habit 2 with a network on the end of it: **the reference has to be data the
code under test did not shape.** For a parser that means BYTES, captured
from the source and committed unmodified, with the URL, the retrieval time
and a checksum beside them - `scenarios/energy-charts-be-price-*.json` and
its `-captures.json` provenance, re-derived by the replay so an edited
fixture stops being a capture. Two granularities, because the same endpoint
answers hourly before 2025-10-01 and quarter-hourly after it and a fixture
at one spacing cannot grade an assumption about the other.
⚠️ **AND THE SURVIVING SYNTHETIC FIXTURES ARE NAMED FOR WHAT THEY ARE.**
Three shipped plugins point at endpoints nobody has verified (TARIFF-07),
so no capture exists for them; their fixture builder is called
`synthetic_row_feed` at every call site, because the next reader's default
assumption will otherwise be that a green check means the feed parses.
⚠️ **ONE OF THIS CARD'S OWN NEW LINES WAS DECORATION AND THE BATTERY SAID
SO.** The columnar parser first rendered its instants with `brussels()`
before returning them. `assemble()` already renders every plugin's points,
so removing the parser's call changed nothing observable - **M4 came back a
survivor on a `:ro`, purged tree**, which is not the direction stale
bytecode fabricates. The line was removed rather than fenced with a check:
a second copy of a rule that already has an owner is indistinguishable from
a no-op, and the mutation that proves the rendering is real belongs on
`assemble()`, where it is now aimed.
20. ⚠️ **A FIXTURE GRADES THE RESPONSE. NOTHING ABOUT THE REQUEST IS REACHABLE
FROM ONE, AND THE TRANSPORT HAD BEEN RECORDING IT ALL ALONG.** TARIFF-09,
and it is habit 19's closed loop with the other half still shut.
`generic_dayahead.fetch()` took `since` and `until` and asked for the bare
`?bzn=BE`, which answers ONE Brussels day - so §8.3 dropped every hour past
today from the trade set as provisional and the optimiser never saw
tomorrow as firm, at any hour of any day. Measured at 09:00: **15 firm
hours and 33 carried of 48**, both ends of the permitted cycle inside one
calendar day; at 23:00, **1 and 47**, with the firm spread no longer
clearing the round-trip cost.
TARIFF-08 had just fixed the RESPONSE side of this same plugin by
committing real bytes. It could not have caught this: a captured body is
the answer to a URL, and **no property of any body says which URL was
asked for**. `canned()` in `test_price.py` had recorded `(url, timeout_s)`
on every call since TARIFF-02 and not one check had ever asserted on the
first element - so the harness *knew* the plugin was ignoring its arguments
and nothing asked it. The fix is one line of habit: **where a plugin takes
arguments that shape a request, assert on the REQUEST off the injected
transport, not only on what comes back.** Section 2c does, and the
shipped-defect mutation (restore the bare URL, `-173 B`) is red on six
named checks in `test_price.py` and two in `test_price_history.py`.
⚠️ **AND A MUTATION THAT TURNS A READ INTO `no_data` CRASHES A SUITE
INSTEAD OF NAMING A CHECK, UNLESS THE CHECK EXPECTS IT.** `_NoData.points`
raises `Absence` by design, so `derived(...) < derived(...)` over two
absent reads is a `TypeError` and `[p for p in plan.read.points]` is a
traceback - habit 5's shape with an exception in place of a hang. The
mutation that matters here is the "defensive" one somebody writes after
reading this card: *refuse when the feed returned fewer days than we asked
for*. It is exactly what a healthy feed does every morning before the
~13:00-14:00 CET auction, and switching arbitrage off for it would be a
worse defect than the one being fixed. It is red BY NAME in
`test_price.py` (33 checks, the AC's own line among them) and takes
`test_price_history.py` down at **FCST-04's section 1**, whose `_before`
read is unguarded - a red, but an unnamed one. That is recorded rather than
retrofitted: guarding another card's section is a change to what its
checks report, and it belongs to whoever next touches it.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
@@ -730,6 +830,7 @@ commit and PR bodies.
| **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. |
| **MAINT-05 round 3**, `migrate_maint_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **was a survivor — now CLOSED** | measured against head `65cddf3` under the purge, tree mounted `:ro`, `19/19 PASS`. It stamps the schema marker on a migration that LOST state, defeating the older `lost` guard as well as the new `misdirected` one, because every check that reached the marker did so on a store whose `kept` list was empty. Closed by `test_maintenance.py` **9d**; the mutation is now red on three named checks. |
| **MAINT-05 round 3**, `migrate_unit_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **found here — SAFETY-02's half, now CLOSED** | the SAME mutation one function up, on the LATCH migration rather than the history one. Measured a survivor against this branch under the purge, `:ro`, `19/19 PASS`, then closed by `test_inverter.py` part 10's new fixture — a store with `unit/inverter-a/sign_reason` already present (so `kept` is non-empty) whose `sign_tripped: true` write is dropped. Shipped: marker withheld. Mutant: marker stamped on a **disarmed latch**, and the next start skips the migration that would have re-armed it. |
| **TARIFF-09** 11 mutations over `app/price.py` and the two-day capture, all red, no survivors | **not at risk** | direction, and method: every mutation was applied to a FRESH `cp -a` of the clean worktree rather than reverted, so there is no revert to verify (habit 18) and no shared scratch path two runs could collide on. Each ran through `tools/mutation_run.sh`, which purges and fingerprints. Byte deltas recorded on the PR; one is **+0 B** - a single price digit edited inside the committed capture, the risky delta - and the re-derived sha256 catches it. |
| **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. |
**Nothing here says a past result is wrong.** The two survivors re-run above
+155 -15
View File
@@ -67,7 +67,7 @@ from .mqtt import (MqttPublisher, announced_units, unit_slugs,
from . import peakguard
from . import packshare
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
from .price import Uncertainty, price_option, provider_configured
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
@@ -138,6 +138,60 @@ TARIFF_REFRESH_S = 1800.0
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
def tariff_refresh_wanted(opts: dict) -> bool:
"""TARIFF-10. Does this install need the background tariff refresh at all?
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
only caller of `price_history.carry()` - so on a shipped install with a
provider configured NOTHING was ever fetched, no plan was ever resolved,
`/data/price_history.json` was never created, the *Price history* row read
`empty` forever, and TARIFF-09's short-horizon warning could not fire
because there was no fetch to warn about. Measured over a 40 h soak of two
instances side by side: zero feed requests and `present: false` across all
802 samples on the shipped default, against a resolved plan from the same
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
it, and the sentence saying a configured provider still fetches, all in one
change. config.yaml has said it since TARIFF-02 (852fc7c).
Two terms, and they answer two different questions:
provider_configured(opts) -> "the operator asked for a price feed", so
fetch it, validate it and record the day
(§8.3). This is the term TARIFF-10 ADDS.
arbitrage.enabled(opts) -> "the operator armed trading". This term is
what the gate USED to be, kept verbatim.
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
that started this task before still starts it; none that did not start it
stops anything. That matters because the second term is not redundant:
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
configuration validate.py explicitly ACCEPTS, and it trades - measured, at
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile,
`ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
that site forever and `decide()` would answer "no tariff plan has been
resolved yet" on every cycle - silently disarming a strategy the UI says is
running, which is the exact failure validate.py's OPT-04 rules exist to
refuse.
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
arithmetic, no socket - so the second term starts a task that fetches
nothing. The true shipped default (no provider, no arbitrage) starts no
task at all. What is fetched is decided by the FIRST term only.
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
Whether they move energy is `arbitrage.enabled()` at the one call site in
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
means no trade, ever, on any configuration.
"""
return provider_configured(opts) or arbitrage.enabled(opts)
def load_options() -> dict:
try:
with open(OPTIONS_PATH, "r", encoding="utf-8") as fh:
@@ -3480,13 +3534,64 @@ class Controller:
raise out of here and take the task down, because then the plan freezes
at its last value and looks current forever. That is what
`fresh_tariff_plan()`'s age bound is the backstop for, and this is the
half that keeps the task alive so the bound rarely matters.
half that keeps the task alive. ⚠️ THE BOUND IS NO LONGER A BACKSTOP
ONLY: since TARIFF-12 it is also the serve-stale window an outage runs
on, and it is consulted below on every refresh.
⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN. `resolve_tariff()` already has
a §11.4 answer for a dead feed - degrade to the static profile, mark it
provisional, arbitrage refuses - and that answer is better than the
absence of one. Zeroing the plan here would replace a coarse tariff with
no tariff, which is the opposite of what §11.4 asks for.
⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN - WHICH IS A CLAIM ABOUT WHAT
`resolve_tariff()` RETURNS, NOT ABOUT WHAT IT RAISES (TARIFF-12). This
paragraph was enforced by the `except` below alone, and the `except`
below is the one path a supplier outage never takes. `load_prices()`
says so in its own words - it "RETURNS, NEVER RAISES, for every failure
a site can actually have" - because a degradation arriving as a
traceback into the planning tick would stop the controller regulating
over an expired certificate. So a dead feed is a RETURN: the `try`
succeeds, `_degrade()` hands back a §11.4 plan, and the assignment used
to overwrite the good one unconditionally. Measured in a 40 h soak with
`static_profile` unset (the shipped default, and the normal state of a
dynamic-tariff site): the FIRST failed fetch replaced a complete 48 h
plan with `TariffPlan(no_data, None, "none")` - 0 points - 27 minutes
after the last good one, and `TARIFF_MAX_AGE_S` governed nothing. A
transient blip cost the same as a 90-minute outage.
⚠️ SO THE QUESTION IS ASKED ONCE, AFTER THE ATTEMPT, FROM THE EVIDENCE
THAT EXISTS - FCST-06's answer, in this file. The evidence is
`plan.from_feed` (did this attempt observe the feed) and
`fresh_tariff_plan(now)` (is an observation still current). A
degradation replaces a standing observation only once that observation
has aged out, so `TARIFF_MAX_AGE_S` is now the serve-stale window it
reads like rather than a bound nothing could reach.
⚠️ AND A §11.4 STATIC FALLBACK DOES NOT REPLACE A FRESH PROVIDER PLAN
EITHER, although it carries real prices. Every point `_degrade()`
builds is `provisional=True`, so `firm_points()` is empty,
`arbitrage()` refuses and `cheap_now()` is False - everything a
consumer may act on is already off. A day-ahead price for a given hour
does not change once published, so a plan fetched half an hour ago is
exactly as true about that hour as one fetched now. The fallback is
less actionable and no more current; it takes over when the observation
retires, which is what §11.4 is for. The reverse is not symmetrical: a
standing plan that is ITSELF a degradation is replaced by a fresh one,
or an outage would freeze the operator's static profile at the window
it was generated for.
⚠️ AND NOTHING HERE READS `arbitrage_enabled`, WHICH TARIFF-10 MAKES
WORTH SAYING. Since that card this task also runs on a site with a
provider configured and trading DISARMED, so "keep the plan" is a
decision about what the add-on has LOOKED UP, not about what it may
trade on. It is also strictly downstream of `resolve_tariff()`: the
§8.3 carry into `price_history` happens on the `series` branch inside
that call, before this decision exists, so which of two plans is kept
cannot add or lose a recorded day.
⚠️ AND `tariff_at` DOES NOT MOVE ON A KEPT PLAN. It is the moment the
fetch that produced the standing plan happened, and
`fresh_tariff_plan()` judges the age from it - re-stamping it here
would be this task asserting the feed answered when it did not, and the
plan would "freeze at its last value and look current forever", which
is the hazard the paragraph above this one names. An unmoved
`tariff_at` is also the only thing that tells a kept plan from a
freshly fetched one, so it is what the check asserts.
"""
horizon = float(price_option(self.o, "price_horizon_h"))
while not self.stopping:
@@ -3495,11 +3600,33 @@ class Controller:
plan = await resolve_tariff(
self.o, now, now + timedelta(hours=horizon), now=now,
history=self.price_history)
self.tariff_plan, self.tariff_at = plan, now
_LOG.info("tariff refreshed: %s", plan.words)
standing = self.fresh_tariff_plan(now)
# Three terms, and each of them is the only false one in some
# check: the feed answered (replace), nothing current stands
# (take the degradation - this is what makes the age bound
# govern), or what stands is itself a degradation (replace).
keep = (not plan.from_feed
and standing is not None and standing.from_feed)
if keep:
_LOG.warning(
"tariff refresh degraded (%s); KEEPING the plan "
"resolved at %s, %.0f s ago. tariff_at is NOT moved, "
"so it retires %.0f s after that fetch; arbitrage, "
"where it is armed, stops with it. A kept plan is "
"never presented as a current one.", plan.words,
self.tariff_at,
(now - self.tariff_at).total_seconds(),
TARIFF_MAX_AGE_S)
else:
self.tariff_plan, self.tariff_at = plan, now
_LOG.info("tariff refreshed: %s", plan.words)
except Exception as err: # noqa: BLE001
# ⚠️ NOT THE OUTAGE PATH - see the docstring. `load_prices()`
# returns on every failure a site can have, so what reaches
# here is something nobody expected, and the plan is kept for
# the same reason and on the same terms.
_LOG.warning(
"tariff refresh failed (%s); keeping the plan resolved at "
"tariff refresh raised (%s); keeping the plan resolved at "
"%s. It retires on its own after %.0f s and arbitrage "
"stops with it.", err, self.tariff_at, TARIFF_MAX_AGE_S)
with contextlib.suppress(asyncio.TimeoutError):
@@ -4353,6 +4480,14 @@ class Controller:
# OPT-04. The strategy's own sentence, not a re-derivation of it -
# every refusal it can make already carries a reason a human can
# act on, and "Arbitrage: no" would throw all of them away.
#
# ⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE, which is worth
# saying because that card is about the two switches being
# different. `self.arb` is None if and only if
# `arbitrage.enabled(self.o)` was False, so `off (arbitrage_enabled)`
# still names the option that is actually off - and on a site with a
# provider configured it is now the row that says the prices
# arriving on the *Price history* row above are moving nothing.
("Arbitrage", "off (arbitrage_enabled)" if self.arb is None
else self.arb.explain()),
]
@@ -4605,11 +4740,16 @@ async def amain() -> None:
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
#
# ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on
# reads the resolved plan - `cheap` still comes from static_status() on
# the tick, which is pure - so on a default install this task would be
# network traffic in aid of an attribute nobody reads.
if arbitrage.enabled(opts):
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
# enabled` ships `false`, so a site that had selected a price feed
# fetched nothing at all; the whole argument, the measurement and what
# the `or` is protecting are in `tariff_refresh_wanted()` above.
#
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
# two decisions; this is the first, and `cycle()`'s
# `if arbitrage.enabled(self.o):` is still the only gate on the second.
if tariff_refresh_wanted(opts):
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
+634 -147
View File
@@ -63,14 +63,21 @@ THE THREE THINGS THIS MODULE IS SHAPED BY
knowing which of those two things you are reading.
2. THE PRODUCT TYPE IS DECLARED, NOT INFERRED FROM THE PLUGIN'S NAME.
`eneco_eddy` is a real-time / imbalance product (§9.2) and §8.3 says those
are treated as high-volatility with widened arbitrage thresholds. If the
optimiser has to write `if provider_id == "eneco_eddy"` to know that, then
adding a sixth supplier means editing the optimiser - which is precisely
what AC2 forbids. So `capabilities().product` is a `PriceProduct`, it rides
along on the series, and `volatility_regime()` reads the enum. A plugin id
appears nowhere in this file except in `PROVIDERS` and in the class that
answers to it.
§9.2 classes real-time / imbalance products separately from day-ahead ones
and §8.3 says those are treated as high-volatility with widened arbitrage
thresholds. If the optimiser had to write `if provider_id == "<supplier>"`
to know that, then adding a supplier would mean editing the optimiser -
which is precisely what AC2 forbids. So `capabilities().product` is a
`PriceProduct`, it rides along on the series, and `volatility_regime()`
reads the enum. A plugin id appears nowhere in this file except in
`PROVIDERS`, in `RETIRED_PROVIDERS`, and in the class that answers to it.
⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME` (TARIFF-07). Both
shipped plugins are day-ahead, so `_WIDE`, `Uncertainty.REALTIME_PRODUCT`
and `WIDEN_FACTOR`'s 2.5 currently have no shipped declarer. They are KEPT
deliberately - see DECISIONS/TARIFF-07-withdrawing-three-plugins.md - and
test_price.py section 10 keeps them live by declaring REALTIME from a class
defined outside this file, which is AC2's own stimulus.
3. "NO PRICE DATA" IS A TYPE, NOT AN EMPTY LIST.
§11.4: no price data -> fall back to the static profile, mark the plan
@@ -140,6 +147,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from textwrap import dedent
from urllib.parse import urlencode
_LOG = logging.getLogger("goodwe.price")
@@ -184,6 +192,87 @@ def brussels(instant: datetime) -> datetime:
return instant.astimezone(brussels_offset(instant))
def brussels_date_range(since: datetime, until: datetime) -> tuple:
"""The Brussels calendar days a half-open window [since, until) touches.
Returns `(first, last)`, two `date`s, BOTH INCLUSIVE - which is the shape
`api.energy-charts.info` takes: `?bzn=BE&start=2026-09-09&end=2026-09-10`
answered 192 quarter-hours across exactly those two local days, measured
2026-09-10T07:45:49Z and committed as
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`.
THE DEFECT THIS EXISTS TO PREVENT (TARIFF-09). `generic_dayahead.fetch()`
accepted `since` and `until` and requested the bare default URL, so the
feed answered ONE Brussels day. Everything past today was then carried
forward from yesterday's profile, flagged provisional, and §8.3 dropped all
of it from the trade set - so the optimiser never saw tomorrow as firm, at
any hour. Measured on the merged tree at 2026-09-10T09:00+02:00 over a 48 h
horizon: 15 firm whole hours and 33 carried, and both ends of the only
permitted cycle inside ONE calendar day. The ceiling was structural: at
hour H of the local day a one-day window gives at most 24-H firm hours and
24+H carried - 1/47 at 23:00, where the firm spread no longer even clears
the round-trip cost. That is not what FCST-04's fill is for; it covers the
pre-publication window until the ~13:00-14:00 CET auction, after which a
healthy feed hands over tomorrow in full.
⚠️ THE CALENDAR IS BRUSSELS, NEVER UTC, AND THIS IS `_bdate()`'s LESSON ONE
LAYER UP. `since`/`until` are INSTANTS; the API takes DATES, and the two are
not the same question. 22:30 UTC in summer is already tomorrow in Belgium,
so a range derived from `since.date()` asks for the wrong FIRST day for the
last one or two hours of every single day - and the day it drops is the one
the controller is standing in. price_history.py made this mistake at the
cache layer and documents the same arithmetic; here it decides what is
fetched at all, so getting it wrong loses the hours rather than misfiling
them.
⚠️ `until` IS EXCLUSIVE, SO LOCAL MIDNIGHT BELONGS TO THE DAY BEFORE IT.
A 48 h horizon from local midnight ends at local midnight two days later,
and that instant is the first tick of a day the caller did not ask for.
Taking `brussels(until).date()` unconditionally would request a THIRD day
on every whole-hour horizon. This feed answers such a request without
complaint, so nothing would report it - which is the reason to get it right
here rather than to rely on the server's tolerance: the range is a claim
about what the caller asked for, and it should be true.
⚠️ AND AN EMPTY OR INVERTED WINDOW COLLAPSES TO ONE DAY RATHER THAN TO A
BACKWARDS RANGE. `start` after `end` is a request no feed has a defined
answer for; some return everything, some return an error, and neither is a
thing to hand a battery controller.
"""
first = brussels(since).date()
edge = brussels(until)
last = edge.date()
if (edge.hour, edge.minute, edge.second, edge.microsecond) == (0, 0, 0, 0):
last = last - timedelta(days=1)
if last < first:
last = first
return first, last
def dated_feed_url(base: str, **params) -> str:
"""`base` plus a query string. Refuses a `base` that already carries one.
⚠️ THE REFUSAL IS THE POINT, NOT THE FORMATTING. Appending `&start=...` to
a URL that may already hold its own query string is how a plugin produces a
request nobody intended - `?bzn=BE?bzn=BE&start=...`, or a duplicated
parameter whose winner is the server's choice rather than ours. So the base
is required to be parameterless and the parameters are added exactly once,
by `urlencode`, which is also what keeps a date out of the escaping
question. A future edit that folds the query back into the constant is a
refused fetch with a sentence, not a silently malformed URL.
⚠️ IT IS NOT REACHED BY AN OPERATOR'S `price_feed_url`. See
`GenericDayAhead.fetch()`: an override is used exactly as given.
"""
if "?" in base or "#" in base:
raise SourceUnavailable(
f"the feed base {base!r} already carries a query string or "
f"fragment, and this builder appends parameters. Adding a second "
f"'?' produces a URL the server parses differently from the one "
f"anybody intended; put the fixed parameters in the call instead")
return f"{base}?{urlencode(params)}" if params else base
# --------------------------------------------------------------------------- #
# Absence, borrowed wholesale from sim/riglock.py (TEST-04).
# --------------------------------------------------------------------------- #
@@ -398,7 +487,6 @@ class ProviderCapabilities:
product: PriceProduct
sources: frozenset # which Components the published figure contains
horizon_h: int = 48 # aligns with the §8.1/§8.2 forecast horizon
needs_credentials: bool = False
egress_hosts: tuple = () # §16 allow-list, declared not discovered
def __post_init__(self):
@@ -459,6 +547,121 @@ class PriceRead(_Outcome):
return _NoData(reason)
# --------------------------------------------------------------------------- #
# How long ONE point covers - TARIFF-14
# --------------------------------------------------------------------------- #
# ⚠️ A POINT DOES NOT CARRY ITS OWN DURATION AND MUST NOT LEARN TO. §9.3 lists
# THREE fields and `PricePoint` has three; a fourth would change the wire
# format every store, replay, fixture and captured body in this repository is
# written against. The span is DERIVED from the neighbours instead - here,
# once, at construction.
MAX_POINT_SPAN = timedelta(hours=1)
def _cover_table(points) -> tuple:
"""`(start_utc, end_utc, point)` per point, ascending. The `at()` index.
THE DEFECT THIS PREVENTS (TARIFF-14, HIGH). `at()` asked
`s <= u < s + 1 h` of every point IN LIST ORDER and returned the FIRST
match. On an hourly series - the only resolution any fixture carried
before TARIFF-08 - that is right by accident. Belgium moved to a
15-minute MTU on 2025-10-01 and the live day-ahead feed returns 96 points
at 900 s, where it is wrong twice over. Measured on shipped `5059e28`:
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14
at(02:15) -> 02:00 the :00 point's HOUR has not run out yet
at(03:15) -> 02:30 and from the second hour on it is not even the
top of the hour: the first point still inside
its own hour wins, so the error COMPOUNDS with
depth into the series
`arbitrage._act()` compares `here.start` against `v.cheapest.start` and
`v.dearest.start` to decide whether this interval is the buy or the sell
one. That identity test almost never matched the extremum, so the
strategy idled through exactly the interval it exists to trade - and read
`here.provisional` and §17's `price < 0` guard off the wrong point while
doing it. `static_tariff.py` emits hourly points, so static sites never
saw it; this is reachable only down the dynamic path TARIFF-08/09 made
work.
THE RULE, in one sentence:
A POINT COVERS FROM ITS OWN START UNTIL THE NEXT POINT'S START, AND
NEVER MORE THAN ONE HOUR.
* 900 s -> each point covers its own quarter, so `at(02:15)` is the
02:15 point;
* 3600 s -> each point covers its own hour, unchanged from shipped;
* MIXED -> EACH POINT TAKES ITS OWN GAP. FCST-04's carry-forward emits
HOURLY carried points beside QUARTER-HOURLY firm ones, and the soak
measured spacing `[900, 3600]` in 70 % of samples - so mixed is the
normal steady state, and a window derived from one global spacing
would be wrong THERE rather than in an edge case.
⚠️ WHY THE ONE-HOUR CAP, WHICH IS NOT DECORATION. FCST-04 LEAVES HOLES ON
PURPOSE: an hour with no published price AND no yesterday value is left
OUT of the series, and `price_forecast.py`'s module note names `at()`
raising `Absence` for it as the behaviour that sends the caller to the
static profile (§11.4 for that hour). Uncapped, "until the next point's
start" would stretch the point BEFORE a three-hour hole across the whole
hole and answer every instant in it with a stale price. Filling holes is
the defect this module exists to refuse, and it would have been
introduced by the fix for a different one.
⚠️ THE LAST POINT'S WINDOW IS DECIDED, NOT INHERITED. There is no next
start and nothing in §9.3 says how wide the final interval is, so THE
LAST POINT IS GIVEN THE SAME SPAN AS THE POINT BEFORE IT - one hour when
it is the only point. Three consequences, all intended:
* on a 96-point 900 s day the 23:45 point covers 23:45 .. 00:00, so
`at(23:50)` ANSWERS and `at(00:00)` raises `Absence`. A flat one-hour
tail would have answered 00:30 TOMORROW with today's last price,
which is reaching for a neighbour across a day boundary;
* on an hourly series the tail is an hour, exactly as it shipped, so no
static or hourly site changes behaviour;
* on a MIXED series whose last point is a carried HOURLY one behind
quarter-hourly firm ones, the tail is 900 s and the last 45 minutes
of the horizon raise `Absence`. That is the SAFE direction and it is
accepted rather than tolerated: the caller falls back to the static
profile (§11.4) instead of trading on a span the series never
demonstrated, and a carried point is provisional, so §8.3 forbids any
irreversible action resting on it regardless.
⚠️ ORDER IS ESTABLISHED HERE, NOT ASSUMED - and that is the difference
between this and the rule it replaces. `at()` now reads neighbours, so it
WOULD depend on sortedness; the table is therefore sorted by UTC start at
construction, and no caller's list order can reach it. `_validate_raw()`
rejects out-of-order and duplicate starts on the `load_prices()` path,
`price_history._parse_points()` re-sorts what it reads off disk, and
FCST-04's fill emits ascending - but `PriceRead.series()` itself has
never enforced any of that, and an assumption that holds today and is
unstated is how this class of defect recurs.
⚠️ NOTHING HERE RAISES ON A MALFORMED SERIES. A duplicated start - not
reachable through `load_prices()`, which refuses it - gives the earlier
list entry a ZERO-width window and the later one the real span, so one of
the pair becomes unreachable through `at()` and neither the 30 s control
path nor construction raises.
"""
rows = sorted(((p.start.astimezone(timezone.utc), i, p)
for i, p in enumerate(points)),
key=lambda r: (r[0], r[1]))
out = []
for n, (start, _i, point) in enumerate(rows):
if n + 1 < len(rows):
span = rows[n + 1][0] - start # -> the next point's start
elif n:
span = start - rows[n - 1][0] # -> the tail, see above
else:
span = MAX_POINT_SPAN # -> a series of one point
if span > MAX_POINT_SPAN:
span = MAX_POINT_SPAN
out.append((start, start + span, point))
return tuple(out)
class _Series(PriceRead):
kind = "series"
@@ -482,6 +685,10 @@ class _Series(PriceRead):
self.provider = provider
self.fetched_at = fetched_at
self.regime = volatility_regime(product)
# ⚠️ TARIFF-14. Built ONCE, here, so that `at()` scans an index whose
# order it established itself rather than one it hopes it was handed.
# See `_cover_table()` for the window rule and for what the tail does.
self._cover = _cover_table(self.points)
self.words = (f"{len(self.points)} points from {provider} "
f"({product.value}, {self.regime} thresholds)")
@@ -490,25 +697,47 @@ class _Series(PriceRead):
return {"prices": [p.canonical() for p in self.points]}
def at(self, instant: datetime) -> PricePoint:
"""The point covering `instant`. Raises `Absence` if the series does not.
"""The point whose OWN interval covers `instant`, at ANY resolution.
⚠️ RETURNS A POINT OR RAISES; it never returns None. `series.at(t) or
fallback` is the same defaulting bug one level down, so the falsy
answer does not exist. Ask `covers()` first if you mean to branch.
⚠️ TARIFF-14: THE WINDOW IS NOT ONE HOUR WIDE ANY MORE, AND WAS NEVER
ENTITLED TO BE. It runs to the next point's start, capped at an hour -
`_cover_table()` above holds the rule, the tail decision and the
reproduction on a quarter-hourly series. A fixed `s + 1 h` here
returned the FIRST point still inside its own hour, which on the live
900 s feed is neither the covering point nor, past the first hour,
even the top of the hour.
A linear scan over the index, which is 192 entries on the shipped 48 h
horizon. This runs on the 30 s control path through
`arbitrage._point_at()`; a bisect would be correct and is not worth
the second way to be wrong at this size.
"""
u = instant.astimezone(timezone.utc)
for p in self.points:
s = p.start.astimezone(timezone.utc)
if s <= u < s + timedelta(hours=1):
return p
for start, end, point in self._cover:
if start <= u < end:
return point
raise Absence(
f"no price point covers {u.isoformat()}; the series runs "
f"{self.points[0].start.isoformat()} .. "
f"{self.points[-1].start.isoformat()}. An hour outside the series "
f"is an hour with no price, which is §11.4 for that hour - not a "
f"reason to reach for a neighbouring one.")
f"{self.points[-1].start.isoformat()}. An instant outside the "
f"series - or inside a hole FCST-04 left because nothing "
f"published that hour - is an instant with no price, which is "
f"§11.4 for it, not a reason to reach for a neighbouring one.")
def covers(self, instant: datetime) -> bool:
"""Whether `at(instant)` would answer.
⚠️ SHARED TARIFF-14 EXACTLY, AND IS FIXED BY THE SAME CHANGE. It has
no window rule of its own - it asks `at()` - so on the 900 s feed it
answered True for instants no point covered and False for none of
them, because the hour-wide window is never SHORTER than the truth.
It is kept as a delegation for that reason: a second copy of the rule
is a second place for it to be wrong.
"""
try:
self.at(instant)
return True
@@ -516,7 +745,17 @@ class _Series(PriceRead):
return False
def window(self, since: datetime, until: datetime) -> tuple:
"""Points whose start falls in [since, until). May legitimately be empty."""
"""Points whose START falls in [since, until). May legitimately be empty.
⚠️ DID NOT SHARE TARIFF-14, AND THE REASON IS WORTH STATING RATHER
THAN REDISCOVERING. This grades a point's `start` against a half-open
range and never asks how long a point lasts, so it carries no
resolution assumption to be wrong about - the same call answers the
same set at 900 s, at 3600 s and mixed. It is therefore NOT a
coverage query: a point that starts before `since` and runs into it
is absent from this tuple by design. `at()`/`covers()` are the
coverage questions.
"""
a = since.astimezone(timezone.utc)
b = until.astimezone(timezone.utc)
return tuple(p for p in self.points
@@ -684,15 +923,73 @@ def price_option(opts: dict, key: str):
return v
def provider_id(opts: dict) -> str:
"""The selected plugin id, normalised. `"off"` when none is selected.
⚠️ ONE SPELLING OF THIS EXPRESSION, AND TARIFF-10 IS WHY IT IS A FUNCTION.
`str(price_option(opts, "price_provider") or "off").strip()` was written out
twice - in `load_prices()` below and in `static_tariff.resolve_tariff()` -
and main.py's startup gate would have been a third. "Is a provider
configured" is now a DECISION the add-on takes (it is what starts the
background refresh), and a decision spelled out at three call sites is three
places to disagree about whether `" off "` or `None` counts.
⚠️ `validate.py` deliberately keeps its own reader: it validates the RAW
options blob, including one the Supervisor never saw, so it must not route
through `price_option()`'s defaults. Note what this does NOT do: it does not
decide
whether the id names a shipped plugin - `load_prices()` and validate.py's
rule 7 own that, and answering "unknown" here would turn a typo into
"no provider" instead of into a startup refusal.
"""
return str(price_option(opts, "price_provider") or "off").strip()
def provider_configured(opts: dict) -> bool:
"""Has the operator selected a price feed at all? (TARIFF-10)
THE DEFECT THIS PREVENTS: the background tariff refresh used to start only
when `arbitrage_enabled` was on, which ships `false` - so a site with a
provider configured fetched NOTHING, ever. `/data/price_history.json` was
never created, the *Price history* status row read `empty` forever, and
TARIFF-09's short-horizon warning could not fire because no fetch happened
to warn about. Measured over a 40 h two-instance soak: zero feed requests
and `present: false` across all 802 samples on the shipped default, against
a resolved plan on the same feed with arbitrage armed.
⚠️ THIS IS NOT A TRADING SWITCH AND MUST NEVER BECOME ONE. It answers
"fetch prices?"; `arbitrage.enabled()` answers "move energy on them?", and
§10.1's priority 3 is gated on the second alone. See
`main.tariff_refresh_wanted()` for the one place the two meet.
"""
return provider_id(opts) not in ("", "off")
def export_price_eur_kwh(opts: dict) -> float:
"""AC4 / §9.3: the injection price is a SEPARATE SCALAR, not a second series.
In Belgium it is often near zero, which by itself favours self-consumption
over export. It is deliberately not routed through the plugin framework: no
shipped supplier publishes a dynamic injection curve, and inventing the
shape for one would be a second canonical format nobody emits. When one
appears it gets a `PriceProvider` of its own and this scalar becomes its
FIXED-product default - the seam is already the right shape for that.
over export. It is deliberately not routed through the plugin framework -
but ⚠️ NOT because nothing dynamic exists to route. That claim stood here
until TARIFF-07 and it was FALSE:
* Eneco publishes an injection formula on the same tariff card as its
consumption formula - `0,1 x BELPEX-H - 1,188` - identical on the
January and the September 2026 cards;
* ENGIE publishes one in the same two-coefficient shape,
`(B x eSpot_15) - A`.
So a supplier DOES publish a dynamic injection curve. What is true is
narrower and is the actual reason this is a scalar: an injection curve is
the SAME `B x spot - A` shape as the consumption curve but carries ITS OWN
coefficients and its own VAT treatment - injection is VAT-exempt where
consumption is at 6% - so it is a second per-contract formula, not a second
copy of the first. Nothing in this add-on can evaluate either one yet
(`generic_dayahead` cannot express `B x spot + A` at all; the formula
provider is a separate card). Until it can, this scalar is a SIMPLIFICATION
an operator enters by hand - an average of what they are actually paid -
and not a reflection of the market. When the formula provider lands, an
injection curve gets a `PriceProvider` of its own and this scalar becomes
its FIXED-product default; the seam is already the right shape for that.
⚠️ It is NOT reachable from a plugin: `export_price_eur_kwh` is in
FRAMEWORK_KEYS, so a consumption plugin cannot quietly fold an injection
@@ -731,6 +1028,22 @@ class PriceProvider(ABC):
`transport(url, timeout_s) -> bytes` is injected so the checks never
touch a network and `manual_csv` never needs one. `opts` is a
`PluginOptions`, so the markup and taxes are not reachable from here.
⚠️ `since`/`until` ARE THE RANGE THE CALLER WANTS, AND A PLUGIN THAT
IGNORES THEM GETS WHATEVER ITS FEED DEFAULTS TO (TARIFF-09). That is
not a small difference: `generic_dayahead` ignored both, the public
EPEX feed answered ONE Brussels day, and §8.3 then dropped every hour
past today from the trade set as provisional - so the optimiser never
saw tomorrow as firm, at any hour of any day, and nothing reported it.
This promise is NOT mechanically enforced. A rule obliging every
plugin to pass a range would be satisfied by an INVENTED parameter
name - the parser-graded-against-its-own-guess defect, moved from the
response to the request, and the defect TARIFF-08 found on the
response side. What exists instead is a roster pin in test_price.py:
which plugins consume the range and which do not is asserted as a
literal, so a third supplier forces the decision rather than
inheriting the silence.
"""
@@ -758,9 +1071,10 @@ def code_leaks(cls) -> list:
⚠️ DOCSTRINGS ARE STRIPPED BEFORE THE SEARCH, AND THAT IS NOT COSMETIC.
Every shipped plugin's docstring is REQUIRED to name the components it
sources, so `engie_dynamic` says "markup" in prose and would false-positive
against `price_markup_eur_kwh` on a raw text scan. The check that fires on
the documentation it also mandates is the check that gets deleted. So the
sources, so `generic_dayahead` says "markup" in prose and would
false-positive against `price_markup_eur_kwh` on a raw text scan. The
check that fires on the documentation it also mandates is the check that
gets deleted. So the
class is parsed, every docstring node is dropped, and the search runs on
`ast.unparse` of what is left - i.e. on code, which is the only thing that
can actually add a number twice.
@@ -770,8 +1084,8 @@ def code_leaks(cls) -> list:
or zipapp build would not. Refusing to start a battery controller because a
STATIC ANALYSIS could not run would trade a real outage for a hypothetical
accounting error, so an unreadable source returns `[]` and logs. CI proves
the scan really runs: test_price.py asserts source was found for all five
shipped plugins, so a build that silently lost it is a red check, not a
the scan really runs: test_price.py asserts source was found for every
shipped plugin, so a build that silently lost it is a red check, not a
quietly disarmed guard.
"""
try:
@@ -790,6 +1104,58 @@ def code_leaks(cls) -> list:
return sorted({name for name in BANNED_IN_PLUGIN_CODE if name in code})
# --------------------------------------------------------------------------- #
# TARIFF-07: the ids that USED to resolve here, and what an operator on one of
# them has actually been running.
#
# ⚠️ THE DEFECT THIS EXISTS TO PREVENT: A RETIRED ID READ AS A TYPO. Without
# this table `price_provider: "eneco_eddy"` reaches validate.py's unknown-id
# branch and is refused as "not a shipped plugin" - true, and useless. The
# operator did not mistype it; it WAS a shipped plugin, they chose it on
# purpose, and the add-on has accepted it every startup since. What they need
# told is not "that id is wrong" but "that id never fetched a price, and here
# is what you have been running instead" - because §11.4 turned every failed
# fetch into a silent fall back to `static_profile`, so NOTHING they can see
# has ever differed from a working provider that finds no cheap hour.
#
# ⚠️ AND WHY THERE IS NO SILENT ALIAS. Mapping these to `generic_dayahead`
# would be the friendlier-looking option and it is the wrong one: these three
# published RETAIL prices (`B x spot + A`, per contract) and `generic_dayahead`
# reads WHOLESALE. Aliasing needs the site's own two coefficients, which nobody
# has - they live behind the customer's klantenzone login, and there is no
# regulator index to read them from (CREG's tariff-card pages 404; VREG was
# unreachable; every coefficient found in research came from a supplier's own
# PDF). An alias would therefore have to INVENT the numbers it prices a battery
# against, which is the same class of defect as the parser graded against its
# own guess - just one layer further from anything that reports.
#
# Each value is the ID-SPECIFIC half of the refusal: the endpoint, what it was
# observed to answer on 2026-09-13, and why no replacement plugin can be built.
# validate.py owns the common framing. Verified by hand before deletion, not
# taken on report - see DECISIONS/TARIFF-07-withdrawing-three-plugins.md.
# --------------------------------------------------------------------------- #
RETIRED_PROVIDERS: dict = {
"engie_dynamic": (
"its endpoint https://api.engie.be/dynamic/be does not exist - the "
"gateway there answers 404 with Kong's \"no Route matched with those "
"values\". No public endpoint could replace it: ENGIE's dynamic price "
"is (B x eSpot_15) - A with PER-CONTRACT coefficients that live behind "
"your klantenzone login, so no unauthenticated URL can return YOUR "
"price"),
"totalenergies_dynamic": (
"its endpoint https://api.totalenergies.be/prices/dynamic does not "
"exist - the TLS handshake fails outright because that hostname is "
"absent from the certificate the server presents. The coefficients are "
"per-contract there too"),
"eneco_eddy": (
"its endpoint https://api.eneco.be/eddy/prices does not exist - it "
"answers 404 with an empty body - and \"Eddy\" is not an Eneco "
"product at all; their dynamic product is \"Zon & Wind Dynamisch\", "
"which is indexed to Belpex/EPEX DAY-AHEAD and whose coefficients are "
"likewise per-contract"),
}
PROVIDERS: dict = {}
@@ -803,6 +1169,22 @@ def register(cls):
"""
if cls.id in PROVIDERS:
raise ValueError(f"duplicate provider id {cls.id!r}")
if cls.id in RETIRED_PROVIDERS:
# ⚠️ AT IMPORT, NOT AT FETCH, and for the same reason the leak check is.
# A retired id that is registered again is the one state in which
# validate.py's retired-id refusal goes quiet WITHOUT anyone deciding
# it should: `pid in PROVIDERS` becomes true, the named message is
# never reached, and the operator is back to a provider that looks
# like it works. Re-shipping an id is legitimate - the endpoint could
# become real - but it is a DECISION, and taking the id out of
# RETIRED_PROVIDERS in the same commit is what records it.
raise ValueError(
f"{cls.id!r} is in RETIRED_PROVIDERS and cannot also be a shipped "
f"plugin. A site configured on it is refused at startup with a "
f"message naming the id; registering it silently takes that "
f"message away. If this id really ships again, remove it from "
f"RETIRED_PROVIDERS in the same commit - and say in the CHANGELOG "
f"what changed about the endpoint.")
cls.capabilities() # fail at import, not at 15:00 on fetch day
leaks = code_leaks(cls)
if leaks:
@@ -941,7 +1323,7 @@ def check_egress(url: str, caps: ProviderCapabilities) -> str:
# --------------------------------------------------------------------------- #
# The five shipped plugins.
# The shipped plugins.
#
# ⚠️ EACH DOCSTRING STATES WHICH COMPONENTS IT SOURCES AND WHICH THE FRAMEWORK
# ADDS, in the two fixed lines `SOURCES: ...` / `The framework ADDS: ...`. That
@@ -951,37 +1333,167 @@ def check_egress(url: str, caps: ProviderCapabilities) -> str:
# what its feed contains is a red check rather than a 3 c/kWh error nobody sees.
# --------------------------------------------------------------------------- #
def _points_from_json(body, price_key, scale, provisional_default):
"""Shared parse for the three JSON feeds. Shape differences are the args."""
# TARIFF-08: the COLUMNAR document, which is what the public EPEX feed really
# publishes.
#
# ⚠️ THIS IS THE ONLY PARSER LEFT, AND THE SHAPE IT DOES **NOT** READ IS WHY.
# Until TARIFF-07 a second function, `_points_from_json()`, read a row-per-point
# document: `{"data": [{...}, {...}]}`. NO FEED ANYBODY REACHED EVER PUBLISHED
# THAT SHAPE - it was invented alongside the three withdrawn plugins, and the
# only bodies ever fed to it were the fixtures written from the same guess. It
# went with them. `https://api.energy-charts.info/price?bzn=BE` publishes
# PARALLEL ARRAYS:
#
# {"unix_seconds": [1788991200, 1788992100, ...], <- UTC epoch seconds
# "price": [216.08, 209.90, ...], <- one per instant
# "unit": "EUR / MWh", "license_info": "...", "deprecated": false}
#
# The row parser was pointed at that URL and asked for `doc["data"]`, so
# every fetch ended `SourceUnavailable: the feed carried no 'data' list` ->
# §11.4 -> static profile, arbitrage off, and nothing in the status page said
# why. Verified live 2026-09-10 against both fixtures under `scenarios/`.
#
# ⚠️ AND IT IS NOT ONE FUNCTION THAT SNIFFS FOR BOTH. A parser that decides
# which document it got is a parser that can decide wrong, and a wrong-but-
# parseable response then becomes a price. Two shapes, two functions, and each
# plugin names the one its own feed publishes.
#
# The columnar shape was already known in this repository: DATA-02's
# `scenarios/fetch_epex_be.py` has read `zip(d["unix_seconds"], d["price"])`
# since 2026-08-31. Only the SHIPPED path never learned it, because every
# fixture in the suite was built from the invented row shape - the parser was
# graded against its own assumption. See test_price.py section 2b.
# --------------------------------------------------------------------------- #
COLUMNAR_TIME_KEY = "unix_seconds"
COLUMNAR_PRICE_KEY = "price"
COLUMNAR_UNIT_KEY = "unit"
def _columnar_unit(text) -> str:
"""`"EUR / MWh"` -> `"eur/mwh"`. Spacing and case are not information."""
return "".join(str(text).split()).lower()
def _points_from_columnar_json(body, expect_unit, scale,
provisional_default):
"""Parse a COLUMNAR price document: parallel `unix_seconds` / `price`.
Defects this refuses, each of which has a way of becoming a number rather
than an error:
* ⚠️ UNEQUAL ARRAYS ARE `SourceUnavailable`, NOT A SHORT SERIES. `zip()`
stops at the shorter array and says nothing, so a `price` list that
arrived truncated would produce a REAL price series with its tail
missing - and the tail of a Belgian day is the evening peak. A series
that is missing the expensive hours is worse than no series: §11.4's
no-data row switches arbitrage OFF, whereas a truncated series is
traded on. Nothing downstream can tell the two apart, so the refusal
has to happen here.
* ⚠️ A UNIT THAT IS NOT THE DECLARED ONE IS REFUSED. `scale` is the
caller's standing assumption about the unit; if the feed ever
publishes EUR/kWh under the same keys, every price silently comes out
a THOUSAND times too small. `_validate_raw()` cannot catch that -
`MAX_PLAUSIBLE_EUR_KWH` only bounds prices that are too LARGE.
DATA-02's fetcher refuses on the same field for the same reason.
* ⚠️ `unix_seconds` ARE UTC INSTANTS, SAID SO EXPLICITLY.
`datetime.fromtimestamp(t, timezone.utc)`. Without that second
argument CPython returns a NAIVE datetime in the HOST's zone, and a
naive datetime is a wall clock reading plus an assumption about whose
clock it was. `brussels()` and `_validate_raw()` both refuse one, so
the mistake surfaces as a refused fetch rather than as a mispriced
day - but they refuse ALL naive datetimes and can say nothing about
whether this one was the right instant. That part is decided here.
§9.3's rendering AT THE BRUSSELS OFFSET is `assemble()`'s, once, for
every plugin - `brussels(p.start)` there is the single line that does
it, and calling it here as well would be a second copy of a rule that
already has an owner, indistinguishable from a no-op under mutation.
A plugin's job is the right INSTANT; the framework's is the rendering.
A `null` price is DROPPED and logged, not fatal, and that is a different
judgement from the one above: an unequal pair of arrays means the pairing
of instant to price is unreliable, whereas a null means the source stated
it published no price for that one instant. A dropped instant leaves a
hole, and `_Series.at()` already answers a hole with `Absence` - §11.4 for
that hour. `scenarios/fetch_epex_be.py` dropped 0 nulls over the 12 months
to 2026-08-31, so this is a shape the source allows rather than one it uses.
"""
try:
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \
else str(body)
doc = json.loads(text)
except (ValueError, UnicodeDecodeError) as e:
raise SourceUnavailable(f"the feed did not parse as JSON: {e}")
rows = doc.get("data") if isinstance(doc, dict) else doc
if not isinstance(rows, list):
raise SourceUnavailable("the feed carried no 'data' list")
out = []
for row in rows:
try:
start = datetime.fromisoformat(str(row["start"]))
value = float(row[price_key]) * scale
except (KeyError, TypeError, ValueError) as e:
raise SourceUnavailable(f"malformed row {row!r}: {e}")
if start.tzinfo is None:
if not isinstance(doc, dict):
raise SourceUnavailable(
f"the feed is a {type(doc).__name__}, not a columnar object with "
f"{COLUMNAR_TIME_KEY!r} and {COLUMNAR_PRICE_KEY!r} arrays")
times = doc.get(COLUMNAR_TIME_KEY)
prices = doc.get(COLUMNAR_PRICE_KEY)
for key, col in ((COLUMNAR_TIME_KEY, times), (COLUMNAR_PRICE_KEY, prices)):
if not isinstance(col, list):
raise SourceUnavailable(
f"row {row['start']!r} carries no UTC offset; this feed is "
f"required to publish absolute instants")
out.append(RawPoint(start=start, eur_per_kwh=value,
provisional=bool(row.get("provisional",
provisional_default))))
f"the feed carried no {key!r} array; this provider reads the "
f"columnar shape (parallel {COLUMNAR_TIME_KEY}/"
f"{COLUMNAR_PRICE_KEY} arrays), and a row-per-point document "
f"is a different feed, not this one with a field missing")
if len(times) != len(prices):
raise SourceUnavailable(
f"{COLUMNAR_TIME_KEY} has {len(times)} entries and "
f"{COLUMNAR_PRICE_KEY} has {len(prices)}; refusing to zip them. "
f"The short array would truncate the series silently, and a price "
f"series missing its last hours is one missing the evening peak - "
f"traded on as if complete, where no data at all would have "
f"switched arbitrage off (§11.4)")
unit = _columnar_unit(doc.get(COLUMNAR_UNIT_KEY, ""))
if unit != _columnar_unit(expect_unit):
raise SourceUnavailable(
f"the feed reports {doc.get(COLUMNAR_UNIT_KEY)!r}, not "
f"{expect_unit!r}. This provider converts with a fixed factor of "
f"{scale}, so a changed unit is a factor of 1000 on every price "
f"with nothing downstream able to notice it")
if doc.get("deprecated"):
# Not a refusal: today's prices are still prices. But §11.4 makes a
# dead feed look exactly like a quiet one, so the warning that the
# source is going away has to be emitted while it still works.
_LOG.warning("the price feed reports deprecated=%r; it still answers, "
"but this endpoint is scheduled to stop",
doc.get("deprecated"))
out = []
dropped = []
for i, (t, v) in enumerate(zip(times, prices)):
if v is None:
dropped.append(i)
continue
if isinstance(t, bool) or not isinstance(t, (int, float)) or \
not math.isfinite(t):
raise SourceUnavailable(
f"{COLUMNAR_TIME_KEY}[{i}] is {t!r}, not epoch seconds")
if isinstance(v, bool) or not isinstance(v, (int, float)) or \
not math.isfinite(v):
raise SourceUnavailable(
f"{COLUMNAR_PRICE_KEY}[{i}] is {v!r}, which is not a price")
start = datetime.fromtimestamp(float(t), timezone.utc)
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
provisional=bool(provisional_default)))
if dropped:
_LOG.warning("the feed published no price for %d of %d instants "
"(index %s); those instants are left OUT of the series, "
"never zero-filled - a missing hour is not a free hour",
len(dropped), len(times),
", ".join(str(i) for i in dropped[:5])
+ (", ..." if len(dropped) > 5 else ""))
if not out:
raise SourceUnavailable(
f"the feed carried {len(times)} instants and a price for none of "
f"them")
return out
@register
class GenericDayAhead(PriceProvider):
"""Public EPEX BE day-ahead feed, in EUR/MWh.
"""Public EPEX BE day-ahead feed, COLUMNAR, in EUR/MWh.
SOURCES: energy
The framework ADDS: markup, taxes
@@ -990,6 +1502,20 @@ class GenericDayAhead(PriceProvider):
is the wholesale clearing price and nothing else, so both user-entered
components have to be added to it - and because this plugin cannot read
either number, it cannot be the one that adds them twice.
⚠️ IT READS THE COLUMNAR SHAPE, AND SAYS SO IN ITS OWN `fetch()`. Until
TARIFF-08 it called the row-per-point parser (withdrawn by TARIFF-07) and
asked
`api.energy-charts.info` for a `"data"` list it has never published, so
every fetch this provider ever made ended in `SourceUnavailable` and §11.4
turned that into a silent fall back to the static profile. See
`_points_from_columnar_json()` for the shape and for why there are two
parsers rather than one that guesses.
⚠️ THE FEED IS QUARTER-HOURLY SINCE BELGIUM'S 15-MINUTE MTU (2025-10-01):
96 points a day at 900 s, not 24 at 3600 s. Nothing here assumes either -
the instants come from the document - and both granularities are replayed
from real captured bodies in test_price.py section 2b.
"""
id = "generic_dayahead"
@@ -1001,107 +1527,68 @@ class GenericDayAhead(PriceProvider):
sources=frozenset({Component.ENERGY}),
egress_hosts=("api.energy-charts.info", "web-api.tp.entsoe.eu"))
async def fetch(self, opts, since, until, transport):
url = opts.get("price_feed_url") or \
"https://api.energy-charts.info/price?bzn=BE"
check_egress(url, self.capabilities())
body = await transport(url, float(opts.get("price_timeout_s")))
return _points_from_json(body, "price_eur_mwh", 0.001, False)
@register
class EngieDynamic(PriceProvider):
"""Engie's dynamic day-ahead product, published in EUR/kWh.
SOURCES: energy, markup
The framework ADDS: taxes
⚠️ Engie's published figure ALREADY CONTAINS THEIR MARKUP - it is a retail
price, not a wholesale one. Adding `price_markup_eur_kwh` on top of it is
the defect this ticket's design exists to make unreachable; the declaration
above is what stops it, and nothing here needs to remember the rule.
"""
id = "engie_dynamic"
@classmethod
def capabilities(cls):
return ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY, Component.MARKUP}),
needs_credentials=True,
egress_hosts=("api.engie.be",))
FEED_BASE = "https://api.energy-charts.info/price"
BZN = "BE"
async def fetch(self, opts, since, until, transport):
url = opts.get("price_feed_url") or "https://api.engie.be/dynamic/be"
"""Request the range the CALLER asked for, on the Brussels calendar.
⚠️ THE ARGUMENTS ARE USED. Until TARIFF-09 this method took `since` and
`until` and asked for the bare `?bzn=BE`, which answers ONE Brussels
day - so a 48 h horizon was 15 firm hours and 33 carried at 09:00, and
1 firm and 47 carried at 23:00, with the whole trade set inside a
single calendar day at every hour of every day. See
`brussels_date_range()` for the measurement and for why the calendar
is Brussels rather than UTC.
⚠️ AN OVERRIDE IS THE EXACT URL AND THIS METHOD APPENDS NOTHING TO IT,
WHICH COSTS THE OVERRIDING SITE THE RANGE. That is a decision, not an
oversight, and the sentence below is why it is not a footnote:
* `price_feed_url` says "override the plugin's default feed", and an
override that is silently rewritten is not one. Merging parameters
into an operator's query string means parsing and re-serialising a
URL they wrote - which reorders, de-duplicates and re-escapes it,
and can hand the server a request nobody typed.
* `start`/`end` are ENERGY-CHARTS' spelling, and this provider
DECLARES A SECOND HOST: `web-api.tp.entsoe.eu`. Nothing here has
verified that host's day-ahead parameters and nothing here should
guess them - but they are not `start`/`end`, so an override to the
other DECLARED host is exactly the case where appending our
vocabulary adds two parameters that host ignores and yields the
short horizon anyway, with nothing saying so.
* and blind appending is refused outright: `dated_feed_url()` will
not add a query string to a base that has one.
So the cost is real and it is REPORTED, at fetch time, on the shipped
logger, naming the horizon consequence rather than the substitution.
§11.4 is what makes a price defect quiet; an operator who has taken the
short horizon has to be able to find out from the log that they did.
"""
override = str(opts.get("price_feed_url") or "").strip()
if override:
url = override
_LOG.warning(
"price_feed_url is set, so %s is requesting %s exactly as "
"given and is adding NO date range to it. The public feed "
"answers a single Brussels day to a request that names no "
"range, and every hour past that day is then carried forward "
"from yesterday's profile and flagged provisional - §8.3 "
"drops provisional hours from the trade set, so arbitrage "
"would never see tomorrow as firm. Clear price_feed_url to "
"let this plugin ask for the %s..%s it was given.",
self.id, url, *(d.isoformat()
for d in brussels_date_range(since, until)))
else:
first, last = brussels_date_range(since, until)
url = dated_feed_url(self.FEED_BASE, bzn=self.BZN,
start=first.isoformat(),
end=last.isoformat())
check_egress(url, self.capabilities())
body = await transport(url, float(opts.get("price_timeout_s")))
return _points_from_json(body, "price_eur_kwh", 1.0, False)
@register
class TotalEnergiesDynamic(PriceProvider):
"""TotalEnergies' dynamic day-ahead product, published in EUR/kWh.
SOURCES: energy, markup
The framework ADDS: taxes
Same retail shape as Engie: the supplier's own margin is inside the number.
"""
id = "totalenergies_dynamic"
@classmethod
def capabilities(cls):
return ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY, Component.MARKUP}),
needs_credentials=True,
egress_hosts=("api.totalenergies.be",))
async def fetch(self, opts, since, until, transport):
url = opts.get("price_feed_url") or \
"https://api.totalenergies.be/prices/dynamic"
check_egress(url, self.capabilities())
body = await transport(url, float(opts.get("price_timeout_s")))
return _points_from_json(body, "price_eur_kwh", 1.0, False)
@register
class EnecoEddy(PriceProvider):
"""Eneco Eddy / Eddy Flex - a REAL-TIME / IMBALANCE product, all-in EUR/MWh.
SOURCES: energy, markup, taxes
The framework ADDS: nothing
⚠️ THE PRODUCT TYPE IS THE INTERESTING FIELD HERE, NOT THE PRICE. §9.2
classes Eddy Flex as real-time / imbalance and §8.3 says those get widened
arbitrage thresholds. That regime reaches the optimiser through
`capabilities().product` -> `_Series.product` -> `volatility_regime()`. If
it reached the optimiser through the string "eneco_eddy" instead, then the
sixth supplier would need an optimiser edit and AC2 would be dead. This
class is the reason the enum exists.
Imbalance prices are settled after the fact, so the current quarter's figure
is genuinely provisional and the feed says so per point.
"""
id = "eneco_eddy"
@classmethod
def capabilities(cls):
return ProviderCapabilities(
product=PriceProduct.REALTIME,
sources=ALL_IN,
horizon_h=24,
needs_credentials=True,
egress_hosts=("api.eneco.be",))
async def fetch(self, opts, since, until, transport):
url = opts.get("price_feed_url") or "https://api.eneco.be/eddy/prices"
check_egress(url, self.capabilities())
body = await transport(url, float(opts.get("price_timeout_s")))
return _points_from_json(body, "price_eur_mwh", 0.001, True)
return _points_from_columnar_json(body, expect_unit="EUR / MWh",
scale=0.001,
provisional_default=False)
@register
@@ -1190,8 +1677,8 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
site condition, it is a plugin trying to read the markup, and turning it
into "no price data" would hide the exact defect trap 1 exists to catch.
"""
pid = str(price_option(opts, "price_provider") or "off").strip()
if pid in ("", "off"):
pid = provider_id(opts)
if not provider_configured(opts):
return PriceRead.no_data(
"no price provider configured (price_provider is 'off'); "
"§11.4: static profile, plan provisional, no arbitrage")
+17 -9
View File
@@ -71,12 +71,19 @@ So the flag is not passed through anywhere in this file:
the pass-through idiom cannot come back in a later edit and pass review by
looking ordinary.
⚠️ "PUBLISHED" DOES NOT MEAN "FIRM". `eneco_eddy` is an imbalance product whose
current-quarter print is genuinely provisional and says so per point (§9.2,
TARIFF-02). Such a point is published AND provisional, and the audit's rule for
a published point is "the flag is whatever the feed said", never "False". A
provenance check that forced published points to False would be a bug that
looks like a guarantee.
⚠️ "PUBLISHED" DOES NOT MEAN "FIRM". §9.3's canonical format carries
`provisional` PER POINT, not per series, precisely because a real-time /
imbalance product's current-interval print is settled after the fact and is
genuinely provisional while it is published. Such a point is published AND
provisional, and the audit's rule for a published point is "the flag is
whatever the feed said", never "False". A provenance check that forced
published points to False would be a bug that looks like a guarantee.
⚠️ NO SHIPPED PLUGIN EMITS ONE TODAY (TARIFF-07 withdrew the only declarer of
`PriceProduct.REALTIME`), and that is exactly why this paragraph stays. The
rule is a property of the FORMAT, not of any supplier; weakening it to
"published implies False" while nothing exercises it is how the first real
imbalance feed would arrive mispriced and flagged firm.
THE FILL, AND WHY IT KEYS ON THE WALL CLOCK
@@ -104,8 +111,9 @@ because an empty series is not constructible on purpose (price.py trap 3).
⚠️ A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR, and the hourly walk
below is about WHICH HOURS NEED FILLING - never about what a published hour is
allowed to contain. `eneco_eddy` is the Belgian imbalance product and settles
per QUARTER, `_validate_raw()` accepts any strictly-increasing spacing, and a
allowed to contain. BELGIAN DAY-AHEAD ITSELF SETTLES PER QUARTER since the
15-minute MTU took effect on 2025-10-01 - `generic_dayahead` reads 96 points a
day, not 24 - `_validate_raw()` accepts any strictly-increasing spacing, and a
published hour may therefore hold four points. So EVERY published point inside
the window is passed through, in order, and a carried point is added only for a
whole hour with NO published coverage at all.
@@ -171,7 +179,7 @@ class Provenance(Enum):
flag" and "the others".
"""
PUBLISHED = "published" # the provider printed this hour. Flag: the
# feed's own - an imbalance print may be
# feed's own - a real-time print may be
# published AND provisional (§9.2).
CARRIED = "carried" # yesterday's same-wall-clock hour (§8.3).
# Flag: ALWAYS True, by construction.
+107 -19
View File
@@ -61,6 +61,38 @@ yesterday that works all day and is gone on the next restart, which is exactly
the silently-forgetting cache this rule exists to refuse. A store that cannot
persist now records nothing, and both of its answers say the same thing.
⚠️ AND "CANNOT PERSIST" IS ONE QUESTION, ASKED ONCE, ABOUT THE OUTCOME -
FCST-06. The paragraph above enforced that rule against `not self.ok`, which is
one SYMPTOM of not persisting: the durable volume is missing. There is a second,
and FCST-06 measured it. With `ok=True` and the directory present, `_save()` can
still fail - a read-only remount (`[Errno 30] Read-only file system`), a full
volume (`[Errno 28] No space left on device`) - and it logs, records
`last_error` and returns. `_remember()` had already populated `self._days`, so
`words` read `'2026-01-14 (24 points over 24 h); 2026-01-15 (24 points over
24 h) [could not write (...)]'` while the file on disk held `{}`, and
`yesterday()` answered 24 REAL POINTS for the rest of the process's life. Gone
on the next restart, and nothing said it would be - the same silently-forgetting
cache, reached by the condition the rule did not name.
⚠️ SO IT IS NOT TWO RULES, AND IT IS DELIBERATELY NOT TWO TESTS. "Can this
store persist?" CANNOT BE ANSWERED BEFORE THE ATTEMPT - a directory that existed
and was writable a moment ago is not a write that completed - so it is answered
AFTER it, once, by the only thing holding the evidence: `_save()` returns
whether the file now holds what memory holds. A missing volume is one of its
False cases and an `OSError` from the write is another, and `_remember()` KEEPS
ONLY WHAT THAT ONE ANSWER SAYS WAS KEPT. There is no `not self.ok` test in
`_remember()` any more, because there is nothing left for a second test to add -
and a third symptom nobody has thought of is how this defect arrived both times.
⚠️ AND WHAT IS KEPT IS THE LAST STATE THAT IS ON DISK, NOT AN EMPTY ONE. The
MERGE is undone, not the store. A site whose volume fills at 14:00 goes on
answering the yesterday it wrote at 13:30 - that record survives a restart, so
answering it is not a promise the store cannot keep. What it stops doing is
adding to it. The invariant the rule was always describing, stated once:
`self._days` NEVER HOLDS A POINT THE FILE DOES NOT. (Not "mirrors the file" -
`_usable_days()` drops records the file still holds. The only direction that can
invent a yesterday is the one named.)
THE FOUR RULES THIS FILE IS SHAPED BY
=====================================
@@ -75,7 +107,8 @@ THE FOUR RULES THIS FILE IS SHAPED BY
⚠️ NO VALUE-LEVEL CHECK CAN CATCH THAT, and saying so is more useful than a
guarantee that would not hold. A carried point and a published-provisional
point (`eneco_eddy`'s current quarter, §9.2) are the same three fields with
point (a real-time product's current interval, §9.2) are the same three
fields with
the same flag; the difference is provenance, and provenance is not in the
value. So the mechanism is ORDERING, not validation: `carry()` is the only
public entry point, it remembers the argument and returns the fill, and the
@@ -148,6 +181,7 @@ and turning it on is a product decision, not a wiring one. See
DECISIONS/FCST-04-the-day-that-is-carried.md.
"""
import copy
import json
import logging
import os
@@ -333,6 +367,14 @@ class PriceHistory:
site's first day, and a different thing from a store that could not be
opened. Both are visible: `ok`/`last_error` describe the file, and
`yesterday()`'s reason describes the answer.
⚠️ AND `ok` IS NOT "CAN PERSIST" (FCST-06). It says the directory was
there when the store opened, which a full volume and a read-only remount
both leave true. The one thing that answers "can this store persist" is
`_save()`'s return value, and `self._days` NEVER HOLDS A POINT THE FILE DOES
NOT, because `_remember()` undoes a merge that answer refused. That is what
makes `words` and `yesterday()` describe the same store in EVERY degraded
state rather than only in the ones somebody enumerated.
"""
def __init__(self, path: str = "/data/price_history.json"):
@@ -478,14 +520,31 @@ class PriceHistory:
"it", self.path, self.last_error)
return kept
def _save(self) -> None:
def _save(self) -> bool:
"""Write `self._days` out. True only if the file now holds it.
⚠️ THE RETURN VALUE IS THE "CAN THIS STORE PERSIST?" PREDICATE, AND
IT IS THE ONLY ONE (FCST-06). Both ways of not persisting end here and
are the same answer: no durable volume returns False without touching
the disk, an `OSError` out of the write returns False after recording
why. `_remember()` acts on that one answer, so the second place that
used to test the first symptom on its own - and missed the second - has
nothing left to test. See the module docstring's fifth warning.
⚠️ AND IT REPORTS, IT DOES NOT RAISE. §6 and this module's opening: a
broken cache must not be a broken controller. An add-on that will not
start because /data filled up is a worse defect than the one FCST-06
fixed, so a failed write is logged, kept in `last_error` - where `words`
and `yesterday()` both read it - and RETURNED, never thrown.
⚠️ THE TEMP FILE IS REMOVED ON EVERY FAILING PATH. A write that dies
between `mkstemp` and `os.replace` used to leave its temp behind, and
the refresh task runs 48 times a day: a persistent write failure - a
full volume being the likeliest cause - leaked 48 inodes a day onto the
volume that was already the problem.
"""
if not self.ok:
return
# ⚠️ THE TEMP FILE IS REMOVED ON EVERY FAILING PATH. A write that dies
# between `mkstemp` and `os.replace` used to leave its temp behind, and
# the refresh task runs 48 times a day: a persistent write failure -
# a full volume being the likeliest cause - leaked 48 inodes a day onto
# the volume that was already the problem.
return False
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path))
@@ -498,12 +557,14 @@ class PriceHistory:
except OSError as err:
self.last_error = f"could not write ({err})"
_LOG.error("could not persist price history: %s", err)
return False
finally:
if tmp is not None:
try:
os.unlink(tmp)
except OSError: # nothing further to try, and a
pass # failed cleanup is not a fault
return True
# -- the one public entry point -----------------------------------------
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
@@ -540,15 +601,6 @@ class PriceHistory:
nothing outside this file names it, and `test_price_history.py` checks
the tree for that rather than trusting the underscore.
"""
if not self.ok:
# ⚠️ A STORE THAT CANNOT PERSIST RECORDS NOTHING. See the module
# docstring's third warning: filling `self._days` here would give a
# site with no /data an in-memory yesterday that answers a series
# all day and is gone on the next restart - a cache that forgets
# without saying so, which is the one thing that rule refuses. The
# honest degraded state is `words` = "unavailable" and `yesterday()`
# = `no_data`, and those two must agree.
return
points = read.match(series=lambda r: r.points, no_data=lambda _r: ())
if not points:
return
@@ -557,6 +609,25 @@ class PriceHistory:
provider = read.match(series=lambda r: r.provider,
no_data=lambda _r: None)
# ⚠️ THE STATE THIS READ STARTED FROM, KEPT SO IT CAN BE PUT BACK
# (FCST-06). Everything below merges into `self._days` IN PLACE, and
# whether any of it may be KEPT is not knowable until `_save()` has
# tried - so the merge runs, and it is undone if the file did not take
# it. There is no `not self.ok` branch above any more: a store with no
# durable volume is one of the answers `_save()` gives, not a separate
# symptom with a test of its own. Module docstring, fifth warning.
#
# ⚠️ DEEP, NOT `dict(self._days)`. A one-level copy shares the day
# RECORDS, and the loop below rebinds keys on the ones it touches - so
# the "previous" state would hand back records the merge had already
# edited, and a store with a good yesterday on disk would go on
# answering the merge of it that never reached the file. It would also
# be right only for as long as nobody makes this loop mutate a `points`
# list or a row in place. A copy that is right by construction outlives
# that, and the store holds at most KEEP_DAYS + 2 records, 48 times a
# day.
before = copy.deepcopy(self._days)
touched = set()
junked = {}
for p in points:
@@ -605,7 +676,9 @@ class PriceHistory:
# has just discarded rows off disk stops reporting plain health -
# and after `_save()` below the file is clean, so a restart clears
# this. `_save()` overwrites it if the write itself fails, which is
# the more urgent fact of the two.
# the more urgent fact of the two - and on that path the drop is
# undone with the rest of the merge, so those rows are still in the
# file and the next write that completes drops them again.
self.last_error = (
f"{sum(junked.values())} malformed point row(s) dropped from "
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items())))
@@ -613,7 +686,22 @@ class PriceHistory:
"without them and the refresh continues",
self.path, self.last_error)
self._prune(stamp)
self._save()
if not self._save():
# ⚠️ A STORE THAT CANNOT PERSIST RECORDS NOTHING - the rule, asked
# ONCE as a question about the outcome instead of tested twice
# against two symptoms of it. Keeping the merge here is exactly the
# cache the module docstring refuses: `yesterday()` would answer a
# series the file does not contain, all day, until a restart
# dropped it without a word - and `words` and `yesterday()` would
# be describing two different stores. The reason is already in
# `last_error` (from `_save()`, or from `_open()` when there is no
# volume at all), so both answers now describe what is on disk.
#
# It does NOT raise, and §6 is why: a broken cache must not be a
# broken controller. `carry()` still returns the fill, the horizon
# is as short as the feed makes it, and the add-on regulates.
self._days = before
return
_LOG.debug("price history: %d point(s) filed under %s; %d day(s) held",
len(points), ", ".join(sorted(touched)), len(self._days))
+32 -5
View File
@@ -13,7 +13,7 @@ is an EMS for a minority." The §9.2 product table maps four products onto four
EMS behaviours, and TARIFF-02 shipped the machinery for the top two only:
Dynamic day-ahead hourly EPEX BE + markup full arbitrage
Real-time/imbalance e.g. Eneco Eddy Flex arbitrage, wider thresholds
Real-time/imbalance no shipped plugin (TARIFF-07) arbitrage, wider thresh.
Static day/night two-period tariff TIME-WINDOW SHIFTING <- here
Fully fixed single price SELF-CONSUMPTION ONLY <- here
@@ -207,7 +207,7 @@ from datetime import datetime, timedelta, timezone
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
Uncertainty, _Outcome, brussels, load_prices, price_option,
required_spread, uncertainty_of, widen_factor,
provider_configured, required_spread, uncertainty_of, widen_factor,
)
_LOG = logging.getLogger("goodwe.tariff.static")
@@ -728,6 +728,33 @@ class TariffPlan:
self.origin = origin
# -- derived facts -------------------------------------------------------
@property
def from_feed(self) -> bool:
"""Is this series an OBSERVATION of the configured provider's feed?
TARIFF-12. `origin` names four ways a plan is obtained; this collapses
them to the one question a caller holding a PREVIOUS plan has to ask
about a new one - did this resolution see the feed, or is it standing
in for one that did not? Only `"provider"` saw it. `"fallback"` and
`"none"` are the two §11.4 rows, and `"static"` is a site with no
feed to observe at all, where the question does not arise.
⚠️ IT IS NOT `has_prices`, AND THE DIFFERENCE IS THE WHOLE POINT. A
§11.4 fallback HAS prices - the operator's own static profile - but
every point `_degrade()` builds carries `provisional=True`, so
`firm_points()` is empty, `arbitrage()` refuses and `cheap_now()` is
False. It is a real tariff and it is not an observation, and a caller
deciding whether to REPLACE an observation with it needs the second
fact, not the first.
⚠️ AND IT IS A PROPERTY HERE RATHER THAN AN `origin == "provider"` AT
THE CALL SITE, for `main.fresh_tariff_plan()`'s stated reason: a check
has to be able to assert on the shipped expression rather than on a
transcription that drifts, and `origin`'s vocabulary belongs to this
module.
"""
return self.origin == "provider"
@property
def has_prices(self) -> bool:
return self.read.match(series=lambda _r: True, no_data=lambda _r: False)
@@ -1055,7 +1082,8 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
⚠️ THE FILL IS PER WHOLE HOUR AND ITS COVERAGE IS CONDITIONAL. Every point
the provider published inside the window passes through BY IDENTITY - four
of them in one hour on a quarter-hourly product like `eneco_eddy` - and a
of them in one hour on the quarter-hourly day-ahead feed `generic_dayahead`
reads (Belgium's 15-minute MTU, 2025-10-01) - and a
carried point is added only for a whole hour the feed did not print at all
AND yesterday did. An hour neither source can price is left out, which is
§11.4 for that hour: `plan.price_at()` raises rather than answering, and an
@@ -1071,8 +1099,7 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
missing and loses the elapsed ones it should never have had.
"""
stamp = now or datetime.now(timezone.utc)
pid = str(price_option(opts, "price_provider") or "off").strip()
if pid in ("", "off"):
if not provider_configured(opts):
return static_plan(opts, since, until, provisional=False, now=stamp)
read = await load_prices(opts, since, until, transport)
+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 "
+52 -8
View File
@@ -265,9 +265,17 @@ options:
# TARIFF - what energy costs, and whether to trade on it
# ==========================================================================
# `off` is the shipped default and means the §11.4 no-price-data row applies:
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
# validates and publishes prices and changes no energy decision.
# static profile, plan provisional, NO ARBITRAGE.
#
# ⚠️ SELECTING A PROVIDER IS WHAT STARTS THE FETCH, AND IT IS NOT
# `arbitrage_enabled` (TARIFF-10). With a provider set and trading off - the
# shipped default - the add-on fetches the feed every 30 minutes, validates
# it, records the day in /data/price_history.json and reports what it holds
# on the *Price history* status row. It changes NO energy decision: the cheap
# window the maintenance cycle reads still comes from `static_profile`, and
# §10.1's arbitrage is gated on `arbitrage_enabled` alone. Until TARIFF-10
# the fetch was gated on `arbitrage_enabled` too, so this option did nothing
# whatsoever on a shipped install - the store was never even created.
#
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
@@ -276,9 +284,8 @@ options:
#
# markup/taxes are the §14 `tariffs.energy_price.*` figures and are added by
# the FRAMEWORK, only for the components the chosen plugin declares its feed
# does not already contain. engie_dynamic, totalenergies_dynamic, eneco_eddy
# and manual_csv publish a figure that already carries the markup, so setting
# it under those is ignored - and logged as ignored, never applied twice.
# does not already contain. manual_csv carries an all-in figure, so setting
# either under it is ignored - and logged as ignored, never applied twice.
price_provider: "off"
price_markup_eur_kwh: 0.03
price_taxes_eur_kwh: 0.06
@@ -318,6 +325,14 @@ options:
# validate.py warns about exactly that.
static_profile: ""
price_csv_path: "/share/goodwe_prices.csv"
# ⚠️ AN OVERRIDE IS THE EXACT URL (TARIFF-09). The plugin sends it verbatim
# and appends nothing - including the `start`/`end` date range it derives from
# the horizon for its own feed - so an override gets whatever range that URL
# answers, which for a bare day-ahead endpoint is ONE Brussels day. Every hour
# past it is then carried from yesterday and flagged provisional, and §8.3
# keeps provisional hours out of the trade set. The plugin logs a WARNING
# naming that consequence on every fetch that uses an override; clearing this
# option is what restores the full horizon.
price_feed_url: ""
price_horizon_h: 48
price_timeout_s: 10
@@ -344,6 +359,12 @@ options:
# configuration: this is the first strategy in the add-on that moves energy
# against the meter on purpose, so it is armed deliberately or not at all.
#
# ⚠️ IT IS NOT WHAT TURNS THE PRICE FEED ON (TARIFF-10). `price_provider`
# above decides whether prices are FETCHED; this decides whether they move
# energy. Leaving this off on a site with a provider configured is a
# supported, documented state: prices are fetched, validated and recorded,
# and nothing trades.
#
# ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the
# combinations that cannot work rather than letting them look armed. With
# `price_provider: off` and no `static_profile` there is no series to trade
@@ -736,8 +757,31 @@ schema:
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
# checks reach validate_startup_config() with a dict the Supervisor never saw.
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
# else, which is AC2.
# Adding a plugin means adding it here and to PROVIDERS - and nowhere else,
# which is AC2.
#
# ⚠️ THREE OF THESE FIVE IDS NO LONGER RESOLVE TO A PLUGIN, AND LEAVING THEM
# HERE IS A DECISION (TARIFF-07). engie_dynamic, totalenergies_dynamic and
# eneco_eddy were withdrawn - their endpoints do not exist and never did - and
# the obvious edit is to delete them from this line too. DO NOT. The Supervisor
# validates the options file against this schema BEFORE the add-on starts, so
# an id that is not listed here makes an EXISTING config schema-invalid: the
# operator gets an opaque "expected one of ..." from the Supervisor, the add-on
# never runs, and validate.py's retired-id refusal - the message that tells
# them their prices never worked and what to do instead - IS NEVER REACHED, on
# exactly the sites that need it. Schema tightness would cost the only thing
# this withdrawal is FOR.
#
# The cost of keeping them is that the dropdown still offers three dead ids to
# a NEW operator. That is the cheaper failure: choosing one is refused at the
# next startup by name, with the endpoint, the observation and the alternative
# - which is strictly more than a typo-shaped schema rejection would say. Both
# translations label them withdrawn in the option description as well.
#
# app/price.py's RETIRED_PROVIDERS is the list, test_price.py section 1 asserts
# this line offers exactly PROVIDERS + RETIRED_PROVIDERS + 'off' - derived, not
# typed - and register() refuses to re-register a retired id, so the two
# cannot both claim one.
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
price_markup_eur_kwh: float(0,1)
price_taxes_eur_kwh: float(0,1)
@@ -0,0 +1 @@
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1757455200,1757458800,1757462400,1757466000,1757469600,1757473200,1757476800,1757480400,1757484000,1757487600,1757491200,1757494800,1757498400,1757502000,1757505600,1757509200,1757512800,1757516400,1757520000,1757523600,1757527200,1757530800,1757534400,1757538000],"price":[73.0,61.31,64.32,62.49,67.0,64.48,94.52,149.26,132.43,91.61,84.8,79.61,69.23,66.75,68.91,69.31,80.29,66.91,105.2,161.05,116.73,84.2,64.1,42.41],"unit":"EUR / MWh","deprecated":false}
@@ -0,0 +1 @@
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1788904800,1788905700,1788906600,1788907500,1788908400,1788909300,1788910200,1788911100,1788912000,1788912900,1788913800,1788914700,1788915600,1788916500,1788917400,1788918300,1788919200,1788920100,1788921000,1788921900,1788922800,1788923700,1788924600,1788925500,1788926400,1788927300,1788928200,1788929100,1788930000,1788930900,1788931800,1788932700,1788933600,1788934500,1788935400,1788936300,1788937200,1788938100,1788939000,1788939900,1788940800,1788941700,1788942600,1788943500,1788944400,1788945300,1788946200,1788947100,1788948000,1788948900,1788949800,1788950700,1788951600,1788952500,1788953400,1788954300,1788955200,1788956100,1788957000,1788957900,1788958800,1788959700,1788960600,1788961500,1788962400,1788963300,1788964200,1788965100,1788966000,1788966900,1788967800,1788968700,1788969600,1788970500,1788971400,1788972300,1788973200,1788974100,1788975000,1788975900,1788976800,1788977700,1788978600,1788979500,1788980400,1788981300,1788982200,1788983100,1788984000,1788984900,1788985800,1788986700,1788987600,1788988500,1788989400,1788990300,1788991200,1788992100,1788993000,1788993900,1788994800,1788995700,1788996600,1788997500,1788998400,1788999300,1789000200,1789001100,1789002000,1789002900,1789003800,1789004700,1789005600,1789006500,1789007400,1789008300,1789009200,1789010100,1789011000,1789011900,1789012800,1789013700,1789014600,1789015500,1789016400,1789017300,1789018200,1789019100,1789020000,1789020900,1789021800,1789022700,1789023600,1789024500,1789025400,1789026300,1789027200,1789028100,1789029000,1789029900,1789030800,1789031700,1789032600,1789033500,1789034400,1789035300,1789036200,1789037100,1789038000,1789038900,1789039800,1789040700,1789041600,1789042500,1789043400,1789044300,1789045200,1789046100,1789047000,1789047900,1789048800,1789049700,1789050600,1789051500,1789052400,1789053300,1789054200,1789055100,1789056000,1789056900,1789057800,1789058700,1789059600,1789060500,1789061400,1789062300,1789063200,1789064100,1789065000,1789065900,1789066800,1789067700,1789068600,1789069500,1789070400,1789071300,1789072200,1789073100,1789074000,1789074900,1789075800,1789076700],"price":[150.93,151.41,150.99,147.59,153.25,147.26,144.04,141.53,147.79,146.53,143.55,139.93,145.58,139.56,139.11,138.06,138.92,138.52,138.04,138.65,137.29,139.03,144.59,150.34,154.12,174.5,178.43,186.62,199.35,200.21,200.11,197.43,220.07,202.0,196.01,174.03,198.32,167.49,152.54,142.05,161.5,145.45,128.61,100.85,139.18,115.83,94.41,80.39,111.25,89.61,84.99,74.38,80.53,70.58,62.27,57.1,62.24,64.95,68.99,71.19,66.9,87.32,109.0,123.12,68.48,125.53,155.99,161.9,138.77,160.57,167.35,173.0,142.2,172.83,196.19,211.26,205.85,218.77,217.08,222.0,210.98,208.46,208.88,210.02,209.77,202.61,200.27,191.49,194.39,195.04,197.26,190.92,188.39,198.05,203.05,191.84,216.08,216.08,214.92,200.0,223.37,201.43,196.32,190.0,199.53,194.83,180.53,172.23,179.88,175.77,167.1,173.89,163.29,165.99,171.41,178.73,170.27,177.37,187.71,201.07,193.62,207.61,218.27,226.77,227.17,234.05,236.99,229.94,248.93,227.5,216.08,195.67,215.99,195.01,177.1,155.09,174.91,152.71,139.59,123.83,140.77,123.19,114.96,94.5,102.77,96.63,94.57,116.34,114.63,126.82,129.89,123.95,105.0,119.11,121.0,136.79,123.41,135.01,135.0,156.68,146.88,152.16,162.02,181.78,154.76,184.28,194.3,215.76,197.23,234.15,253.03,296.58,345.32,389.16,482.99,559.0,551.11,446.41,358.97,295.9,271.39,268.06,241.43,238.01,239.7,226.41,223.05,211.56,218.21,210.61,197.78,171.79],"unit":"EUR / MWh","deprecated":false}
@@ -0,0 +1 @@
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1788991200,1788992100,1788993000,1788993900,1788994800,1788995700,1788996600,1788997500,1788998400,1788999300,1789000200,1789001100,1789002000,1789002900,1789003800,1789004700,1789005600,1789006500,1789007400,1789008300,1789009200,1789010100,1789011000,1789011900,1789012800,1789013700,1789014600,1789015500,1789016400,1789017300,1789018200,1789019100,1789020000,1789020900,1789021800,1789022700,1789023600,1789024500,1789025400,1789026300,1789027200,1789028100,1789029000,1789029900,1789030800,1789031700,1789032600,1789033500,1789034400,1789035300,1789036200,1789037100,1789038000,1789038900,1789039800,1789040700,1789041600,1789042500,1789043400,1789044300,1789045200,1789046100,1789047000,1789047900,1789048800,1789049700,1789050600,1789051500,1789052400,1789053300,1789054200,1789055100,1789056000,1789056900,1789057800,1789058700,1789059600,1789060500,1789061400,1789062300,1789063200,1789064100,1789065000,1789065900,1789066800,1789067700,1789068600,1789069500,1789070400,1789071300,1789072200,1789073100,1789074000,1789074900,1789075800,1789076700],"price":[216.08,216.08,214.92,200.0,223.37,201.43,196.32,190.0,199.53,194.83,180.53,172.23,179.88,175.77,167.1,173.89,163.29,165.99,171.41,178.73,170.27,177.37,187.71,201.07,193.62,207.61,218.27,226.77,227.17,234.05,236.99,229.94,248.93,227.5,216.08,195.67,215.99,195.01,177.1,155.09,174.91,152.71,139.59,123.83,140.77,123.19,114.96,94.5,102.77,96.63,94.57,116.34,114.63,126.82,129.89,123.95,105.0,119.11,121.0,136.79,123.41,135.01,135.0,156.68,146.88,152.16,162.02,181.78,154.76,184.28,194.3,215.76,197.23,234.15,253.03,296.58,345.32,389.16,482.99,559.0,551.11,446.41,358.97,295.9,271.39,268.06,241.43,238.01,239.7,226.41,223.05,211.56,218.21,210.61,197.78,171.79],"unit":"EUR / MWh","deprecated":false}
@@ -0,0 +1,82 @@
{
"card": "TARIFF-08, extended by TARIFF-09",
"what": "UNMODIFIED response bodies from the public EPEX BE day-ahead feed, captured so the SHIPPED parser can be replayed against what the source really publishes",
"why": "every price fixture in the suite was built from a `{\"data\": [{\"start\": ..., \"price_eur_mwh\": ...}]}` document that this feed has never published. The suite therefore graded `_points_from_json` against its own assumption - a closed loop that could not notice the assumption was wrong - and §11.4 turned the resulting failure into a silent fall back to the static profile. A captured real body is what breaks that loop, so these files are bytes, not a reconstruction: nothing here is pretty-printed, re-keyed or trimmed. TARIFF-09 adds a body from a request that names a DATE RANGE, for the same reason one layer out: the shipped plugin took `since`/`until` and asked for the bare default URL, so the feed answered ONE Brussels day and every hour past today was carried forward and dropped from the trade set as provisional. A two-day body captured from the range request is what makes \"asking for the range gets the range\" checkable against something the plugin did not shape.",
"source": "Energy-Charts API (Fraunhofer ISE), api.energy-charts.info",
"upstream": "Bundesnetzagentur | SMARD.de",
"licence": "CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)",
"licence_note": "the BE bidding zone is in the API's CC BY 4.0 list, so redistribution is permitted with attribution. DATA-02 established this against the API's own OpenAPI document before committing `epex-be-dayahead-2025-09-01_2026-09-01.json`; both bodies below carry the same `license_info` string, recorded per file and asserted by the replay test.",
"attribution": "Data licensed CC BY 4.0 by Bundesnetzagentur | SMARD.de, retrieved via the Energy-Charts API (Fraunhofer ISE), https://energy-charts.info. Published without changes.",
"retrieved_utc": "2026-09-10T06:35:00+00:00 (TARIFF-08's two), 2026-09-10T07:45:49+00:00 (TARIFF-09's)",
"retrieved_with": "curl, by hand; no code in `app/` fetches these",
"shape": "COLUMNAR: parallel `unix_seconds` (UTC epoch seconds) and `price` arrays, plus `unit`, `license_info` and `deprecated`. There is no `data` key and no per-point object.",
"replayed_by": "test_price.py sections 2b (TARIFF-08) and 2c (TARIFF-09)",
"files": {
"energy-charts-be-price-2026-09-10-quarterhourly.json": {
"url": "https://api.energy-charts.info/price?bzn=BE",
"note": "the SHIPPED default URL of `generic_dayahead`, verbatim and with no parameters, so the replay drives the bytes the add-on's own request produces",
"covers_local_day": "2026-09-10 (Europe/Brussels)",
"granularity_s": 900,
"intervals": 96,
"bytes": 1877,
"sha256": "176309d0020ad208320797385ef68b6a98ac248218539416cddf6579dd060487",
"unit_as_returned": "EUR / MWh",
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
"deprecated_as_returned": false,
"top_level_keys": [
"deprecated",
"license_info",
"price",
"unit",
"unix_seconds"
],
"also_returned_verbatim_by": {
"url": "https://api.energy-charts.info/price?bzn=BE&start=2026-09-10&end=2026-09-11",
"retrieved_utc": "2026-09-10T07:46:55+00:00",
"note": "TARIFF-09's PRE-PUBLICATION case, and it needed no second file because the answer was BYTE-IDENTICAL to the bare-URL capture above - the same 1877 bytes and the same sha256. Asked for two days at 09:46 CEST, before the ~13:00-14:00 CET auction publishes 2026-09-11, the feed returned the ONE day that exists. That is the short answer this card requires to stay a `series` of what exists rather than becoming `no_data`, and the replay re-derives the identity from the bytes rather than restating it."
}
},
"energy-charts-be-price-2025-09-10-hourly.json": {
"url": "https://api.energy-charts.info/price?bzn=BE&start=2025-09-10&end=2025-09-10",
"note": "a day BEFORE Belgium's 15-minute MTU (2025-10-01), so the same endpoint answers HOURLY. Kept because the parser must not assume either spacing, and a fixture at only one granularity cannot grade that",
"covers_local_day": "2025-09-10 (Europe/Brussels)",
"granularity_s": 3600,
"intervals": 24,
"bytes": 572,
"sha256": "03fe41690810df224611f4016899f856457f67a3c12c7654da17b1331ff2eff4",
"unit_as_returned": "EUR / MWh",
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
"deprecated_as_returned": false,
"top_level_keys": [
"deprecated",
"license_info",
"price",
"unit",
"unix_seconds"
]
},
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json": {
"url": "https://api.energy-charts.info/price?bzn=BE&start=2026-09-09&end=2026-09-10",
"retrieved_utc": "2026-09-10T07:45:49+00:00",
"note": "TARIFF-09. A RANGE request, and the counterfactual the ticket is built on: `start`/`end` are INCLUSIVE Brussels calendar dates and this one answers 192 quarter-hours across TWO local days, where the bare `?bzn=BE` of the same endpoint answers 96 across one. The shipped `generic_dayahead` now derives these two dates from the `since`/`until` it is handed.",
"covers_local_days": [
"2026-09-09",
"2026-09-10"
],
"granularity_s": 900,
"intervals": 192,
"bytes": 3579,
"sha256": "4d83f5efb4294a4a682562eac619bf35e45c95edde957bfa94c402e1777cc9b3",
"unit_as_returned": "EUR / MWh",
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
"deprecated_as_returned": false,
"top_level_keys": [
"deprecated",
"license_info",
"price",
"unit",
"unix_seconds"
]
}
}
}
+626 -1
View File
@@ -92,7 +92,9 @@ from app.price import (
PRICE_OPTION_DEFAULTS, Absence, PricePoint, PriceProduct, PriceRead,
Uncertainty, WIDEN_FACTOR, brussels, required_spread, widen_factor,
)
from app.static_tariff import TariffPlan, parse_static_profile, static_plan
from app.static_tariff import (
TariffPlan, parse_static_profile, resolve_tariff, static_plan,
)
from app.validate import validate_startup_config
UTC = timezone.utc
@@ -1284,6 +1286,323 @@ check("main.py reads every arbitrage option through app/arbitrage.py or "
"arbitrage_soc_ceiling")))
# --------------------------------------------------------------------------- #
print()
print("8b. TARIFF-12 - a RETURNED degradation must not clear a fresh plan")
# ⚠️ THE STIMULUS IS A RETURN, NOT A RAISE, AND THAT IS THE WHOLE CHECK.
# `run_tariff_refresh()` guarded the plan with `except Exception` alone, and
# `load_prices()` states in its own docstring that it "RETURNS, NEVER RAISES,
# for every failure a site can actually have" - so the one case the guard was
# written for, a supplier outage, never reached it. Everything below drives the
# SHIPPED chain and stubs nothing but the socket: the transport raises the way
# aiohttp does, `load_prices()` turns that into a `no_data` read, `_degrade()`
# turns THAT into a §11.4 plan, and `run_tariff_refresh()` decides what to do
# with it. A check that patched `resolve_tariff` to raise would have passed
# against the defective code.
import asyncio # noqa: E402
from app import price as _price # noqa: E402
from app.price_history import NoHistory # noqa: E402
class ClientConnectorError(Exception):
"""aiohttp's own class name, so the §11.4 reason this drives is the
sentence the 40 h soak recorded at refresh 56, verbatim."""
_T12_FEED_UP = {"up": True}
async def _t12_transport(url, timeout_s):
"""The socket, and nothing else, is what this replaces."""
if not _T12_FEED_UP["up"]:
raise ClientConnectorError(
"Cannot connect to host api.energy-charts.info:443 ssl:default")
t0 = datetime.now(UTC).replace(minute=0, second=0, microsecond=0)
return json.dumps({
"unix_seconds": [int((t0 + timedelta(hours=i)).timestamp())
for i in range(48)],
"price": [80.0 + 40.0 * (i % 6) for i in range(48)],
"unit": "EUR / MWh"}).encode()
class _T12Controller:
"""Just enough Controller to run the SHIPPED refresh coroutine.
Both methods are bound off the shipped class rather than copied, for
`fresh_tariff_plan()`'s own stated reason: a transcription drifts.
"""
run_tariff_refresh = _main.Controller.run_tariff_refresh
fresh_tariff_plan = _main.Controller.fresh_tariff_plan
def __init__(self, opts):
self.o = opts
self.tariff_plan = None
self.tariff_at = None
self.price_history = NoHistory()
self.stop_event = asyncio.Event()
self.stop_event.set() # so the inter-refresh wait returns
self._ticks = 0
@property
def stopping(self):
self._ticks -= 1
return self._ticks < 0
def refresh_once(self):
self._ticks = 1
asyncio.run(self.run_tariff_refresh())
return self
def _t12_opts(profile=""):
# `price_timeout_s` is read with a plain `.get()` by every plugin, so it
# has to be present - an options dict that relies on price_option()'s
# defaults for it fetches with `float(None)` and this section would grade
# a TypeError instead of an outage.
return {"price_provider": "generic_dayahead", "price_timeout_s": 10,
"price_horizon_h": 48, "static_profile": profile}
_T12_HORIZON_H = 48
_T12_STALE_S = _main.TARIFF_MAX_AGE_S
_t12_saved_transport = _price.https_get
_price.https_get = _t12_transport
# -- the accepted baseline (habit 7): one good fetch, varied one thing at a
# -- time from here.
_T12_FEED_UP["up"] = True
_t12_a = derived(lambda: _T12Controller(_t12_opts()).refresh_once())
_t12_good = derived(lambda: _t12_a.tariff_plan)
check(f"BASELINE: a fetch that succeeds stores an OBSERVATION - "
f"{getattr(_t12_good, 'words', _t12_good)!r}",
getattr(_t12_good, "origin", None) == "provider"
and _t12_good.from_feed is True
and len(_t12_good.read.points) == _T12_HORIZON_H)
# -- the stimulus, graded before it is used on anything (habit 6/7) ----------
_T12_FEED_UP["up"] = False
_t12_now = datetime.now(UTC)
_t12_deg = derived(lambda: asyncio.run(resolve_tariff(
_t12_opts(), _t12_now, _t12_now + timedelta(hours=_T12_HORIZON_H),
now=_t12_now, history=NoHistory())))
check("STIMULUS: a dead feed makes resolve_tariff() RETURN a degraded plan - "
"it does not raise, so `except Exception` is not what was protecting "
"the plan and the defect was never on that branch",
isinstance(_t12_deg, TariffPlan))
check(f"...and the returned plan is the §11.4 no-profile row the soak saw - "
f"{getattr(_t12_deg, 'words', _t12_deg)!r}",
isinstance(_t12_deg, TariffPlan) and _t12_deg.origin == "none"
and _t12_deg.from_feed is False and _t12_deg.has_prices is False
and "ClientConnectorError" in _t12_deg.words)
# -- AC1: the degradation must not replace a plan still inside the bound -----
# ⚠️ 1800 s IS STATED HERE, NOT DERIVED FROM THE MODULE BEING GRADED - section
# 8's own lesson. A probe at `TARIFF_MAX_AGE_S - 1` moves with a mutation of
# the constant; half an hour is inside any reading of a 5400 s bound, and it is
# the interval the soak actually ran at.
_t12_b = derived(lambda: _T12Controller(_t12_opts()))
_t12_b.tariff_plan, _t12_b.tariff_at = _t12_good, datetime.now(UTC) - \
timedelta(seconds=1800)
_t12_at_before = _t12_b.tariff_at
check(f"PRECONDITION: the standing plan is 1800 s old and TARIFF_MAX_AGE_S is "
f"{_T12_STALE_S:.0f} s, so it IS fresh when the failed refresh runs - "
f"without this the next check grades an expired plan",
1800 < _T12_STALE_S
and _t12_b.fresh_tariff_plan(datetime.now(UTC)) is _t12_good)
_t12_b.refresh_once()
check("a RETURNED degradation does not replace a plan still inside "
"TARIFF_MAX_AGE_S - the 48 h series is still there, by identity, and "
"not a 0-point `origin: none` read",
_t12_b.tariff_plan is _t12_good
and _t12_b.tariff_plan.has_prices is True
and len(_t12_b.tariff_plan.read.points) == _T12_HORIZON_H)
check("...and `tariff_at` did NOT move, so the kept plan still ages out from "
"the fetch that produced it and is distinguishable from a freshly "
"fetched one. Re-stamping it here is how a plan freezes at its last "
"value and looks current forever",
_t12_b.tariff_at == _t12_at_before)
check("...and the two timestamps a successful refresh and a kept plan produce "
"really are different values, or the check above compares a number "
"against itself",
_t12_a.tariff_at != _t12_at_before)
# -- AC3: TARIFF_MAX_AGE_S governs - the bound is where the module says ------
_t12_c = derived(lambda: _T12Controller(_t12_opts()))
_t12_c.tariff_plan, _t12_c.tariff_at = _t12_good, datetime.now(UTC) - \
timedelta(seconds=_T12_STALE_S + 5)
_t12_c.refresh_once()
check(f"a plan that has aged PAST {_T12_STALE_S:.0f} s IS replaced by the "
f"degradation - the plan is served stale for the age bound and no "
f"longer, so TARIFF_MAX_AGE_S governs something rather than being "
f"unreachable",
_t12_c.tariff_plan is not _t12_good
and _t12_c.tariff_plan.from_feed is False
and _t12_c.tariff_plan.has_prices is False)
_t12_d = derived(lambda: _T12Controller(_t12_opts()))
_t12_d.tariff_plan, _t12_d.tariff_at = _t12_good, datetime.now(UTC) - \
timedelta(seconds=_T12_STALE_S - 60)
_t12_d.refresh_once()
check("...and one minute SHORT of the bound it is still kept, so the line "
"above is a bound rather than 'the degradation always wins'",
_t12_d.tariff_plan is _t12_good)
_t12_e = derived(lambda: _T12Controller(_t12_opts()).refresh_once())
check("a degradation with NO plan standing at all is stored - §11.4 still "
"reaches an install that has never had a good fetch, and the guard is "
"not 'never overwrite'",
_t12_e.tariff_plan is not None
and _t12_e.tariff_plan.from_feed is False
and _t12_e.tariff_at is not None)
# -- the §11.4 STATIC FALLBACK, which is the judgement this card had to make -
_t12_f = derived(lambda: _T12Controller(_t12_opts(STATIC_BE)))
_t12_fb = derived(lambda: asyncio.run(resolve_tariff(
_t12_opts(STATIC_BE), _t12_now,
_t12_now + timedelta(hours=_T12_HORIZON_H), now=_t12_now,
history=NoHistory())))
check(f"PRECONDITION: with a static_profile configured the SAME dead feed "
f"degrades to a §11.4 FALLBACK carrying real prices, not to the "
f"no-profile row - {getattr(_t12_fb, 'words', _t12_fb)!r}",
isinstance(_t12_fb, TariffPlan) and _t12_fb.origin == "fallback"
and _t12_fb.has_prices is True and _t12_fb.from_feed is False)
check("...and every one of its points is provisional, so firm_points() is "
"empty, arbitrage() refuses and cheap_now() is False - it is a real "
"tariff and nothing may be acted on it. That is why it does not replace "
"a fresh observation",
isinstance(_t12_fb, TariffPlan) and _t12_fb.provisional is True
and _t12_fb.firm_points() == ()
and _t12_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=NO_WEATHER).kind == "refused")
_t12_f.tariff_plan, _t12_f.tariff_at = _t12_good, datetime.now(UTC) - \
timedelta(seconds=1800)
_t12_f_at = _t12_f.tariff_at
_t12_f.refresh_once()
check("a §11.4 static FALLBACK does not replace a fresh provider plan either, "
"although it has prices - a day-ahead price for an hour does not change "
"once published, so the 30-minute-old observation is no less true and "
"is the only one of the two that carries a firm hour",
_t12_f.tariff_plan is _t12_good and _t12_f.tariff_at == _t12_f_at)
# -- ...and the rule is NOT symmetrical: a degradation replaces a degradation.
_t12_g = derived(lambda: _T12Controller(_t12_opts(STATIC_BE)))
_t12_g.tariff_plan, _t12_g.tariff_at = _t12_fb, datetime.now(UTC) - \
timedelta(seconds=1800)
_t12_g_at = _t12_g.tariff_at
_t12_g.refresh_once()
check("a standing plan that is ITSELF a §11.4 fallback IS replaced by the "
"next one, and its timestamp moves - otherwise an outage would freeze "
"the operator's static profile at the window it was first generated "
"for, and the guard would be 'never overwrite' wearing a bound's name",
_t12_g.tariff_plan is not _t12_fb and _t12_g.tariff_plan.origin ==
"fallback" and _t12_g.tariff_at != _t12_g_at)
# -- ...and a recovered feed replaces a kept plan, which is the third term ---
_T12_FEED_UP["up"] = True
_t12_h = derived(lambda: _T12Controller(_t12_opts()))
_t12_h.tariff_plan, _t12_h.tariff_at = _t12_good, datetime.now(UTC) - \
timedelta(seconds=1800)
_t12_h_at = _t12_h.tariff_at
_t12_h.refresh_once()
check("when the feed answers again the observation replaces the kept plan and "
"`tariff_at` moves - the guard keeps a plan against a DEGRADATION, not "
"against a fetch that worked",
_t12_h.tariff_plan is not _t12_good
and _t12_h.tariff_plan.from_feed is True
and _t12_h.tariff_at != _t12_h_at)
# -- TARIFF-10 widened WHICH INSTALLS RUN THIS TASK, so the configurations it
# -- newly keeps alive are graded here rather than reasoned about.
# ⚠️ `price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
# configuration validate.py ACCEPTS and TARIFF-10's `or` exists to protect. On
# it `resolve_tariff()` returns `static_plan(provisional=False)` - the
# CONFIGURED product, origin "static" - so `standing.from_feed` is false on
# every refresh and this card's guard must never fire. Confirmed by driving it,
# not by reading the origin table: a guard that quietly latched here would
# freeze a static site's plan at its first window for the life of the install.
_t12_i = derived(lambda: _T12Controller(
{"price_provider": "off", "price_timeout_s": 10, "price_horizon_h": 48,
"static_profile": STATIC_BE}).refresh_once())
_t12_i_plan, _t12_i_at = _t12_i.tariff_plan, _t12_i.tariff_at
_t12_i.refresh_once()
check("PRECONDITION: a `price_provider: off` + static_profile site resolves to "
"the CONFIGURED product, not to a §11.4 row - origin 'static', prices, "
"and from_feed false because there is no feed to have observed",
getattr(_t12_i_plan, "origin", None) == "static"
and _t12_i_plan.has_prices is True and _t12_i_plan.from_feed is False)
check("...and TARIFF-10 now keeps this task alive on exactly that site, where "
"this card's guard must NEVER latch: the second refresh replaces the "
"first and the timestamp moves, so the plan is regenerated for the "
"current window every 30 minutes as it always was",
_t12_i.tariff_plan is not _t12_i_plan
and _t12_i.tariff_plan.origin == "static"
and _t12_i.tariff_at != _t12_i_at)
# ⚠️ AND THE OTHER CONFIGURATION TARIFF-10 ADDED - a provider configured with
# trading DISARMED - is graded by every driven check above without a second
# fixture, because nothing on this path reads `arbitrage_enabled` at all. That
# is a claim about the code rather than about a behaviour, so it is asserted as
# one (habit 4's exception, FCST-06's precedent): a fixture that set the option
# and asserted no difference would be comparing a thing against itself.
_T12_TRADING_OPTS = ("arbitrage_enabled", "cycle_cost_threshold_eur",
"arbitrage_soc_floor", "arbitrage_soc_ceiling")
# ⚠️ AND IT IS WHAT THE FUNCTION READS THAT IS SCANNED, NOT ITS TEXT. Two
# earlier drafts of this check were red against a CORRECT tree: the first
# because the docstring paragraph that SAYS `arbitrage_enabled` is not read is
# itself an occurrence of the string, the second because the log line says
# "arbitrage, where it is armed, stops with it" - which is a sentence, not a
# read. A substring test over a function grades its prose as much as its
# behaviour (habit 4). So the symbols are taken from the AST, and the string
# constants are taken separately and with the docstring dropped: naming
# arbitrage in a message is fine, READING a trading option is the thing claimed
# not to happen.
_t12_syms = derived(lambda: {getattr(n, "id", None) or getattr(n, "attr", None)
for n in ast.walk(_refresh)
if isinstance(n, (ast.Name, ast.Attribute))})
_t12_consts = derived(lambda: {c.value for b in _refresh.body[1:]
for c in ast.walk(b)
if isinstance(c, ast.Constant)
and isinstance(c.value, str)})
check("run_tariff_refresh() READS no trading option and calls nothing in "
"app/arbitrage.py, so keeping a plan is a decision about what the add-on "
"has LOOKED UP - TARIFF-10's own distinction, and why the checks above "
"cover a provider-configured site whether or not arbitrage is armed",
isinstance(_t12_syms, set)
and not any("arbitrage" in (n or "") for n in _t12_syms)
and not (_t12_consts & set(_T12_TRADING_OPTS))
# ...and both scanners can see what IS there, or they grade nothing: the
# one option this task does read, and a name it really does call.
and "price_horizon_h" in _t12_consts
and "fresh_tariff_plan" in _t12_syms
# ...and the docstring really was dropped, or the first draft's defect is
# back and the constant set is grading prose again.
and not any("RETURNS, NEVER RAISES" in c for c in _t12_consts))
# -- the vocabulary, graded on its own so a mutation of it has a named home --
check("TariffPlan.from_feed is true of exactly the observation - a §11.4 "
"fallback, a no-data read and a CONFIGURED static profile are all false, "
"and it is not a synonym for has_prices",
_t12_good.from_feed is True and _t12_fb.from_feed is False
and _t12_deg.from_feed is False
and static_plan({"static_profile": STATIC_BE}, D0,
D0 + timedelta(days=2), now=D0).from_feed is False
and static_plan({"static_profile": STATIC_BE}, D0,
D0 + timedelta(days=2), now=D0).has_prices is True)
# ⚠️ A SHAPE CLAIM, AND IT IS ALLOWED TO BE ONE (FCST-06's precedent). "The age
# is judged in ONE place" is a claim about the code, not about a behaviour: a
# transcribed copy of the same comparison behaves identically until the day the
# two drift. Every other check in this section is driven.
check("run_tariff_refresh() asks fresh_tariff_plan() rather than re-deriving "
"the age bound - one place judges it, which is what that method's own "
"docstring is for",
_refresh is not None
and {n.func.attr for n in ast.walk(_refresh)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)}
>= {"fresh_tariff_plan"})
_price.https_get = _t12_saved_transport
# --------------------------------------------------------------------------- #
print()
print("9. the startup gate - the configurations that cannot work are refused")
@@ -1376,6 +1695,312 @@ check("soc_bounds() returns the shipped pair, floor first",
float(PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])))
# --------------------------------------------------------------------------- #
print()
print("10. TARIFF-14 - the QUARTER-HOURLY day, end to end through decide()")
# ⚠️ THIS IS THE PROPERTY THE DEFECT ACTUALLY BROKE, WHICH IS WHY IT IS GRADED
# HERE AND NOT ONLY ON `at()`. `_act()` decides whether this interval is the
# buy one or the sell one with `here.start == v.cheapest.start` - an IDENTITY
# test between the point `_point_at()` matched and the point the verdict was
# built from. `_Series.at()` used to return the first point whose start was
# within one hour BEFORE the instant, in list order, so on a 900 s series that
# identity almost never held: the strategy idled through exactly the interval
# it exists to trade, and read `here.provisional` and §17's `price < 0` off a
# point that was not the one being traded.
#
# Belgium moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
# returns 96 points at 900 s. `static_tariff.py` emits HOURLY points, so every
# fixture above this line - and every static site - is immune; this is
# reachable only down the dynamic path TARIFF-08/09 made work.
QD0 = datetime(2026, 1, 15, 0, 0, tzinfo=timezone(timedelta(hours=1)))
QUARTER = timedelta(minutes=15)
def qplan_of(prices, *, provisional=False, start=QD0, gaps=None):
"""A TariffPlan over `prices` spaced by `gaps` seconds (900 s by default).
`gaps` is what makes the MIXED fixture below expressible: FCST-04's
carry-forward emits HOURLY carried points beside QUARTER-HOURLY firm ones,
and the soak measured spacing [900, 3600] in 70 % of samples.
"""
g = [900] * (len(prices) - 1) if gaps is None else list(gaps)
flags = ([bool(provisional)] * len(prices)
if isinstance(provisional, bool) else list(provisional))
pts, t = [], start
for i, (p, f) in enumerate(zip(prices, flags)):
pts.append(PricePoint(start=brussels(t), eur_per_kwh=round(float(p), 6),
provisional=bool(f)))
if i < len(g):
t += timedelta(seconds=g[i])
return TariffPlan(PriceRead.series(pts, PriceProduct.DAY_AHEAD,
"generic_dayahead", start),
None, "provider")
def qprobe(i, *, start=QD0, gaps=None):
"""An instant 7 minutes into slot `i` - inside every window, never on a
boundary, and NOT the midpoint of an hour either, so an implementation that
answered per hour and one that answers per quarter differ here."""
g = [900] * i if gaps is None else list(gaps)[:i]
return start + timedelta(seconds=sum(g)) + timedelta(minutes=7)
def shipped_at(series, u):
"""THE RULE TARIFF-14 REPLACED, kept here as the COUNTERFACTUAL ONLY.
The first point whose start is within one hour before `u`, in list order.
Nothing below is graded against it; it exists so that the fixtures can be
shown to SEPARATE the two rules. A check whose data cannot reach the
failure it names is decoration (MUTATION.md), and on an hourly series the
two rules agree at every instant - which is why 29 files stayed green.
"""
uu = u.astimezone(UTC)
for p in series.points:
s = p.start.astimezone(UTC)
if s <= uu < s + timedelta(hours=1):
return p
return None
# The hourly day interpolated across its quarters, with the two extremes
# planted OFF the hour boundary on purpose.
QBASE = []
for _i in range(24):
_a, _b = DAY[_i], DAY[(_i + 1) % 24]
for _q in range(4):
QBASE.append(round(_a + (_b - _a) * _q / 4 + 0.000001 * (_i * 4 + _q),
6))
Q_CHEAP_I = 4 * 5 + 3 # 05:45 - a :45 quarter, not a whole hour
Q_DEAR_I = 4 * 19 + 1 # 19:15 - a :15 quarter, not a whole hour
QDAY = list(QBASE)
QDAY[Q_CHEAP_I] = round(min(QBASE) - 0.030, 6)
QDAY[Q_DEAR_I] = round(max(QBASE) + 0.030, 6)
QPLAN = qplan_of(QDAY)
QPTS = QPLAN.read.points
print("10a. the fixture is not degenerate, and it SEPARATES the two rules")
_qgaps = sorted({int((b.start - a.start).total_seconds())
for a, b in zip(QPTS, QPTS[1:])})
check(f"the day is 96 points at {_qgaps} s - the shape the live feed has "
f"returned since Belgium's 2025-10-01 move to a 15-minute MTU",
len(QPTS) == 96 and _qgaps == [900])
check(f"the cheapest quarter is slot {Q_CHEAP_I} and the dearest slot "
f"{Q_DEAR_I} - neither first, neither last, {abs(Q_CHEAP_I - Q_DEAR_I)} "
f"slots apart, and NEITHER ON A WHOLE HOUR, without which this section "
f"would grade nothing an hourly fixture does not already grade",
QDAY.index(min(QDAY)) == Q_CHEAP_I and QDAY.index(max(QDAY)) == Q_DEAR_I
and Q_CHEAP_I % 4 != 0 and Q_DEAR_I % 4 != 0
and Q_CHEAP_I not in (0, 95) and Q_DEAR_I not in (0, 95)
and abs(Q_CHEAP_I - Q_DEAR_I) > 1)
check("...and all 96 prices are distinct, so 'the cheapest quarter' names one "
"quarter and no tie-break is what any check below is really grading",
len(set(QDAY)) == 96)
check(f"...and its spread is {round(max(QDAY) - min(QDAY), 6):.4f} EUR/kWh, "
f"clear of the widened threshold, so what follows grades the strategy "
f"and not the spread rung",
round(max(QDAY) - min(QDAY), 6) > required_spread(_shipped, NO_WEATHER))
# ⚠️ HABIT 4, AND THE ONLY REASON THIS SECTION IS WORTH ITS LINES. State, as a
# check, that the shipped rule and the fixed one give DIFFERENT points at the
# two probes that matter. Without it, every check below could be passing on a
# fixture the defect could not have changed.
_cf_buy = derived(lambda: shipped_at(QPLAN.read, qprobe(Q_CHEAP_I)))
_cf_sell = derived(lambda: shipped_at(QPLAN.read, qprobe(Q_DEAR_I)))
check(f"the shipped rule and this one give DIFFERENT points at both probes - "
f"buy: {derived(lambda: _cf_buy.eur_per_kwh)} instead of "
f"{QDAY[Q_CHEAP_I]}, sell: {derived(lambda: _cf_sell.eur_per_kwh)} "
f"instead of {QDAY[Q_DEAR_I]} - so the fixture can reach the failure it "
f"names",
_cf_buy is not QPTS[Q_CHEAP_I] and _cf_sell is not QPTS[Q_DEAR_I]
and _cf_buy is not None and _cf_sell is not None)
print()
print("10b. the buy/sell identity test matches the actual extremum interval")
_qv = QPLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
uncertainty=NO_WEATHER)
check("the verdict's cheapest and dearest are the two QUARTERS, not the hours "
"containing them - `static_tariff` reduces over every firm point and "
"carries no resolution assumption of its own",
derived(lambda: _qv.cheapest) is QPTS[Q_CHEAP_I]
and derived(lambda: _qv.dearest) is QPTS[Q_DEAR_I])
_pa_buy = derived(lambda: _arb._point_at(QPLAN, qprobe(Q_CHEAP_I)))
_pa_sell = derived(lambda: _arb._point_at(QPLAN, qprobe(Q_DEAR_I)))
check("THE PROPERTY THE DEFECT BROKE: at the buy probe `_point_at()` returns "
"the verdict's OWN cheapest point, by identity - which is what makes "
"`_act()`'s `here.start == v.cheapest.start` true, and what made it "
"false on every quarter-hourly series the add-on has ever fetched",
_pa_buy is derived(lambda: _qv.cheapest))
check("...and the same at the sell probe, against the verdict's dearest",
_pa_sell is derived(lambda: _qv.dearest))
_qbuy = decide(QPLAN, qprobe(Q_CHEAP_I), peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
_qsell = decide(QPLAN, qprobe(Q_DEAR_I), peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
check("...so decide() CHARGES in the cheapest quarter and DISCHARGES in the "
"dearest one, which is the trade the strategy exists to make and the "
"one it slept through",
_qbuy.action == CHARGE and _qsell.action == DISCHARGE)
check(f"...at THAT QUARTER's price and not at the price of the hour "
f"containing it (buy {_qbuy.price_now_eur_kwh}, sell "
f"{_qsell.price_now_eur_kwh})",
near(_qbuy.price_now_eur_kwh, QDAY[Q_CHEAP_I])
and near(_qsell.price_now_eur_kwh, QDAY[Q_DEAR_I]))
check("...and both reasons quote that quarter's price, so the operator's log "
"names the interval that was actually traded",
f"{QDAY[Q_CHEAP_I]:.4f}" in _qbuy.reason
and f"{QDAY[Q_DEAR_I]:.4f}" in _qsell.reason)
# The negative arm. Without it "it acted" could be "it acts everywhere".
_QOFF_I = Q_CHEAP_I + 2
check(f"the off-peak probe's own price differs from its hour's :00 price "
f"({QDAY[_QOFF_I]} vs {QDAY[4 * (_QOFF_I // 4)]}), so the check below "
f"can tell which point the decision read",
QDAY[_QOFF_I] != QDAY[4 * (_QOFF_I // 4)]
and _QOFF_I not in (Q_CHEAP_I, Q_DEAR_I))
_qoff = decide(QPLAN, qprobe(_QOFF_I), peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
check("a quarter that is neither extremum still idles, for the reason that "
"names the right rule, and reports ITS OWN price - not the price of the "
"quarter the shipped scan would have matched",
_qoff.action is None and _qoff.reason == _arb.IDLE_OFF_PEAK_HOUR
and near(_qoff.price_now_eur_kwh, QDAY[_QOFF_I]))
print()
print("10c. §17 and §8.3 are evaluated on the QUARTER being traded")
# ⚠️ §17 AT 900 s. The whole day is below zero and the dearest QUARTER is the
# least negative one - section 2's shape, at the resolution the live feed
# actually publishes. Under the shipped scan this probe never reached the
# guard at all: the identity test failed first and the decision idled as
# "neither the buy hour nor the sell hour", so §17 was not so much wrong as
# never consulted.
_shift = round(max(QDAY) + 0.020, 6)
QNEG = [round(p - _shift, 6) for p in QDAY]
QNEG_PLAN = qplan_of(QNEG)
check(f"the negative day's dearest quarter is slot {Q_DEAR_I} and it is BELOW "
f"ZERO ({max(QNEG):+.4f}), and its spread still clears the widened "
f"threshold - so the refusal below is §17's and not the spread rung's",
QNEG.index(max(QNEG)) == Q_DEAR_I and max(QNEG) < 0
and round(max(QNEG) - min(QNEG), 6) > required_spread(_shipped,
NO_WEATHER))
_qneg_sell = decide(QNEG_PLAN, qprobe(Q_DEAR_I), peak_guard_active=False,
soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=NO_WEATHER, charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
check("selling into a negative-price QUARTER is refused, and the reason names "
"§17 and THAT QUARTER's price",
_qneg_sell.action is None and "§17" in _qneg_sell.reason
and f"{max(QNEG):+.4f}" in _qneg_sell.reason)
# ...and the other side, so the refusal is about the sign and nothing else.
QPOS = list(QNEG)
QPOS[Q_DEAR_I] = round(-max(QNEG), 6)
check("...while the SAME quarter-hourly series with that ONE price's SIGN "
"FLIPPED discharges instead",
decide(qplan_of(QPOS), qprobe(Q_DEAR_I), peak_guard_active=False,
soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == DISCHARGE
and sum(1 for a, b in zip(QNEG, QPOS) if a != b) == 1)
# §8.3's flag, read off the covering point. `_act`'s own provisional guard
# stays unreachable here for section 4's reason - the extrema are firm by
# `_firm_or_die` and starts are unique - and THAT ARGUMENT IS EXACTLY WHAT THE
# DEFECT BROKE at 900 s, because the point `at()` returned was not the
# verdict's point at all. It is restated here at the resolution that matters.
check("the point matched for the buy quarter IS the verdict's own cheapest "
"point and is FIRM, so `_act`'s §8.3 guard is unreachable at 900 s for "
"the same construction section 4 states at 3600 s",
_pa_buy is derived(lambda: _qv.cheapest)
and derived(lambda: _pa_buy.provisional) is False
and len({p.start for p in QPTS}) == len(QPTS))
print()
print("10d. the MIXED series FCST-04 actually emits, traded end to end")
# ⚠️ 70 % OF SOAK SAMPLES, NOT AN EDGE CASE. The carry-forward emits HOURLY
# carried points beside QUARTER-HOURLY firm ones, so a window derived from one
# global spacing would be wrong in the NORMAL steady state. Here: 12 carried
# hourly points for the morning, then firm quarters for the rest of the day.
MIX_N_H, MIX_N_Q = 12, 48
MIX_GAPS = [3600] * MIX_N_H + [900] * (MIX_N_Q - 1)
MIX_PRICES = [QDAY[4 * i] for i in range(MIX_N_H)] + QDAY[4 * MIX_N_H:]
MIX_FLAGS = [True] * MIX_N_H + [False] * MIX_N_Q
MIX_PLAN = qplan_of(MIX_PRICES, provisional=MIX_FLAGS, gaps=MIX_GAPS)
MIX_PTS = MIX_PLAN.read.points
_mix_gaps = sorted({int((b.start - a.start).total_seconds())
for a, b in zip(MIX_PTS, MIX_PTS[1:])})
check(f"the mixed plan really is mixed - spacing {_mix_gaps} s, "
f"{MIX_N_H} carried and {MIX_N_Q} firm points, so neither half is what "
f"the other half's window rule would give",
_mix_gaps == [900, 3600]
and sum(1 for p in MIX_PTS if p.provisional) == MIX_N_H
and sum(1 for p in MIX_PTS if not p.provisional) == MIX_N_Q)
_mix_cheap_i = MIX_N_H + (MIX_PRICES[MIX_N_H:].index(min(MIX_PRICES[MIX_N_H:])))
_mix_dear_i = MIX_N_H + (MIX_PRICES[MIX_N_H:].index(max(MIX_PRICES[MIX_N_H:])))
check(f"...and its firm half's extremes are quarters {_mix_cheap_i} and "
f"{_mix_dear_i}, neither on a whole hour, so the identity test below is "
f"about the quarter and not about the hour",
(_mix_cheap_i - MIX_N_H) % 4 != 0 and (_mix_dear_i - MIX_N_H) % 4 != 0)
_mv = MIX_PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
uncertainty=NO_WEATHER)
_mix_buy_t = qprobe(_mix_cheap_i, gaps=MIX_GAPS)
_mix_sell_t = qprobe(_mix_dear_i, gaps=MIX_GAPS)
check("the verdict over a MIXED series still names firm quarters, and "
"`_point_at()` matches them by identity at their own probes",
derived(lambda: _mv.cheapest) is MIX_PTS[_mix_cheap_i]
and derived(lambda: _mv.dearest) is MIX_PTS[_mix_dear_i]
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_buy_t))
is MIX_PTS[_mix_cheap_i]
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_sell_t))
is MIX_PTS[_mix_dear_i])
check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
"their own prices",
decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == CHARGE
and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == DISCHARGE)
# An instant inside a CARRIED hour of the same series. The covering point is
# the carried one; the shipped scan answered a point from the FIRM block
# earlier in the list, which is §8.3's flag read off the wrong point.
_mix_carried_t = QD0 + timedelta(hours=MIX_N_H - 1, minutes=37)
check("an instant inside a CARRIED hour of the mixed series matches the "
"CARRIED point, whose provisional flag is what §8.3 reads - and the "
"strategy does nothing there, because a carried point is not in the "
"firm trade set",
derived(lambda: _arb._point_at(MIX_PLAN, _mix_carried_t))
is MIX_PTS[MIX_N_H - 1]
and MIX_PTS[MIX_N_H - 1].provisional is True
and decide(MIX_PLAN, _mix_carried_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action is None)
print()
print("10e. the HOURLY day is unchanged, which is the regression half")
# ⚠️ A REGRESSION GUARD, NOT A WITNESS. On an hourly series the shipped rule
# and this one agree at every instant, which is precisely why 29 files stayed
# green through the defect. It is here so that a fix which got 900 s right by
# breaking 3600 s cannot pass.
_h_buy = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER)
_h_sell = act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER)
check("the hourly fixture still charges in its cheapest hour and discharges "
"in its dearest, at those hours' prices",
_h_buy.action == CHARGE and _h_sell.action == DISCHARGE
and near(_h_buy.price_now_eur_kwh, DAY[CHEAP_H])
and near(_h_sell.price_now_eur_kwh, DAY[DEAR_H]))
check("...and a probe 59 minutes into an hourly slot still matches that slot, "
"so the hourly window did not shrink under the fix",
derived(lambda: _arb._point_at(
PLAN, D0 + timedelta(hours=CHEAP_H, minutes=59)))
is derived(lambda: PLAN.read.points[CHEAP_H]))
# --------------------------------------------------------------------------- #
print()
if fails:
File diff suppressed because it is too large Load Diff
+28 -14
View File
@@ -289,22 +289,29 @@ check("...and never repairs a flag: the audit's only outcomes are silence and "
for i, u in enumerate(DAY[:PUBLISHED_N])})
is None)
# ⚠️ A PUBLISHED POINT MAY BE PROVISIONAL. eneco_eddy's current-quarter print
# is settled after the fact and says so (§9.2). "Published" is not "firm".
_eddy = series_of(DAY[:2], lambda u: t_price(brussels(u).hour),
PriceProduct.REALTIME, "eneco_eddy", provisional=True)
# ⚠️ A PUBLISHED POINT MAY BE PROVISIONAL. A real-time / imbalance product's
# current interval is settled after the fact and says so per point (§9.2).
# "Published" is not "firm", and §9.3 carries the flag PER POINT for exactly
# this. No shipped plugin declares REALTIME since TARIFF-07, so the series is
# declared here - which is the only place the case can come from now, and is
# why this stimulus did not go with the plugin.
_rt = series_of(DAY[:2], lambda u: t_price(brussels(u).hour),
PriceProduct.REALTIME, "_realtime_not_shipped",
provisional=True)
_eddy_filled = derived(lambda: fill_from_yesterday(
_eddy, yesterday=YESTERDAY, since=D0, until=D1, stamp=D0))
_rt, yesterday=YESTERDAY, since=D0, until=D1, stamp=D0))
check("a PUBLISHED point that the feed itself flagged provisional keeps that "
"flag - the audit checks provenance, and does not force published hours "
"to False",
_eddy_filled.kind == "series"
and all(p.provisional for p in _eddy_filled.points[:2])
and _eddy_filled.points[0] is _eddy.points[0])
and _eddy_filled.points[0] is _rt.points[0])
# ⚠️ AND A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR (FCST-04 H1).
# `eneco_eddy` settles per QUARTER and `_validate_raw()` accepts any
# strictly-increasing spacing, so a published hour can hold four points. The
# BELGIAN DAY-AHEAD ITSELF SETTLES PER QUARTER - the 15-minute MTU of
# 2025-10-01, which `generic_dayahead` reads as 96 points a day - and
# `_validate_raw()` accepts any strictly-increasing spacing, so a published
# hour can hold four points. The
# fill walks WHOLE HOURS to decide what needs carrying; that must not become a
# rule about what a published hour may contain. Measured on FCST-04's first
# draft, which keyed the published set `{_utc_hour(p.start): p ...}`: a
@@ -316,7 +323,7 @@ _QUARTERS = PriceRead.series(
eur_per_kwh=round(0.100000 + 0.011300 * n, 6),
provisional=False)
for n in range(_QN)],
PriceProduct.REALTIME, "eneco_eddy", D0)
PriceProduct.DAY_AHEAD, "generic_dayahead", D0)
_QHOURS = {_fc._utc_hour(p.start) for p in _QUARTERS.points}
check(f"the quarter-hourly fixture really is sub-hourly - {_QN} points across "
f"{len(_QHOURS)} hours - or every check below passes for an hourly feed "
@@ -942,13 +949,20 @@ _normal_named_eddy = derived(lambda: min_accepted_spread(
PriceProduct.DAY_AHEAD, frozenset(), BASE, provider="eneco_eddy"))
check("the widening follows the DECLARED PriceProduct even when the provider "
"is called 'generic_dayahead', and does NOT follow the name "
"'eneco_eddy' when the declared product is day-ahead - so a sixth "
"supplier is widened by its declaration and not by an id in a list",
"'eneco_eddy' when the declared product is day-ahead - so a supplier is "
"widened by its declaration and not by an id in a list",
near(_wide_named_dayahead, _wide, 1e-9)
and near(_normal_named_eddy, _normal, 1e-9))
check("...and the shipped plugin §9.2 names IS the one that declares REALTIME, "
"so the mechanism is pointed at the real product",
PROVIDERS["eneco_eddy"].capabilities().product is PriceProduct.REALTIME
# ⚠️ 'eneco_eddy' ABOVE IS NOW A RETIRED ID AND THE CHECK IS STRONGER FOR IT.
# It was chosen because it was the one plugin that really did declare REALTIME,
# so the check read "the name does not override the declaration". It declares
# nothing now, which makes the string a pure name with no product behind it -
# exactly the input an id-sniffing implementation would still widen.
check("...and NO shipped plugin declares REALTIME since TARIFF-07, so the "
"widening path is reached by declaration alone - kept deliberately, see "
"DECISIONS/TARIFF-07-withdrawing-three-plugins.md",
[p for p, c in PROVIDERS.items()
if c.capabilities().product is PriceProduct.REALTIME] == []
and uncertainty_of(PriceProduct.REALTIME)
== frozenset({Uncertainty.REALTIME_PRODUCT})
and uncertainty_of(PriceProduct.DAY_AHEAD) == frozenset())
+445 -11
View File
@@ -288,9 +288,25 @@ def allin(mwh):
def feed_body(instants, table):
return json.dumps({"data": [
{"start": u.isoformat(), "price_eur_mwh": table[brussels(u).hour]}
for u in instants]}).encode()
"""A `generic_dayahead` body in the COLUMNAR shape the real feed publishes.
⚠️ TARIFF-08. This used to build `{"data": [{"start": ...,
"price_eur_mwh": ...}]}` - a shape `api.energy-charts.info` has never
published and that the shipped parser was nevertheless written to expect.
Every price fixture in the repository fed that invented shape, so the
suite graded the parser against the parser's own assumption while the live
plugin raised `SourceUnavailable` on every fetch. Real captured bodies are
replayed in test_price.py section 2b; the fixtures here are SYNTHETIC
prices in the REAL shape, because this file is grading the fill and the
history, not the parse.
"""
return json.dumps({
"unix_seconds": [int(u.timestamp()) for u in instants],
"price": [table[brussels(u).hour] for u in instants],
"unit": "EUR / MWh",
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
"from Bundesnetzagentur | SMARD.de",
"deprecated": False}).encode()
def transport_for(body):
@@ -299,8 +315,12 @@ def transport_for(body):
return _t
# ⚠️ NO `price_feed_url` (TARIFF-09). An override is used VERBATIM and the
# plugin appends no date range to it, so a CFG that sets one - even to the
# feed's own address - would exercise the override path in every check in this
# file and never the shipped default. The override has its own grading in
# test_price.py section 2c.
CFG = {"price_provider": "generic_dayahead",
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
"price_timeout_s": 1,
"price_markup_eur_kwh": MARKUP,
"price_taxes_eur_kwh": TAXES,
@@ -501,6 +521,220 @@ check("...and the plan is still a provider plan, not a §11.4 fallback: the "
_after.origin == "provider" and _after.product is PriceProduct.DAY_AHEAD)
# --------------------------------------------------------------------------- #
print()
print("1c. TARIFF-09 - the feed is now ASKED for the horizon, and tomorrow "
"comes back FIRM")
#
# ⚠️ WHAT SECTION 1 COULD NOT SEE, AND WHY. Everything above grades the FILL
# against a feed that published one day, and it was right to: FCST-04 covers
# the window between midnight and the ~13:00-14:00 CET auction. But
# `generic_dayahead.fetch()` accepted `since`/`until` and requested the bare
# `?bzn=BE`, which answers one Brussels day AT EVERY HOUR OF EVERY DAY - so the
# one-day feed was not the pre-publication state, it was the ONLY state, and
# §8.3 dropped every hour past today from the trade set as provisional. The
# optimiser never saw tomorrow as firm.
#
# Measured on the merged tree (4257e38) at 2026-09-10T09:00+02:00 over a 48 h
# horizon, live feed, yesterday seeded: 15 firm whole hours and 33 carried,
# and BOTH ends of the permitted cycle inside 2026-09-10. At 23:00 it was 1
# firm and 47 carried, and the firm spread no longer cleared the round-trip
# cost at all. The ceiling was structural - at hour H a one-day window gives at
# most 24-H firm hours - which is why no amount of carrying could fix it.
#
# The two plans below differ in ONE thing: what the feed answered. The code
# path, the window, the store and the instant are identical, so the difference
# is the horizon and nothing else (habit 7).
TOMORROW_D = "2026-01-16"
TOMORROW_H = bday_hours(TOMORROW_D)
# ⚠️ TOMORROW'S PRICES MUST NOT OVERLAP TODAY'S OR YESTERDAY'S, or a point's
# day of origin is not recoverable from its number and every claim below is
# about a coincidence. Asserted, not assumed.
TOMORROW_MWH = {h: 300.0 + h for h in range(24)}
TOMORROW_MWH[18] = 900.0 # the dear evening TOMORROW: the global peak of
# the 48 h window, and it is on the second day
PRICE_AT = {u: TODAY_MWH[brussels(u).hour] for u in TODAY_H}
PRICE_AT.update({u: TOMORROW_MWH[brussels(u).hour] for u in TOMORROW_H})
def body_at(instants):
"""A columnar body priced PER INSTANT, so two days can differ at the same
local hour. `feed_body()` keys on the local hour alone and cannot."""
return json.dumps({
"unix_seconds": [int(u.timestamp()) for u in instants],
"price": [PRICE_AT[u] for u in instants],
"unit": "EUR / MWh",
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
"from Bundesnetzagentur | SMARD.de",
"deprecated": False}).encode()
def recording(body):
"""A transport that serves bytes and remembers the URL it was asked for."""
seen = []
async def _t(url, timeout_s):
seen.append(url)
return body
_t.seen = seen
return _t
_vals_today = {TODAY_MWH[h] for h in range(24)}
_vals_tmrw = {TOMORROW_MWH[h] for h in range(24)}
_vals_yday = {YESTERDAY_MWH[h] for h in range(24)}
check(f"the three days' price tables share no value at all "
f"({len(_vals_today)}/{len(_vals_tmrw)}/{len(_vals_yday)} distinct), so "
f"which day a point came from is recoverable from the point",
not (_vals_today & _vals_tmrw) and not (_vals_tmrw & _vals_yday)
and not (_vals_today & _vals_yday))
check(f"...and both fixture days are 24 Brussels hours long "
f"({len(TODAY_H)}/{len(TOMORROW_H)}), found by search rather than by "
f"adding 24 UTC hours",
len(TODAY_H) == 24 and len(TOMORROW_H) == 24)
# THE TWO FEED STATES, one code path.
_t_two = recording(body_at(TODAY_H + TOMORROW_H))
_h_two = store()
_ph.PriceHistory._remember(_h_two, series_of(YDAY_H, YESTERDAY_MWH), NOW)
_pub = run(resolve_tariff(CFG, NOW, _until, transport=_t_two, now=NOW,
history=_h_two))
_t_one = recording(body_at(TODAY_H))
_h_one = store()
_ph.PriceHistory._remember(_h_one, series_of(YDAY_H, YESTERDAY_MWH), NOW)
_pre = run(resolve_tariff(CFG, NOW, _until, transport=_t_one, now=NOW,
history=_h_one))
# ⚠️ THE REQUEST, WHICH IS THE THING THIS CARD CHANGED. Both calls ask for the
# same three Brussels days; only the answer differs. The expected dates are
# spelled out here rather than derived from the module that builds them.
check(f"the shipped plugin asks the feed for the Brussels days the horizon "
f"touches, on both calls -> {_t_two.seen}",
_t_two.seen == _t_one.seen
== ["https://api.energy-charts.info/price?bzn=BE"
"&start=2026-01-15&end=2026-01-17"])
check("...and it is no longer the bare URL that answers a single day, which "
"is what made the one-day state permanent rather than pre-publication",
"start=" in _t_two.seen[0] and "end=" in _t_two.seen[0])
def split(plan):
"""(firm, carried), and ([], []) for a plan that observed no series.
⚠️ THE EMPTY ANSWER IS DELIBERATE. A mutation that turns the short
pre-publication answer into `no_data` makes `plan.read.points` raise
`Absence` by design, and an unguarded read here would take this file down
with a traceback rather than reporting a named FAIL - "the suite is red"
and "the check that watches this is red" are different claims
(MUTATION.md, reporting a mutation, point 3). With this guard the counts
below go red BY NAME, which is what a mutation report can cite.
"""
if not plan.has_prices:
return [], []
firm = [p for p in plan.read.points if not p.provisional]
carried = [p for p in plan.read.points if p.provisional]
return firm, carried
def local_days(points):
return sorted({brussels(p.start).strftime("%Y-%m-%d") for p in points})
_f2, _c2 = split(_pub)
_f1, _c1 = split(_pre)
# The expected counts are re-derived from the fixture by set arithmetic: the
# horizon hours the feed printed, versus the horizon hours it did not.
_window = [SINCE_HOUR + timedelta(hours=n) for n in range(HORIZON_H)]
_two_pub_utc = {u.astimezone(UTC) for u in TODAY_H + TOMORROW_H}
_one_pub_utc = {u.astimezone(UTC) for u in TODAY_H}
_want_f2 = len([u for u in _window if u.astimezone(UTC) in _two_pub_utc])
_want_f1 = len([u for u in _window if u.astimezone(UTC) in _one_pub_utc])
check(f"WITH TOMORROW PUBLISHED the 48 h plan is {len(_f2)} firm hours and "
f"{len(_c2)} carried; with only today, {len(_f1)} firm and {len(_c1)} "
f"carried. Re-derived from the fixture: {_want_f2} and {_want_f1}",
len(_f2) == _want_f2 and len(_f1) == _want_f1
and len(_f2) + len(_c2) == HORIZON_H
and len(_f1) + len(_c1) == HORIZON_H
and len(_f2) > len(_f1))
check(f"...and the firm hours now span TWO Brussels local days "
f"{local_days(_f2)}, where the one-day answer spans exactly one "
f"{local_days(_f1)}. This is the acceptance criterion, and the ONE thing "
f"no amount of carry-forward could ever produce: a carried hour is "
f"provisional by construction",
len(local_days(_f2)) == 2 and len(local_days(_f1)) == 1
and local_days(_f2) == [TODAY_D, TOMORROW_D])
# ⚠️ THE TAIL IS STILL CARRIED, WHICH IS FCST-04 STILL DOING ITS JOB. A 48 h
# horizon asked for at 09:17 runs 9 hours into a THIRD Brussels day, and no
# day-ahead market publishes that far. The fix widens what is firm; it does not
# claim the market publishes more than it does.
check(f"FCST-04 still fills the remainder: the {len(_c2)} hours past the "
f"market's own horizon are carried from yesterday and flagged, on a "
f"third Brussels day {local_days(_c2)} - so the plan still covers all "
f"{HORIZON_H} hours",
len(_c2) > 0 and local_days(_c2) == ["2026-01-17"]
and all(p.provisional for p in _c2))
check("...and the plan is still a provider plan reporting itself provisional, "
"not a §11.4 fallback",
_pub.origin == "provider" and _pub.provisional)
# ⚠️ THE PRE-PUBLICATION ANSWER IS A SERIES OF WHAT EXISTS, NOT `no_data`.
# Asking for tomorrow before the auction returns fewer days than requested.
# A `no_data` there would switch a working site's arbitrage off every morning.
check(f"the short answer stays a SERIES of what exists - {_pre.read.kind}, "
f"{len(_pre.read.points)} hours, origin {_pre.origin!r} - and is never "
f"no_data and never a truncated series presented as complete "
f"({len(_c1)} of its hours are flagged provisional)",
_pre.read.kind == "series" and _pre.origin == "provider"
and len(_pre.read.points) == HORIZON_H and len(_c1) > 0)
# --- the trade set, which is where the whole thing lands ------------------- #
_A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset())
_v2 = _pub.arbitrage(**_A9)
_v1 = _pre.arbitrage(**_A9)
check("both plans still ALLOW a trade, so the two verdicts below are compared "
"against each other and not against a refusal",
_v2.kind == "allowed" and _v1.kind == "allowed")
_v2 = _v2 if _v2.kind == "allowed" else None
_v1 = _v1 if _v1.kind == "allowed" else None
# ⚠️ NON-DEGENERACY, DERIVED. The global cheapest firm hour must be on TODAY
# and the global dearest on TOMORROW, or "the trade set spans two days" is a
# property of the arithmetic rather than of the fix.
def _day_of(v, end):
"""The Brussels day of one end of a permitting verdict, or "" for a
refusal - so a mutation that turns the verdict into a refusal is a named
FAIL below rather than an `Absence` traceback."""
return "" if v is None else brussels(getattr(v, end).start).strftime(
"%Y-%m-%d")
_lo2, _hi2 = _day_of(_v2, "cheapest"), _day_of(_v2, "dearest")
_lo1, _hi1 = _day_of(_v1, "cheapest"), _day_of(_v1, "dearest")
check(f"WITH TOMORROW FIRM the trade set contains hours from BOTH days: "
f"buy on {_lo2}, sell on {_hi2}",
sorted({_lo2, _hi2}) == [TODAY_D, TOMORROW_D])
check(f"...where the one-day answer's buy AND sell are both inside {_lo1} - "
f"a trade set that is a subset of ONE calendar day, structurally, which "
f"is the defect this card is about",
_lo1 == _hi1 == TODAY_D)
_s2 = None if _v2 is None else _v2.spread_eur_kwh
_s1 = None if _v1 is None else _v1.spread_eur_kwh
check(f"...and the cross-day spread is strictly larger ({_s2} vs {_s1} "
f"EUR/kWh), so the second day is not merely visible but PRICED",
_s2 is not None and _s1 is not None and _s2 > _s1)
check("...and both ends of the permitted cycle are FIRM, which "
"`_firm_or_die()` asserts at construction and §8.3 requires",
_v2 is not None and not _v2.cheapest.provisional
and not _v2.dearest.provisional)
# --------------------------------------------------------------------------- #
print()
print("2. AC2 - the spread stopped being taken over hours that have gone")
@@ -545,9 +779,10 @@ check("the carried hours are NOT in the trade set - the verdict's cheapest and "
print()
print("2b. a QUARTER-HOURLY feed keeps every print, and its spread with them")
# ⚠️ THE DEFECT THIS SECTION EXISTS FOR. `eneco_eddy` is the Belgian imbalance
# product and settles per QUARTER; `_validate_raw()` accepts any
# strictly-increasing spacing, so a published hour may hold four points.
# ⚠️ THE DEFECT THIS SECTION EXISTS FOR. Belgian DAY-AHEAD settles per QUARTER
# - the 15-minute MTU took effect on 2025-10-01, so `generic_dayahead` reads 96
# points a day - and `_validate_raw()` accepts any strictly-increasing spacing,
# so a published hour may hold four points.
# FCST-04's first draft keyed the published set `{_utc_hour(p.start): p ...}` -
# LAST POINT OF THE HOUR WINS, the other three dropped - and on an all-firm,
# fully-published 96-point feed the plan came out 15 points long with its
@@ -559,14 +794,24 @@ for _n in range(len(TODAY_H) * (60 // _QSTEP_MIN)):
_qu = TODAY_H[0] + timedelta(minutes=_QSTEP_MIN * _n)
# A spiky within-the-hour profile, so decimation is visible in the SPREAD
# and not only in a count.
_qrows.append({"start": _qu.isoformat(),
"price_eur_mwh": 100.0 + 40.0 * (_n % 4) + brussels(_qu).hour})
_qbody = json.dumps({"data": _qrows}).encode()
_qrows.append((_qu,
100.0 + 40.0 * (_n % 4) + brussels(_qu).hour))
# ⚠️ COLUMNAR, like the real feed - see feed_body(). And 900 s spacing is not a
# hypothetical here: Belgium moved to a 15-minute MTU on 2025-10-01 and
# `api.energy-charts.info` has answered 96 points a day ever since, which is
# exactly what test_price.py section 2b replays from a captured body.
_qbody = json.dumps({
"unix_seconds": [int(_u.timestamp()) for _u, _v in _qrows],
"price": [_v for _u, _v in _qrows],
"unit": "EUR / MWh",
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
"from Bundesnetzagentur | SMARD.de",
"deprecated": False}).encode()
check(f"the fixture really is sub-hourly - {len(_qrows)} points across "
f"{len(TODAY_H)} Brussels hours - or every check below passes for an "
f"hourly feed too and grades nothing",
len(_qrows) == 4 * len(TODAY_H)
and len({_r["start"] for _r in _qrows}) == len(_qrows))
and len({_r[0] for _r in _qrows}) == len(_qrows))
_q_before = run(resolve_tariff(CFG, NOW, _until, transport=transport_for(_qbody),
now=NOW, history=NoHistory()))
@@ -1379,6 +1624,195 @@ check(f"...and a revision of that hour REPLACES it rather than sitting beside "
and abs(_y15.points[0].eur_per_kwh - allin(YESTERDAY_MWH[6])) < 1e-9)
# --------------------------------------------------------------------------- #
print()
print("5d. FCST-06 - the OTHER way to be a store that cannot persist")
# THE DEFECT. Section 5 above grades "a store that cannot persist records
# nothing" against `not ok` - the missing-durable-volume SYMPTOM - and that is
# the only condition the rule was ever enforced on. `ok` stays TRUE on a volume
# that is present and cannot be written: a read-only remount, a full disk. In
# that state `_remember()` had already populated `self._days`, `_save()` logged
# and returned, and the store went on answering a series for the rest of the
# process's life. Measured on 5059e28, twice, with a `/data` bind-mounted
# read-only and again on a full tmpfs:
#
# last_error = 'could not write ([Errno 30] Read-only file system)'
# words = '2026-01-14 (24 points over 24 h); 2026-01-15 (24 points
# over 24 h) [could not write (...)]'
# yesterday()= series, 24 points
# ON DISK = {} <- and gone on the next restart
#
# `words` and `yesterday()` were describing two different stores, which is
# exactly what section 5's own comment refuses - reached by the condition the
# rule did not name.
#
# ⚠️ THE LEVERS ARE REAL FAILURES, NOT PATCHED ONES, AND THERE ARE TWO OF THEM
# ON PURPOSE. The fix is written against the OUTCOME - `_save()` answering "no"
# - and not against an errno, so one cause would grade a coincidence. A
# DIRECTORY where the file goes fails `os.replace`; a volume that moves out
# from under an already-open store fails `mkstemp`. Different syscall,
# different errno, one answer (habit 10: grade the family).
def npts(read):
"""A read's point count, or "no_data". Never raises.
⚠️ NOT `len(read.points)`. On a `no_data` that attribute RAISES `Absence`
by design, so a mutation that empties a store where this section expects
one would take the harness down with a traceback instead of reporting a
FAIL - and MUTATION.md's rule is that "it answered nothing" and "it broke
on the way to answering" must not grade the same. Found by driving M4.
"""
return read.match(series=lambda r: len(r.points),
no_data=lambda _r: "no_data")
_d16 = tmpdir()
_p16 = os.path.join(_d16, "price_history.json")
_h16 = PriceHistory(_p16)
os.mkdir(_p16) # `os.replace` cannot complete onto a directory
check(f"the fixture is an `ok` store on a directory that IS there "
f"(ok={_h16.ok}, last_error={_h16.last_error!r}) - not the "
f"no-durable-volume case section 5 already grades, or this section is "
f"that one again (habit 1)",
_h16.ok and _h16.last_error is None and os.path.isdir(_d16))
_carried16 = _h16.carry(series_of(YDAY_H + TODAY_H, TODAY_MWH),
since=NOW, until=_until, stamp=NOW)
_y16 = _h16.yesterday(NOW)
check(f"a write that could not complete is REPORTED and leaves the store "
f"`ok` (ok={_h16.ok}, {(_h16.last_error or '')[:44]!r}). `ok` is about "
f"the DIRECTORY, and a full volume and a read-only remount both leave "
f"it True - which is why testing `ok` alone could not see this",
_h16.ok and "could not write" in (_h16.last_error or ""))
check(f"...and the file really does not hold the day "
f"(isfile={os.path.isfile(_p16)}), or 'it answers what is not there' is "
f"not a measurement (habit 1)",
not os.path.isfile(_p16))
check(f"A STORE THAT COULD NOT WRITE RECORDS NOTHING, exactly as one with no "
f"durable volume does: it holds {sorted(_h16._days)} and `yesterday()` "
f"is {_y16.kind!r}. Retaining the merge is an in-memory yesterday that "
f"answers all day and is gone on the next restart - the "
f"silently-forgetting cache this module's docstring exists to refuse",
_h16._days == {} and _y16.kind == "no_data")
check(f"...and `words` and `yesterday()` AGREE about that store "
f"{_h16.words[:44]!r}: neither names a day the file does not hold, and "
f"both name the write failure. They disagreed - `words` printed two "
f"full days BESIDE the error while `yesterday()` answered 24 points",
YDAY_D not in _h16.words and TODAY_D not in _h16.words
and "could not write" in _h16.words
and _y16.kind == "no_data" and "could not write" in why(_y16))
check(f"...and it DEGRADES rather than raising: the carry still returned a "
f"real series ({_carried16.kind!r}, {npts(_carried16)} points) with "
f"nothing carried. §6 and this module's opening - an add-on that will "
f"not start because /data filled up is a WORSE defect than the one this "
f"section is about",
_carried16.kind == "series"
and not _carried16.match(
series=lambda r: any(p.provisional for p in r.points),
no_data=lambda _r: True))
_plan16 = derived(lambda: run(resolve_tariff(
CFG, NOW, _until, transport=transport_for(_body_today), now=NOW,
history=_h16)).origin)
check(f"...and the SHIPPED `resolve_tariff()` path is unmoved by it "
f"({_plan16!r}): a provider plan, no §11.4 degradation, on a store that "
f"cannot write a byte",
_plan16 == "provider")
# Habit 9: the same wrong input a SECOND time. A refresh runs 48 times a day,
# and a volume that is full at 14:00 is still full at 14:30.
_h16.carry(series_of(YDAY_H, YESTERDAY_MWH), since=NOW, until=_until,
stamp=NOW)
check(f"...and the SECOND failing refresh behaves the same rather than "
f"accumulating: still {sorted(_h16._days)} held and "
f"{_h16.yesterday(NOW).kind!r}. The write fails 48 times a day, so the "
f"call that matters is the one after the first (habit 9)",
_h16._days == {} and _h16.yesterday(NOW).kind == "no_data")
# ⚠️ AND WHAT IS KEPT IS THE LAST STATE THAT IS ON DISK, NOT AN EMPTY ONE. The
# MERGE is undone, not the store. A site whose volume fills at 14:00 goes on
# answering the yesterday it wrote at 13:30 - that record survives a restart,
# so answering it is not a promise the store cannot keep. What it stops doing
# is adding to it. This is also the fixture that separates a deep snapshot from
# `dict(self._days)`: a one-level copy shares the day RECORD the merge rebinds
# `points` on, so the "restored" store would answer the 24 points the merge
# made and the file never took.
_d17 = os.path.join(tmpdir(), "data")
os.mkdir(_d17)
_p17 = os.path.join(_d17, "price_history.json")
_h17 = PriceHistory(_p17)
_ph.PriceHistory._remember(_h17, series_of(YDAY_H[:12], YESTERDAY_MWH), NOW)
_first17 = _h17.yesterday(NOW)
check(f"a HALF day is persisted first ({npts(_first17)} points, "
f"last_error={_h17.last_error!r}), or 'the merge is undone and the "
f"store is not' grades nothing (habit 1)",
_h17.last_error is None and os.path.isfile(_p17)
and npts(_first17) == 12)
_persisted17 = open(_p17, encoding="utf-8").read()
_moved17 = _d17 + ".moved"
os.rename(_d17, _moved17) # the volume goes out from under an OPEN store:
# `mkstemp` fails, a different call and a
# different errno from the fixture above, and
# the bytes already written stay intact
_ph.PriceHistory._remember(_h17, series_of(YDAY_H[12:], YESTERDAY_MWH), NOW)
_y17 = _h17.yesterday(NOW)
_n17 = npts(_y17)
check(f"the second lever failed the write by a DIFFERENT route "
f"{(_h17.last_error or '')[:44]!r} - `mkstemp`, not `os.replace`, and "
f"ENOENT, not EISDIR. The rule is enforced on the outcome, so it must "
f"be driven by more than one cause (habit 10)",
"could not write" in (_h17.last_error or "")
and "No such file" in (_h17.last_error or ""))
check(f"...and the store keeps the last state that IS ON DISK, not an empty "
f"one: {_n17} points held - the 12 it wrote before the volume went, not "
f"the 24 the unwritten merge made, and not nothing. The MERGE is undone; "
f"the store is not",
npts(_y17) == 12)
_ph.PriceHistory._remember(_h17, series_of(YDAY_H[12:], YESTERDAY_MWH), NOW)
_y17b = _h17.yesterday(NOW)
check(f"...and again on the second failing refresh ({npts(_y17b)} points), "
f"because a volume that has gone is still gone 30 minutes later "
f"(habit 9)",
npts(_y17b) == 12)
os.rename(_moved17, _d17) # the volume comes back: what a RESTART finds
_r17 = PriceHistory(_p17).yesterday(NOW)
check(f"...and that is EXACTLY what a restart finds ({npts(_r17)} points, the "
f"file byte-for-byte as it was left), which is the whole claim: the "
f"store answers what survives a restart and nothing else",
open(_p17, encoding="utf-8").read() == _persisted17
and npts(_r17) == 12
and _r17.match(series=lambda a: _y17.match(
series=lambda b: [p.start for p in a.points]
== [p.start for p in b.points],
no_data=lambda _r: False), no_data=lambda _r: False))
# ⚠️ AND "CANNOT PERSIST" IS ONE PREDICATE, ASKED ONCE. FCST-04's M4 and
# FCST-05's HIGH were both a rule enforced on the conditions somebody thought
# of rather than on the family; this is the third of the shape and the last
# place it can be spelt twice. `_save()` returns whether the file now holds
# what memory holds - no volume is one False, an `OSError` is another - and
# `_remember()` keeps only what that answer says was kept.
check(f"`_save()` answers for BOTH symptoms and is the only thing that does: "
f"no durable volume -> {_ph.PriceHistory._save(_missing)!r}, a write "
f"that fails -> {_ph.PriceHistory._save(_h16)!r}, a store that "
f"persisted -> {_ph.PriceHistory._save(_hok)!r}",
_ph.PriceHistory._save(_missing) is False
and _ph.PriceHistory._save(_h16) is False
and _ph.PriceHistory._save(_hok) is True)
check(f"...and asking it once leaves the two symptoms with ONE behaviour: "
f"no volume holds {sorted(_missing._days)}/"
f"{_missing.yesterday(NOW).kind!r} and a failed write holds "
f"{sorted(_h16._days)}/{_h16.yesterday(NOW).kind!r}. A third cause "
f"nobody has thought of needs no third branch - which is how this defect "
f"arrived, twice",
(_missing._days, _missing.yesterday(NOW).kind)
== (_h16._days, _h16.yesterday(NOW).kind))
check("...and `_remember()` no longer tests a symptom of its own: the source "
"holds no `self.ok` test between the docstring and the merge. This one "
"IS about the code's shape - two places testing two symptoms is the "
"defect, not a behaviour (habit 4's stated exception)",
"if not self.ok:" not in inspect.getsource(_ph.PriceHistory._remember)
and "if not self.ok:" in inspect.getsource(_ph.PriceHistory._save))
# --------------------------------------------------------------------------- #
print()
print("6. rule 4 - hours accumulate within a day; they do not replace it")
+17 -4
View File
@@ -743,8 +743,10 @@ def run(coro):
return asyncio.run(coro)
# ⚠️ NO `price_feed_url` (TARIFF-09): an override is used verbatim and the
# plugin appends no date range to it, so setting one - even to the feed's own
# address - exercises the override path rather than the shipped default.
_cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
price_feed_url="https://api.energy-charts.info/price?bzn=BE",
price_timeout_s=1, price_markup_eur_kwh=0.03,
price_taxes_eur_kwh=0.06)
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead,
@@ -778,9 +780,20 @@ check("a dead feed with NO static_profile has nothing to fall back to, and "
_fb_none.origin == "none" and not _fb_none.has_prices)
# A live feed still wins over the profile.
_body = json.dumps({"data": [
{"start": (THU + timedelta(hours=i)).isoformat(), "price_eur_mwh": 40 + i}
for i in range(24)]}).encode()
# ⚠️ COLUMNAR (TARIFF-08). `api.energy-charts.info` publishes parallel
# `unix_seconds`/`price` arrays and no `data` key; the `{"data": [...]}` body
# this used to build was invented by the parser it was grading, which is how a
# provider that could never parse a live response shipped green. Real captured
# bodies are replayed in test_price.py section 2b - the prices here are
# synthetic, the SHAPE is not.
_body = json.dumps({
"unix_seconds": [int((THU + timedelta(hours=i)).timestamp())
for i in range(24)],
"price": [40 + i for i in range(24)],
"unit": "EUR / MWh",
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
"from Bundesnetzagentur | SMARD.de",
"deprecated": False}).encode()
async def _live(url, timeout_s):
+4 -4
View File
@@ -143,10 +143,10 @@ configuration:
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
price_provider:
name: "Tariff · Dynamic price source"
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ Switching it on today fetches, checks and publishes prices and changes no energy decision - nothing consumes the price series yet. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed."
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH - not the trading switch further down. With a source selected the add-on fetches the feed every half hour, checks it, records the day and shows what it holds on the Price history row, whether or not price trading is armed. On its own it still moves no energy: trading is the separate switch, and the cheap window the monthly maintenance cycle uses comes from the fixed or day/night tariff below. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed. ⚠️ THREE IDS IN THIS LIST ARE WITHDRAWN AND WILL NOT START: engie_dynamic, totalenergies_dynamic and eneco_eddy. Their endpoints do not exist and never did, so those plugins never once fetched a price - a site set to one of them has silently been running on the fixed or day/night tariff below, or on nothing, since the day it was set. They are still offered here only so that such a site gets that explanation at startup instead of an unreadable schema error. Choose generic_dayahead, manual_csv or off."
price_markup_eur_kwh:
name: "Tariff · Supplier markup (EUR/kWh)"
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under engie_dynamic, totalenergies_dynamic, eneco_eddy and manual_csv it is ignored, and logged as ignored, never applied twice."
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under manual_csv, whose figures are all-in, it is ignored, and logged as ignored, never applied twice."
price_taxes_eur_kwh:
name: "Tariff · Levies and VAT (EUR/kWh)"
description: "Taxes and levies per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them."
@@ -161,7 +161,7 @@ configuration:
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
price_feed_url:
name: "Tariff · Price feed URL override"
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere."
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere. WHAT YOU TYPE IS SENT EXACTLY AS WRITTEN: the add-on adds nothing to it, not even the date range it normally asks its own feed for, so an override gets whatever horizon that URL returns - often a single day, with the rest of the plan carried over from yesterday and excluded from price arbitrage. A warning in the log says so on every price refresh. Clear this option to get the full horizon back."
price_horizon_h:
name: "Tariff · How far ahead to fetch prices (hours)"
description: "How many hours of prices to ask the source for."
@@ -176,7 +176,7 @@ configuration:
description: "The average the current price is compared against. Only meaningful together with the current-price entity above, and like it, may not be combined with a dynamic price source."
arbitrage_enabled:
name: "Tariff · Trade on the price spread"
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. ⚠️ LEAVING IT OFF DOES NOT TURN THE PRICE FEED OFF. A price source selected above is fetched, checked and recorded either way; this switch decides only whether the add-on ACTS on those prices. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
cycle_cost_threshold_eur:
name: "Tariff · Cost of one full charge/discharge cycle (EUR/kWh)"
description: "What one full round trip costs per kWh: wear on the pack plus conversion losses. A price spread smaller than this loses money and battery life, so it is not traded. ⚠️ THIS ONE IS NOT DERIVED FROM A MEASUREMENT. 0.10 is a product decision and it is still an open question with the owner (OPT-08). At this value a fixed day/night site does not trade at all - the realistic Belgian day/night spread is about 0.08 EUR/kWh - and that is the correct verdict at this number, not a fault."
+4 -4
View File
@@ -123,10 +123,10 @@ configuration:
description: "AFGELEID, niet gekozen (SAFETY-24): de referentiebatterij heeft 13,8 kWh bruikbaar en de zwaarste doorgerekende afvlakdag vraagt 878 Wh, oftewel 6,4 %, dus 20 % ligt tien punten boven de uitschakelgrens van de batterij zelf en laat de bewaking nog ongeveer 1,6 van haar zwaarste waargenomen dagen speling. ⚠️ DIT BEGRENST ALLEEN DE PIEKBEWAKING. Het is geen reserve voor de hele batterij: gewoon eigenverbruik ontlaadt er dwars doorheen en gaat door tot aan de uitschakelgrens van de batterij, en dit getal verhogen verandert daar geen tiende procent aan. Het moet strikt boven de uitschakelgrens van de batterij blijven, anders start de add-on niet op. Verander het niet zonder de afleiding opnieuw te maken."
price_provider:
name: "Tarief · Bron van dynamische prijzen"
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ Dit vandaag aanzetten haalt prijzen op, controleert en publiceert ze, en verandert geen enkele energiebeslissing - er is nog niets dat de prijsreeks gebruikt. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten."
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ DIT AANZETTEN IS WAT HET OPHALEN VAN PRIJZEN START - niet de handelsschakelaar verderop. Met een bron ingesteld haalt de add-on de feed elk half uur op, controleert die, bewaart de dag en toont op de rij Prijsgeschiedenis wat er bewaard is, of prijshandel nu aan staat of niet. Op zichzelf verplaatst dat nog geen energie: handelen is de aparte schakelaar, en het goedkope venster dat de maandelijkse onderhoudscyclus gebruikt komt uit het vaste of dag/nacht-tarief hieronder. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten. ⚠️ DRIE IDS IN DEZE LIJST ZIJN INGETROKKEN EN STARTEN NIET MEER: engie_dynamic, totalenergies_dynamic en eneco_eddy. Hun endpoints bestaan niet en hebben nooit bestaan, dus die plugins hebben nooit één prijs opgehaald - een installatie die op zo'n bron stond, draait sindsdien stilzwijgend op het vaste of dag/nacht-tarief hieronder, of op niets. Ze staan hier alleen nog zodat zo'n installatie bij het opstarten die uitleg krijgt in plaats van een onleesbare schemafout. Kies generic_dayahead, manual_csv of off."
price_markup_eur_kwh:
name: "Tarief · Marge van de leverancier (EUR/kWh)"
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij engie_dynamic, totalenergies_dynamic, eneco_eddy en manual_csv wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij manual_csv, waarvan de bedragen alles inbegrepen zijn, wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
price_taxes_eur_kwh:
name: "Tarief · Heffingen en btw (EUR/kWh)"
description: "Belastingen en heffingen per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat."
@@ -141,7 +141,7 @@ configuration:
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
price_feed_url:
name: "Tarief · Afwijkende URL voor de prijsfeed"
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen."
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen. WAT U HIER TYPT WORDT LETTERLIJK VERSTUURD: de add-on voegt er niets aan toe, ook niet het datumbereik dat ze normaal aan haar eigen feed vraagt. Een afwijkende URL levert dus de horizon op die die URL zelf teruggeeft - vaak één dag, waarna de rest van het plan uit de prijzen van gisteren wordt overgenomen en buiten de prijsarbitrage valt. Bij elke prijsvernieuwing verschijnt daarover een waarschuwing in het logboek. Maak dit veld leeg om de volledige horizon terug te krijgen."
price_horizon_h:
name: "Tarief · Hoe ver vooruit prijzen ophalen (uren)"
description: "Hoeveel uur aan prijzen er bij de bron opgevraagd wordt."
@@ -156,7 +156,7 @@ configuration:
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen zinvol samen met de entiteit hierboven en, net als die, niet te combineren met een dynamische prijsbron."
arbitrage_enabled:
name: "Tarief · Handelen op het prijsverschil"
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. ⚠️ DIT UIT LATEN STAAN ZET DE PRIJSFEED NIET UIT. Een prijsbron die hierboven gekozen is, wordt hoe dan ook opgehaald, gecontroleerd en bewaard; deze schakelaar bepaalt alleen of de add-on op die prijzen HANDELT. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
cycle_cost_threshold_eur:
name: "Tarief · Kostprijs van één volledige laad/ontlaadcyclus (EUR/kWh)"
description: "Wat één volledige cyclus per kWh kost: slijtage van de batterij plus omzettingsverliezen. Een prijsverschil dat hier onder ligt, kost geld en levensduur en wordt dus niet verhandeld. ⚠️ DIT GETAL IS NIET AFGELEID UIT EEN METING. 0,10 is een productbeslissing en staat nog open bij de eigenaar (OPT-08). Bij deze waarde handelt een installatie met een vast dag/nachttarief helemaal niet - het realistische Belgische dag/nachtverschil is ongeveer 0,08 EUR/kWh - en dat is bij dit getal het juiste antwoord, geen fout."