17 KiB
TARIFF-02 - price provider plugins, and the canonical shape (with later additions)
Status: SHIPPED. Tests: test_price.py, test_price_service.py. Written
down by MAINT-20 from the long comments of app/price.py so the module can stay
short. Related: TARIFF-07, TARIFF-09, TARIFF-10, TARIFF-14,
TARIFF-15, PRICE-01.
Where it lives
specs.md 2.2 puts this at ems/ingest/prices/; that layout is not being built.
Owner decision 2026-08-30: everything ships in goodwe_controller/app/. The
behaviour in specs.md 9.3 / 8.3 / 11.4 is unchanged and authoritative.
The canonical format (9.3)
{"prices": [{"start": "2026-01-15T00:00:00+01:00", "eur_per_kwh": 0.1834, "provisional": false}]}. start is tz-aware at the Brussels offset for that
instant, the price is EUR/kWh and all-in, provisional is per point (8.3: no
irreversible action on a provisional price). Three fields; a fourth would change
the wire format every store, fixture and captured body is written against.
Three rules the module is shaped by
- Double-counting markup is refused, given a correct declaration. A plugin
returns
RawPoints with the figure its source published and declares incapabilities().sourceswhich of {ENERGY, MARKUP, TAXES} it already holds.assemble()(framework only) adds the complement fromprice_markup_eur_kwh/price_taxes_eur_kwh. A plugin cannot reach those two numbers by any route:PluginOptionsraisesFrameworkOption, and review of PR #16 wrote a double-counting plugin three more ways (opts._o,price_option(), and the publicPRICE_OPTION_DEFAULTS), soregister()also refuses any plugin whose own code mentions a framework key (code_leaks()). The guarantee is exactly this: the complement is added once given a correctsources. A plugin declaring{ENERGY}for an all-in feed comes out one markup high and no code can catch that. test_price.py checks that a plugin's docstring lines (SOURCES:/The framework ADDS:) agree with its declaration, not that either is true.code_leaks()strips docstrings before searching (they must name the components) and fails open on missing source (a frozen build must still start; CI asserts source is found for every shipped plugin). - The product type is declared, not inferred from the id.
capabilities(). productis aPriceProduct, rides on the series, andvolatility_regime()reads it (8.3: real-time products widen thresholds). A plugin id appears only inPROVIDERS,RETIRED_PROVIDERSand its own class (AC2). No shipped plugin declaresREALTIMEsince TARIFF-07;_WIDE,Uncertainty.REALTIME_PRODUCTandWIDEN_FACTOR's 2.5 are kept, and test_price.py section 10 declares REALTIME from a class outside this file to keep them live. - "No price data" is a type, not an empty list. 11.4: no data means static
profile, provisional plan, no arbitrage; all-zero prices mean arbitrage is
merely pointless.
PriceRead.series()refuses an empty list;PriceRead.no_data(reason)carries.reasonand nothing else, somin(read.points),for p in read.pointsand.get("points", [])raiseAbsence;.match(*, series, no_data)takes both handlers as required keywords. Same shape assim/riglock.pyafter TEST-04 (read itsAbsenceblock before changing this). Two ways past it survive and are not worth code:vars(read).get("points", []), andbool(read)being True for both variants (a falsyno_datawould turnif read:into a guard by accident; use.match).
Timezone without zoneinfo
The Alpine image has no tzdata, so ZoneInfo("Europe/Brussels") would raise
on the client's Pi. Every instant is UTC and rendered at its fixed offset, so
the October 02:00-03:00 hour that happens twice is two instants (02:00+02:00
and 02:00+01:00); a naive-local-hour key or a 24-slot array is wrong on both
transition days (test_price.py counts points on both 2026 transitions). The EU
rule: CEST from 01:00 UTC on the last Sunday of March to 01:00 UTC on the last
Sunday of October. Review of PR #16 matched it against zoneinfo over 149 040
hourly instants 2020-2036 with zero mismatches. Residual risk is political (EU
abolishes the change): one edit in brussels_offset().
The feed range (TARIFF-09)
generic_dayahead.fetch() used to ignore since/until and request the bare
URL, so the feed answered ONE Brussels day and 8.3 dropped everything past today
as provisional: at 09:00 over a 48 h horizon 15 firm and 33 carried, at 23:00 1
firm and 47 carried. feed_days() returns the Brussels calendar days (first,
last, both inclusive; the API takes dates) that the half-open [since, until)
touches: Brussels, never UTC (22:30 UTC in summer is already tomorrow); until
exclusive, so local midnight belongs to the day before; an empty or inverted
window collapses to one day. api.energy-charts.info capture:
scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json.
dated_feed_url() refuses a base that already has a query string (appending
would give ?bzn=BE?bzn=BE&start=...). An operator's price_feed_url override
is used verbatim and loses the range, on purpose: rewriting an operator's URL
reorders and re-escapes it, and both providers declare a second host
(web-api.tp.entsoe.eu) whose parameters are not start/end. The cost is
logged at fetch time, naming the horizon consequence. The URL rule is shared by
generic_dayahead and custom_formula (TARIFF-15), taking plugin_id as an
argument so AC2 holds. The "range is used" promise is not mechanically enforced
(a rule would be satisfied by an invented parameter name); test_price.py pins
which plugins consume the range as a literal roster.
Absence and the covering window
See TARIFF-14 for the cover table: a point covers from its own start to the
next point's start, never more than an hour; the last point gets the span of the
one before; sorted at construction. covers() delegates to at() (a second copy
of the rule is a second place to be wrong); window_points() grades only the
start against [since, until) and is not a coverage query. at() is a linear
scan over about 192 entries on the 30 s path; a bisect is not worth a second
way to be wrong. A duplicated start (not reachable through load_prices())
gives the earlier entry a zero-width window.
Taxes components (TARIFF-18)
price_taxes_eur_kwh's default (0.06 before) is the sum of
FLEMISH_TAXES_COMPONENTS_EUR_KWH, each row one line of the Eneco residential
card for Flanders, September 2026, including 6 % VAT, in EUR/kWh;
test_price.py section 1b re-adds them. It is per DSO: the distribution row runs
5,28 c (Midden-Vlaanderen) to 6,70 c (West), so the all-in Flemish total runs
about 0.117 to 0.131; a site outside Antwerpen types its own. The default sits
near the low end on purpose: the scalar cancels in max - min, and its one
decision is the 17 negative-price guard in arbitrage.py, which tests the
absolute all-in sell price. Too high carries a genuinely negative hour above
zero (export into paid-for disposal); too low only declines a trade that would
have paid. energiebijdrage is a row of zero: from 1 August 2026 it is merged
into the bijzondere accijns, and the zero row makes the double-count a visible
decision. Moving a shipped default changes which configs start: TARIFF-17's rung
in validate.py refuses price_taxes_profile together with a non-default scalar,
comparing against the default (the Supervisor merges every schema key into the
stored blob, 70 of 71 keys on the reference install, CHANGELOG 0.9.0), so a
superseded shipped default (0.06) must still read as untouched, and the rung's
sentence names both figures.
One in-code mirror of option defaults (CFG-03)
Option defaults live in config.yaml's options: and schema: and main.py's
.get() fallbacks. PRICE_OPTION_DEFAULTS is the single in-code mirror for the
price options: validate.py imports it, main.py has no o.get("price_...", X)
fallbacks, no dataclass carries a copy, and test_price.py section 1 reads
config.yaml and asserts equality key for key. static_profile (TARIFF-03),
price_taxes_profile (TARIFF-17), price_formula (TARIFF-15),
price_export_formula (TARIFF-16) and the OPT-04 keys live there for the same
reason, not in their own modules (a second dict would be a fourth location).
Details:
- The tables / formulas are in
FRAMEWORK_KEYS: a plugin reading one could add taxes a second time per hour, or fold an injection curve into a consumption price.BANNED_IN_PLUGIN_CODEderives from that set. arbitrage_soc_floor/arbitrage_soc_ceilingare not the maintenance keys (a balancing cycle is not a trading reserve).cycle_cost_threshold_euris 0.10 because 10.1's name says so, not because it makes anything trade: on Belgian day/night numbers the static spread is about 0.08, so a static-tariff site does not trade at this default (test_arbitrage.py section 7). Whether 0.10, or FCST-03's 2.5 / 1.5, is right is an owner decision.price_service_tokenis a secret (SECRET_OPTIONS);scrub()removes secret values from any text built from an error.
Widening (FCST-03)
One path, a set of active uncertainty sources (real-time product 8.3, no weather
data 11.4). The combination rule is max, not a product (2.5 x 1.5 would give a
threshold nothing on a Belgian tariff clears). The factors are policy defaults,
not derived (no arbitrage ever ran); they differ so max, sum and product
are distinguishable in a check. widen_factor() refuses a string (a silent 1.0
would report a widening that is not applied).
Injection price (AC4, TARIFF-16)
export_price_eur_kwh() is a separate scalar. It was said to be so because
nothing dynamic exists to route; that was false (Eneco publishes `0,1 x BELPEX-H
- 1,188
, ENGIE(B x eSpot_15) - A). The true reason: an injection curve has its own coefficients and VAT treatment (injection is VAT-exempt, consumption is 6 %). TARIFF-16 addedprice_export_formula, read byexport_formula()through the same parser as the consumption leg (parse_export_formula()isparse_price_formula()with its option name moved).export_price_at()falls through to the scalar and ignores the index, correctly. A negative answer is a real answer (Eneco's formula is below zero for any index under 11,88 EUR/MWh); nothing floors it. Nothing underapp/calls either reader to take a decision: arbitrage prices the sell side as displaced import. A malformed leg is refused at startup by validate.py;taxes_profile()andexport_formula()still handle it loudly at run time.static_tariffis imported lazily intaxes_profile()` because it imports this module at module scope.
Taxes at each point's own instant (TARIFF-17)
taxes_at() takes a scalar or a period table (duck-typed on price_at, because
price.py must not import static_tariff.py). A non-flat component matters: ORES
bills dag 13,27 / nacht 7,39 c, a 5,88 c gap coinciding with the cheap spot
hours, and flattening it understates the spread. assemble() must evaluate it
per point; hoisting the lookup out of the loop collapses the feature back into a
level (test_price.py section 24 grades which hour is cheapest, since a
spread-magnitude assertion passes on the hoisted version). On a scalar, 0.0 + markup is exactly markup in IEEE 754, so scalar sites get identical bytes.
The table wins over the scalar; validate.py refuses configs where both speak.
Retired providers (TARIFF-07)
RETIRED_PROVIDERS exists so eneco_eddy and friends are not refused as typos:
they were shipped, the operator chose them, and every failed fetch fell back
silently to static_profile. No alias to generic_dayahead: they published
retail prices (B x spot + A) and it reads wholesale; an alias would invent the
site's two coefficients (behind the klantenzone login; CREG tariff-card pages
404, VREG unreachable). Each value is the id-specific half of the refusal (the
endpoint, what it answered on 2026-09-13, why no replacement can be built);
validate.py owns the framing. register() refuses at import to register a
retired id again, because pid in PROVIDERS going true silences the named
refusal; re-shipping is a decision recorded by removing it from
RETIRED_PROVIDERS in the same commit. See TARIFF-07.
Parsers (TARIFF-08)
The only parser is columnar: https://api.energy-charts.info/price?bzn=BE gives
parallel unix_seconds / price arrays plus unit. The earlier row parser read
doc["data"], a shape no feed ever published, graded by fixtures written from
the same guess, so every fetch ended SourceUnavailable and 11.4 fell back to
static silently. Two shapes, two functions: a parser that sniffs can decide
wrong, and a wrong-but-parseable response becomes a price.
_points_from_columnar_json() refuses:
- unequal arrays (
zip()would truncate the evening peak, and a series missing the expensive hours is traded on while no-data switches arbitrage off); - a unit other than the declared one (EUR/kWh under the same keys would be 1000
times too small and
MAX_PLAUSIBLE_EUR_KWHbounds only the large side); - naive time:
unix_secondsare UTC (fromtimestamp(t, timezone.utc)). Rendering at the Brussels offset isassemble()'s, once, for every plugin.
A null price is dropped and logged (the pairing is reliable, the source said
it published none; _Series.at() answers the hole with Absence);
scenarios/fetch_epex_be.py dropped 0 nulls in 12 months to 2026-08-31. The
feed is quarter-hourly since Belgium's 15-minute MTU (2025-10-01); nothing
assumes either granularity (test_price.py section 2b replays both).
_validate_raw() accepts negative prices (windy-Sunday hours are the year's best
charging; 17 keeps the regression) and rejects NaN, infinity, an implausible
magnitude, naive datetimes, and duplicate or out-of-order starts.
Transport and plugin details
check_host()splits the scheme before parsing the host. The first version strippedlen("https://"), which onhttp://ate two characters of the host (pi.energy-charts.info), so a non-TLS feed failed the allow-list and the check "plain http is refused" passed even with the TLS rule deleted. Found by mutation in review of PR #16.- The default transport imports aiohttp lazily so
manual_csvworks with no HTTP stack. Withheaders(price_service only) it answers in the safe words ofstatus_words()and follows no redirects (the host is fixed; a redirect would take the token somewhere the allow-list never saw). manual_csv: the CSV is all-in (the plugin cannot know what a typed number contains);load_prices()warns when markup / taxes are set non-zero under a plugin that sources them. No transport, aiohttp, network or tz database: it is the offline escape hatch and the TEST-01 replay ingest path.custom_formula(TARIFF-15) declares{ENERGY, MARKUP}, notALL_IN: a card'sTariefformuleis the energy component only (Eneco tabulates network tariffs separately; ENGIE excludes network, transport, levies, excise), soALL_INwould drop about 12 c/kWh. The risk left is an operator folding network charges intooffset; the card self-check in custom_formula.py refuses that, and grades the coefficients against the card, not the card against reality. No expression evaluation: the string is settable over HTTP through the ingress config page, soevalwould be RCE in a root container holdingSUPERVISOR_TOKEN. It reusesgeneric_dayahead's feed, URL and parser. It keepsscale=1.0, so itsRawPoints carry EUR/MWh in a field calledeur_per_kwh: contained (destructured on the next line, never leaves the method;_validate_raw()would refuse an escape).startup_preview()is a hook (TARIFF-15), not a line inload_prices(): only the plugin knows what went into its arithmetic, and the index beside the all-in figure makes a factor of 1000 obvious. It must not raise into the control path; the id is recorded before the hook runs so a failing preview is not retried every 30 minutes.load_prices()returns, never raises, exceptFrameworkOption(a plugin trying to read the markup; turning it into "no price data" would hide the defect).price_provider_id()is one spelling of the selected id because the same expression was written at three call sites (TARIFF-10). validate.py keeps its own reader on purpose: it validates the raw blob.price_provider_selected()(TARIFF-10): the background refresh used to start only whenarbitrage_enabled(ships false) was on, so a configured site fetched nothing (40 h soak: zero feed requests,present: falseon all 802 samples). It is not a trading switch:arbitrage.enabled()answers that.price_service(PRICE-01):FALLBACKSandKEEP_LASTare empty/False for every earlier plugin, soprice_chain.pyis not used for them.TOKEN_REFUSEDis the one phrase the fallback chain reads to tell "the owner must act" from "the server is down".coverslists complete days only; an hour missing inside a day is a hole, not a free hour.