diff --git a/goodwe_controller/app/price.py b/goodwe_controller/app/price.py index 40d8982..7eda081 100644 --- a/goodwe_controller/app/price.py +++ b/goodwe_controller/app/price.py @@ -1,140 +1,22 @@ """TARIFF-02 - price provider plugins, and the one canonical shape they all emit. -WHERE THIS LIVES, AND WHY IT IS NOT WHERE THE SPEC SAYS. specs.md §2.2 puts this -at `ems/ingest/prices/`. That module layout does not exist and is not being -built; the product owner's decision of 2026-08-30 is that everything ships in -`goodwe_controller/app/`, next to arbiter.py, clamp.py, freeze.py and -validate.py. The BEHAVIOUR specs.md §9.3 / §8.3 / §11.4 describes is unchanged -and still authoritative - only the path moved. +A new Belgian supplier is a new class and one line in `PROVIDERS`; the +optimiser never learns a supplier's name. Lives in `goodwe_controller/app/`, +not `ems/ingest/prices/` (owner, 2026-08-30). -WHAT THIS IS FOR, in one sentence: so that a new Belgian supplier is a new class -and one line in `PROVIDERS`, and the optimiser that eventually consumes prices -never learns a supplier's name. - -THE CANONICAL FORMAT (§9.3), exactly three fields and no more: +Canonical format (specs.md 9.3), three fields and no more: { "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 -per kWh and ALL-IN (energy + markup + taxes), and `provisional` is per point, -not per series (§8.3: unknown hours are flagged, and no irreversible action may -be taken on a provisional price). +per kWh and ALL-IN, `provisional` is per point. - -THE THREE THINGS THIS MODULE IS SHAPED BY -========================================= - -1. DOUBLE-COUNTING MARKUP IS REFUSED, NOT DISCOURAGED - GIVEN A CORRECT - DECLARATION. - `generic_dayahead` reads a raw EPEX figure and the user's markup and taxes - have to be added to it. Engie and TotalEnergies publish a figure that already - HAS the markup in it. Add the configured markup to that one too and every - arbitrage decision on that site is made against a price that is 3 c/kWh - wrong, in a direction nothing reports. - - A comment saying "do not add markup here" is not a mechanism. So: - - * A plugin does not return a price. It returns `RawPoint`s carrying the - figure ITS SOURCE PUBLISHED, and declares in `capabilities().sources` - which of {ENERGY, MARKUP, TAXES} that figure already contains. - * `assemble()` - the framework, never a plugin - adds exactly the - components the plugin declared it did NOT source, from - `price_markup_eur_kwh` / `price_taxes_eur_kwh`. - * ⚠️ A PLUGIN CANNOT REACH THOSE TWO NUMBERS BY ANY ROUTE, and the word - "any" was earned the hard way. `PluginOptions` raises `FrameworkOption` - on them, which closes the documented path - but review of PR #16 wrote a - double-counting plugin THREE more ways, one of them touching nothing but - `PRICE_OPTION_DEFAULTS`, a public dict in the module every plugin must - already import for `PriceProvider` and `register`. One closed door in a - room with three is not a guarantee. So `register()` now also REFUSES ANY - PLUGIN WHOSE OWN CODE MENTIONS A FRAMEWORK KEY - see `code_leaks()`. - - ⚠️ THE PRECISE SHAPE OF THE GUARANTEE, because an overclaim here is worse - than no claim: **the framework adds the complement exactly once, GIVEN A - CORRECT `sources` DECLARATION.** A plugin that declares `{ENERGY}` while its - feed is really all-in still comes out one markup high, and NO CODE CAN CATCH - THAT - the declaration is the plugin's unverifiable claim about somebody - else's tariff. What is mechanised is that a plugin cannot obtain the numbers - and cannot apply them; what remains human is whether it described its feed - truthfully. The docstring/declaration check in test_price.py verifies that - the plugin's two claims AGREE, not that either is true, and it is worth - knowing which of those two things you are reading. - -2. THE PRODUCT TYPE IS DECLARED, NOT INFERRED FROM THE PLUGIN'S NAME. - §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 == ""` - 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). All - three 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 - provisional, NO ARBITRAGE. That is a different instruction from "prices - exist and they are all zero", under which arbitrage is merely pointless - rather than forbidden - and the two are indistinguishable if a failed fetch - returns `[]`, or `None`, or a dict a caller `.get()`s a default out of. - - ⚠️ THIS REPO SPENT 2026-08-30 REMOVING EXACTLY THAT DEFECT. `sim/riglock.py` - in the infra repo used to return a partial dict from `holder()`; TEST-04 - rewrote it so a look at the lock is `observed / not_observed / gone` and only - the observed variant carries fields. `_Outcome` below is that shape, - deliberately, down to the fatal `.get()`. Read that file's `class Absence` - block before changing anything here. - - Concretely: - - * `PriceRead.series(...)` REFUSES AN EMPTY POINT LIST. A series with no - prices in it cannot be constructed, so "I have no prices" cannot arrive - wearing the shape of an answer. - * `PriceRead.no_data(reason)` carries `.reason` AND NOTHING ELSE. It has no - `.points` to iterate, no `.product` to switch on, no `.at()` to call, and - `.get("points", [])` raises. `min(read.points)` - the line that would - silently make "no data" mean "everything is free, charge now" - does not - run against it. - * `.match(*, series, no_data)` takes both handlers as REQUIRED keyword - arguments, so a caller who forgets the no-data case gets a TypeError at - the call site rather than a fall-through. - - -TIMEZONE, AND WHY THERE IS NO `zoneinfo` HERE -============================================= -§9.3's example is `+01:00`, and Belgium runs CET/CEST. Two constraints: - - * The shipped image is Alpine with `py3-aiohttp` from apk and NO `tzdata` - package (goodwe_controller/Dockerfile, and .gitea/ci/Dockerfile is the same - base by design). `ZoneInfo("Europe/Brussels")` raises - `ZoneInfoNotFoundError` there. A price module that only works on a machine - with a tz database is a price module that fails on the client's Pi. - * Fixed offsets have no fold. Every instant here is carried as UTC and - RENDERED at the offset that applies to it, so the 02:00-03:00 hour that - happens twice in October is two different instants that render as - `02:00+02:00` and `02:00+01:00`. A naive-local-hour key collides those two - and misprices an hour of a 25-hour day; an implementation on a fixed - 24-slot array is wrong on both transition days. test_price.py drives both - 2026 transitions and counts the points rather than asserting 24. - -The EU rule is fixed in law and needs no database: CEST from 01:00 UTC on the -last Sunday of March until 01:00 UTC on the last Sunday of October. Review of -PR #16 cross-checked this against `zoneinfo`'s Europe/Brussels over 149,040 -hourly instants, 2020-2036 inclusive, and found ZERO mismatches - so the -residual risk here is not arithmetic, it is political: if the EU ever abolishes -the seasonal change, `brussels_offset()` needs editing. That is the same -exposure as a machine whose `tzdata` has gone stale, and it is one edit in one -function rather than a package upgrade. -""" +Three things shape the module (trap 1 markup is added once by the framework, +2 the product is declared, 3 "no data" is a type), and there is no `zoneinfo` +(the image has no tzdata). The reasoning and the later additions: +DECISIONS/TARIFF-02-price-provider-plugins.md.""" import ast import csv @@ -158,9 +40,8 @@ from .custom_formula import ( _LOG = logging.getLogger("goodwe.price") -# --------------------------------------------------------------------------- # -# Brussels wall time, without a tz database. See the module docstring. -# --------------------------------------------------------------------------- # +# Brussels wall time, without a tz database (DECISIONS/TARIFF-02-price- +# provider-plugins.md). CET = timezone(timedelta(hours=1)) CEST = timezone(timedelta(hours=2)) @@ -185,11 +66,7 @@ def brussels_offset(instant: datetime) -> timezone: def brussels(instant: datetime) -> datetime: """Render a UTC instant at the Brussels offset for that instant. - - ⚠️ Takes an INSTANT, and refuses a naive one. A naive datetime is not a - moment in time, it is a wall clock reading plus an assumption, and the - assumption is wrong for one hour twice a year. - """ + Takes an INSTANT and refuses a naive datetime (wrong for one hour twice a year).""" if instant.tzinfo is None or instant.utcoffset() is None: raise ValueError( f"brussels({instant!r}): a naive datetime is not an instant. Parse " @@ -201,50 +78,11 @@ def brussels(instant: datetime) -> datetime: 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. - """ + Returns `(first, last)`, both inclusive: the shape `api.energy-charts.info` + takes. TARIFF-09: a one-day request left everything past today provisional. + The calendar is Brussels, never UTC; `until` is exclusive (local midnight + belongs to the day before); an empty or inverted window is one day. + DECISIONS/TARIFF-02-price-provider-plugins.md.""" first = brussels(since).date() edge = brussels(until) last = edge.date() @@ -256,20 +94,9 @@ def brussels_date_range(since: datetime, until: datetime) -> tuple: 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. - """ + """`base` plus a query string. Refuses a `base` that already carries one + (appending would build a malformed or duplicated query). Not reached by an + operator's `price_feed_url`, which 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 " @@ -279,9 +106,7 @@ def dated_feed_url(base: str, **params) -> str: return f"{base}?{urlencode(params)}" if params else base -# --------------------------------------------------------------------------- # -# Absence, borrowed wholesale from sim/riglock.py (TEST-04). -# --------------------------------------------------------------------------- # +# Absence, borrowed from sim/riglock.py (TEST-04). class Absence(Exception): """A value was asked of a read that never observed one.""" @@ -292,14 +117,9 @@ class FrameworkOption(Exception): class SourceUnavailable(Exception): - """A plugin could not get its data. Raised by plugins, caught by the - framework, and turned into `PriceRead.no_data(...)`. - - ⚠️ It must never escape into the control tick. §11.4's row is a returned - condition, not a traceback: the loop keeps regulating on P1 whatever the - price situation is, and a price fetch that could stop it would be a price - fetch that can stop the battery. - """ + """A plugin could not get its data. Raised by plugins, caught by the framework, + turned into `PriceRead.no_data(...)`. It must never escape into the control + tick: the loop keeps regulating on P1 whatever the price situation is.""" class _Outcome: @@ -324,8 +144,8 @@ class _Outcome: f"both outcomes.") def __iter__(self): - # ⚠️ DELIBERATE. `for p in read:` and `list(read)` are the other two - # idioms that turn an absent read into an empty, harmless-looking loop. + # DELIBERATE: `for p in read:` and `list(read)` would also turn an + # absent read into an empty loop. raise Absence( f"{type(self).__name__} is not iterable: an empty loop body is how " f"'we have no prices' becomes 'we checked every hour and none were " @@ -335,9 +155,7 @@ class _Outcome: return f"{type(self).__name__}({self.words!r})" -# --------------------------------------------------------------------------- # # The vocabulary -# --------------------------------------------------------------------------- # class Component(Enum): """The three parts of an all-in Belgian consumption price (§9.3).""" @@ -357,51 +175,27 @@ class PriceProduct(Enum): FIXED = "fixed" -# ⚠️ §8.3: "Real-time / imbalance products are treated as high-volatility: -# arbitrage thresholds widen." This maps product -> regime, and it is the ONLY -# place in the shipped code that says which products are volatile. OPT-04 reads -# it; it does not re-derive it, and it certainly does not check plugin ids. +# specs.md 8.3: real-time / imbalance products are high-volatility and widen +# the +# arbitrage thresholds. The ONLY place that says which products are volatile. +# OPT-04 reads it; nothing checks plugin ids. _WIDE = frozenset({PriceProduct.REALTIME}) def volatility_regime(product: PriceProduct) -> str: - """"wide" | "normal" - the arbitrage threshold regime for a product (§8.3). - - Deliberately a plain string and deliberately tiny: OPT-04 owns what the - widened numbers ARE. This owns only which products get them, because that - is a property of the product and belongs with the product enum. - """ + """"wide" | "normal" - the arbitrage threshold regime for a product (8.3). + OPT-04 owns what the widened numbers are; this owns which products get them.""" if not isinstance(product, PriceProduct): raise TypeError(f"volatility_regime() takes a PriceProduct, not " f"{product!r} - a name is exactly what AC2 forbids.") return "wide" if product in _WIDE else "normal" -# --------------------------------------------------------------------------- # -# FCST-03: ONE widening path, driven by a SET of active uncertainty sources. -# -# Two spec rows widen the same number by two different triggers: -# -# §9.2 / §8.3 real-time / imbalance product -> "arbitrage thresholds widen" -# §11.4 no weather data -> "widen arbitrage thresholds" -# -# They are one mechanism reached twice, not two mechanisms, so there is one -# function and a set of reasons. ⚠️ THE COMBINATION RULE IS `max`, NOT A -# PRODUCT: multiplying 2.5 by 1.5 would make two independent doubts compound -# into a threshold nothing on a Belgian tariff can ever clear, and an arbitrage -# strategy that can never fire is indistinguishable from one that is switched -# off. The widest single reason wins. -# -# ⚠️ HONEST ABOUT WHERE THE NUMBERS COME FROM. specs.md says thresholds widen -# and does not say by how much; there is no capture to derive a factor from, -# because no arbitrage strategy has ever run here. These are POLICY DEFAULTS, -# chosen so that (a) a real-time product needs a materially larger spread than -# a settled day-ahead one before a cycle is worth its wear, and (b) the two -# factors DIFFER, so `max`, `sum` and `product` are three distinguishable -# answers rather than the same number - a check that grades a combination rule -# where all the candidates agree grades nothing. When OPT-04 lands -# `cycle_cost_threshold_eur` (§10.1/§14) these become the natural second option -# beside it, in PRICE_OPTION_DEFAULTS and nowhere else (CFG-03). +# FCST-03: ONE widening path, driven by a SET of active uncertainty sources +# (real-time product 8.3, no weather data 11.4). The combination rule is `max`, +# not a product. The factors are policy defaults, not derived, and they differ +# so the combination rules stay distinguishable (DECISIONS/TARIFF-02-price- +# provider-plugins.md). class Uncertainty(Enum): """Why an arbitrage threshold is currently wider than the tariff alone says.""" REALTIME_PRODUCT = "realtime_product" # §9.2 row 2, §8.3 @@ -415,12 +209,8 @@ WIDEN_FACTOR = { def uncertainty_of(product: PriceProduct) -> frozenset: - """The uncertainty a PRODUCT contributes on its own (§9.2 row 2). - - Derived from the declared product, never from a plugin id - the same rule - `volatility_regime()` exists for. A sixth supplier declaring - `PriceProduct.REALTIME` is widened without anyone editing this file. - """ + """The uncertainty a PRODUCT contributes on its own (9.2 row 2), derived from the + declared product, never from a plugin id.""" if not isinstance(product, PriceProduct): raise TypeError(f"uncertainty_of() takes a PriceProduct, not " f"{product!r} - a plugin name is what §9.3 forbids.") @@ -430,11 +220,7 @@ def uncertainty_of(product: PriceProduct) -> frozenset: def widen_factor(sources) -> float: """The multiplier the widest active source asks for. 1.0 when there are none. - - ⚠️ A STRING IS REFUSED. `widen_factor({"realtime"})` would otherwise be a - silent 1.0 - a widening that reports itself as applied and is not, which is - the shape of defect this repo keeps finding. - """ + A string is refused: it would be a silent 1.0.""" srcs = frozenset(sources) bad = [s for s in srcs if not isinstance(s, Uncertainty)] if bad: @@ -444,12 +230,8 @@ def widen_factor(sources) -> float: def required_spread(base_eur_kwh: float, sources) -> float: - """The spread an arbitrage cycle must clear, given what is uncertain (§8.3). - - `base_eur_kwh` is the caller's own round-trip cost threshold - OPT-04 owns - that number (§10.1's `cycle_cost_threshold_eur`), this owns only how much - wider it gets and why. - """ + """The spread an arbitrage cycle must clear, given what is uncertain (8.3). + `base_eur_kwh` is OPT-04's own round-trip cost threshold.""" if isinstance(base_eur_kwh, bool) or not isinstance(base_eur_kwh, (int, float)) or not math.isfinite(base_eur_kwh) or base_eur_kwh < 0: raise ValueError( f"required_spread() takes a non-negative, finite base threshold in " @@ -460,7 +242,7 @@ def required_spread(base_eur_kwh: float, sources) -> float: @dataclass(frozen=True) class PricePoint: - """One canonical point. THREE FIELDS. §9.3 lists three; this has three.""" + """One canonical point. THREE FIELDS, as 9.3 lists them.""" start: datetime # tz-aware, rendered at the Brussels offset eur_per_kwh: float # ALL-IN: energy + markup + taxes @@ -474,12 +256,8 @@ class PricePoint: @dataclass(frozen=True) class RawPoint: - """What a plugin returns: the figure its source published, unmodified. - - ⚠️ NOT a PricePoint, and the type difference is the guard. A plugin cannot - hand back something already claiming to be all-in, because the only - constructor of `PricePoint` that `load_prices()` uses is `assemble()`. - """ + """What a plugin returns: the figure its source published, unmodified. Not a + PricePoint: only `assemble()` builds those, so a plugin cannot claim all-in.""" start: datetime # tz-aware instant eur_per_kwh: float # as published, containing exactly caps.sources @@ -513,30 +291,16 @@ class ProviderCapabilities: return frozenset(ALL_IN - self.sources) -# --------------------------------------------------------------------------- # # The read: a series, or no data. Nothing in between. -# --------------------------------------------------------------------------- # class PriceRead(_Outcome): - """What one attempt to get prices said. Two outcomes, and only one answers. - - * `series` - we have prices. Carries `.points` (NON-EMPTY, by - construction), `.product`, `.provider`, `.fetched_at`. - * `no_data` - we do not. Carries `.reason` and NOTHING ELSE. §11.4: static - profile, plan provisional, no arbitrage. - - ⚠️ A SERIES OF ALL-ZERO PRICES IS A `series`, NOT `no_data`, and that - distinction is the whole point. Zero prices mean arbitrage is pointless; - no data means arbitrage is FORBIDDEN. An implementation that returns `[]` - on a failed fetch cannot tell a caller which of those two it is in. - """ + """What one attempt to get prices said: `series` (prices, NON-EMPTY by + construction) or `no_data` (a `.reason` and nothing else; 11.4: static profile, + plan provisional, no arbitrage). A series of all-zero prices is still a + `series`: zero prices make arbitrage pointless, no data forbids it.""" def match(self, *, series, no_data): - """Fold to a value. Both handlers required and keyword-only. - - Handlers take one argument: this read. Required-ness is what makes the - no-data case impossible to forget rather than merely easy to remember. - """ + """Fold to a value. Both handlers are required keywords, so the no-data case cannot be forgotten.""" return {"series": series, "no_data": no_data}[self.kind](self) @staticmethod @@ -553,104 +317,25 @@ 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. +# A point does not carry its own duration (9.3 has three fields): the span is +# derived from the neighbours 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. - """ + TARIFF-14: a point covers from its own start until the next point's start, and + never more than one hour. Each point takes its own gap (firm quarter-hours and + carried hours are mixed in steady state). The cap keeps FCST-04's deliberate + holes from being filled by stretching the point before them. The last point + gets the span of the one before it (one hour if alone), so on a 96-point day + `at(00:00)` raises `Absence` rather than answering with today's last price. + Sorted here, so no caller's list order matters. Nothing here raises on a + malformed series: a duplicated start (refused by `load_prices()`) gives the + earlier entry a zero-width window. DECISIONS/TARIFF-14-the-window-a-point-covers.md.""" rows = sorted(((p.start.astimezone(timezone.utc), i, p) for i, p in enumerate(points)), key=lambda r: (r[0], r[1])) @@ -673,9 +358,8 @@ class _Series(PriceRead): def __init__(self, points, product, provider, fetched_at): points = list(points) - # ⚠️ THE REFUSAL THAT MAKES THE TYPE WORTH HAVING. An empty series is - # how "the fetch failed" gets to wear the shape of "we looked and there - # is nothing expensive today". It is not constructible. + # An empty series is not constructible: "the fetch failed" must not + # look like "nothing is dear today". if not points: raise ValueError( "PriceRead.series() refuses an empty point list. A fetch that " @@ -691,9 +375,8 @@ 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. + # TARIFF-14. Built once here so `at()` scans an index whose order it + # established itself. self._cover = _cover_table(self.points) self.words = (f"{len(self.points)} points from {provider} " f"({product.value}, {self.regime} thresholds)") @@ -703,25 +386,10 @@ class _Series(PriceRead): return {"prices": [p.canonical() for p in self.points]} def at(self, instant: datetime) -> PricePoint: - """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. - """ + """The point whose OWN interval covers `instant`, at ANY resolution. Returns a + point or raises; there is no falsy answer, so `at(t) or fallback` cannot happen. + Ask `covers()` first to branch. Linear scan (about 192 entries, 30 s path). + The window rule is in `_cover_table()`.""" u = instant.astimezone(timezone.utc) for start, end, point in self._cover: if start <= u < end: @@ -735,15 +403,8 @@ class _Series(PriceRead): 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. - """ + """Whether `at(instant)` would answer. Delegates to `at()`: a second copy of the + window rule is a second place for it to be wrong.""" try: self.at(instant) return True @@ -751,29 +412,15 @@ class _Series(PriceRead): return False def last_end_before(self, instant: datetime): - """When the last price that has run out by `instant` stopped covering, - or None if no price has (PRICE-01: "no prices since 00:00"). - - Each point's end is the one `_cover_table()` worked out, so a hole in - the series is reported from where the hole starts and not from the end - of the day. - """ + """When the last price that has run out by `instant` stopped covering, or None + (PRICE-01: "no prices since 00:00"). A hole is reported from where it starts.""" u = instant.astimezone(timezone.utc) ends = [end for _start, end, _p in self._cover if end <= u] return max(ends) if ends else None def window(self, since: datetime, until: datetime) -> tuple: - """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. - """ + """Points whose START falls in [since, until). May be empty. Not a coverage + query (it never asks how long a point lasts); `at()`/`covers()` are.""" a = since.astimezone(timezone.utc) b = until.astimezone(timezone.utc) return tuple(p for p in self.points @@ -781,27 +428,10 @@ class _Series(PriceRead): class _NoData(PriceRead): - """We have no prices, and the §11.4 row applies. - - ⚠️ THIS CLASS HAS NO `.points` AND THAT IS THE POINT. `min(read.points)`, - `for p in read.points`, `read.get("points", [])` and `len(read.points)` all - raise `Absence` here rather than quietly reporting that the cheapest hour of - the day costs nothing. - - ⚠️ TWO WAYS PAST IT SURVIVE, BOTH FOUND IN REVIEW OF PR #16, AND NEITHER IS - WORTH CODE: - - * `vars(read).get("points", [])` returns `[]`. `vars()` bypasses - `__getattr__` entirely; nothing short of `__slots__` plus a metaclass - would stop it, and anyone writing `vars()` on a variant type has left - the road on purpose. - * `bool(read)` is `True` for BOTH variants, so `if read:` looks like a - check and is not one. Defining `__bool__` to be False here would make - `if read:` "work" - and that is exactly the wrong fix: it would turn a - line that reads like a guard into one that IS a guard by accident, - which is how `.get(k, default)` got into this codebase in the first - place. Use `.match(...)`. There is no falsy variant on purpose. - """ + """We have no prices, and the 11.4 row applies. There is no `.points`, so + `min(read.points)` and `.get("points", [])` raise `Absence` rather than report + the cheapest hour as free. `bool(read)` is True for both variants and has no + falsy form on purpose: use `.match(...)`.""" kind = "no_data" @@ -811,62 +441,28 @@ class _NoData(PriceRead): def arbitrage_allowed(read: PriceRead) -> bool: - """§11.4, stated once, as a function so no caller has to remember it. - - Not the arbitrage strategy - that is OPT-04. This is only the veto, which - belongs with the type that expresses the condition. - """ + """11.4 as a function, so no caller has to remember it. Only the veto; the strategy is OPT-04.""" return read.match(series=lambda _r: True, no_data=lambda _r: False) def plan_is_provisional(read: PriceRead) -> bool: - """§8.3 / §11.4: provisional if there is no data at all, or if ANY point in - the series is itself provisional. Derived from the points, never asserted.""" + """8.3 / 11.4: provisional if there is no data at all, or if ANY point is itself + provisional. Derived from the points, never asserted.""" return read.match( series=lambda r: any(p.provisional for p in r.points), no_data=lambda _r: True) -# --------------------------------------------------------------------------- # # Options: what a plugin may see, and what it may not -# --------------------------------------------------------------------------- # -# --------------------------------------------------------------------------- # # TARIFF-18: what `price_taxes_eur_kwh` IS MADE OF -# --------------------------------------------------------------------------- # -# ⚠️ THE DEFECT THIS TABLE PREVENTS IS THE ONE THE SHIPPED 0.06 WAS: A NUMBER -# NOBODY COULD SOURCE. 0.06 EUR/kWh is roughly HALF the real Flemish non-energy -# figure, and because no reader could see what it was made of there was nothing -# to argue with. Every row below is one line of the Eneco residential card for -# Flanders, September 2026, INCLUDING 6 % VAT, converted from the c EUR/kWh the -# card prints to the EUR/kWh this option takes. `price_taxes_eur_kwh`'s default -# is their sum, and test_price.py section 1b re-adds them and refuses a default -# that is not exactly that. -# -# ⚠️ THIS IS A PER-DSO FIGURE, AND IT IS THE DISTRIBUTION ROW THAT MOVES. Across -# the eight Fluvius DSOs that row runs 5,28 c EUR/kWh (Midden-Vlaanderen) to -# 6,70 (West), so the all-in Flemish total runs about 0.117 to 0.131 EUR/kWh. -# A site outside Antwerpen should type its own total; the other two rows are -# Flanders-wide and do not move with the DSO. -# -# ⚠️ AND THE DEFAULT SITS NEAR THE LOW END OF THAT BAND ON PURPOSE, BECAUSE THE -# TWO ERROR DIRECTIONS ARE NOT SYMMETRIC. This scalar is a LEVEL component: it -# cancels exactly in `max - min` and cannot move which hour is cheapest -# (test_price.py 16b measures that as a family). The one DECISION it reaches is -# §17's negative-price guard in arbitrage.py, which tests the ABSOLUTE all-in -# price of the sell hour. A figure set too HIGH carries a genuinely negative -# hour up above zero, and the add-on exports into paid-for disposal - the exact -# case §17 exists to refuse. A figure set too LOW only declines a trade that -# would have paid. So where a default must stand in for eight different DSOs, -# it belongs at the bottom of the band and not in the middle of it. -# -# ⚠️ `energiebijdrage` IS ZERO, AND IT IS A ROW HERE RATHER THAN AN ABSENCE. -# "Vanaf 1 augustus 2026 bedraagt de energiebijdrage 0 EUR/MWh" - it was merged -# into the bijzondere accijns. Anyone re-deriving this total from a guide -# written before that date will find a separate energiebijdrage line and add it -# on top of an accijns that already contains it. The zero row is here so that -# the double-count is visible as a decision rather than as an omission. +# Each row is one line of the Eneco residential card for Flanders, September +# 2026, including 6 % VAT, in EUR/kWh. The default of `price_taxes_eur_kwh` is +# their sum (test_price.py section 1b re-adds them). Per DSO, and set near the +# low +# end of the band on purpose; `energiebijdrage` is a zero row since 2026-08-01. +# Why: DECISIONS/TARIFF-02-price-provider-plugins.md, "Taxes components". FLEMISH_TAXES_COMPONENTS_EUR_KWH = ( ("Fluvius distribution, Antwerpen", 0.0535), ("Bijzondere accijns, 'Alle verbruik' - no volume bands", 0.04876), @@ -874,152 +470,85 @@ FLEMISH_TAXES_COMPONENTS_EUR_KWH = ( ("Energiebijdrage - 0 EUR/MWh since 2026-08-01, inside the accijns", 0.0), ) -# ⚠️ TARIFF-18, AND IT IS NOT HOUSEKEEPING - IT DECIDES WHICH CONFIGS START. -# TARIFF-17's rung in validate.py refuses a site that sets `price_taxes_profile` -# AND leaves `price_taxes_eur_kwh` at something other than the shipped default, -# because the table wins and the scalar would decide nothing. It compares -# against the DEFAULT rather than against presence, because the Supervisor sets -# `App.options = deepmerge(config.yaml defaults, stored options)` and a saved -# blob therefore carries every schema key whether the operator typed it or not -# (measured: the reference install's export is 70 of 71 keys - CHANGELOG 0.9.0, -# `tools/supervisor_selfapi_probe.py` §D). -# -# So moving the default turns a previously ACCEPTED configuration into -# `SystemExit(78)`. A site that stored 0.06 - which is every install that has -# ever saved its configuration - and that also carries a taxes table would stop -# starting on the upgrade, over a number it never typed and that decides -# nothing. That refusal would be TRUE and USELESS: the rung's subject is a -# number the OPERATOR typed, and a superseded shipped default is not one. It -# still reads as "untouched" here, and the rung's sentence names both figures. +# TARIFF-18: moving this default changes which configs start. validate.py +# (TARIFF-17 rung) compares `price_taxes_eur_kwh` to the default, so a +# superseded +# default (0.06) must still read as untouched. Do not change a default without +# that check. DECISIONS/TARIFF-02-price-provider-plugins.md. SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) # TARIFF-02 .. TARIFF-17 -# ⚠️ CFG-03, AND HOW THIS TICKET AVOIDS WIDENING IT. Option defaults in this -# add-on already live in three places - config.yaml, main.py's `o.get(k, X)` -# fallbacks, and a dataclass default - held apart only by a tripwire in -# test_validate.py. This module adds ONE more location, not another three: -# -# * every price option's fallback is here, and only here; -# * validate.py imports this dict rather than restating any number; -# * main.py gets no `o.get("price_...", X)` fallbacks at all; -# * there is no dataclass carrying a default copy of any of these; -# * test_price.py READS config.yaml and asserts the shipped default equals -# this dict, key for key, so the two cannot drift silently. -# -# config.yaml remains the source of truth. This is the single in-code mirror, -# pinned to it by a check, which is the `validate.py` pattern rather than a -# fourth freehand copy. +# CFG-03. The one in-code mirror of config.yaml for the price options: +# validate.py +# imports it, main.py has no price `.get()` fallbacks, and test_price.py +# section 1 +# pins it to config.yaml key for key. PRICE_OPTION_DEFAULTS = { "price_provider": "off", "price_markup_eur_kwh": 0.03, # §14 tariffs.energy_price.markup_eur_kwh - # TARIFF-18: the sum of FLEMISH_TAXES_COMPONENTS_EUR_KWH above, to the - # card's own last digit. §14 tariffs.energy_price.taxes_eur_kwh. + # TARIFF-18: the sum of FLEMISH_TAXES_COMPONENTS_EUR_KWH, to the card's + # last digit. "price_taxes_eur_kwh": 0.11736, "export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4) "price_csv_path": "/share/goodwe_prices.csv", "price_feed_url": "", - # PRICE-01. The bearer token of the owner's price server. A secret: it is - # in SECRET_OPTIONS below, so no text built from an error reaches a log, - # the status page or a notification with it in. + # PRICE-01. The bearer token (a secret: in SECRET_OPTIONS, so `scrub()` + # keeps it out of text). "price_service_token": "", "price_horizon_h": 48, "price_timeout_s": 10, - # TARIFF-03. §14 `tariffs.static_profile: null`. A JSON object describing a - # static day/night or fully-fixed tariff; "" is §14's `null`. Parsed and - # validated by app/static_tariff.py, refused at startup by validate.py. - # - # ⚠️ IT LIVES HERE RATHER THAN IN static_tariff.py FOR CFG-03's SAKE. This - # dict is the ONE in-code mirror of config.yaml, pinned to it by - # test_price.py section 1. A second defaults dict in the new module would be - # a fourth place option defaults live, which is exactly the open ticket. + # TARIFF-03. A JSON static day/night or fixed tariff; "" is unset. Parsed + # by + # app/static_tariff.py, refused at startup by validate.py. Here, not in + # static_tariff.py, so there is one defaults mirror (CFG-03). "static_profile": "", - # TARIFF-17. A JSON object in `static_profile`'s OWN period grammar whose - # per-period `eur_per_kwh` is the TAXES COMPONENT for that band, not an - # all-in price; "" is unset and the scalar above decides. Parsed and - # validated by app/static_tariff.py (`parse_taxes_profile`), refused at - # startup by validate.py - the `static_profile` precedent exactly, and here - # for the same CFG-03 reason: this dict is the ONE in-code mirror of - # config.yaml. - # - # ⚠️ IT IS IN FRAMEWORK_KEYS. A plugin that could read this could add the - # taxes a second time, per hour, which is the double-counting defect trap 1 - # exists to refuse - wearing a period table instead of a scalar. + # TARIFF-17. A period table in `static_profile`'s grammar whose + # `eur_per_kwh` + # is the TAXES component per band; "" is unset and the scalar decides. In + # FRAMEWORK_KEYS, so a plugin cannot add taxes a second time per hour. "price_taxes_profile": "", - # TARIFF-15. A JSON object carrying ONE contract's two coefficients, the - # units they are printed in, its VAT rate and the card figures the add-on - # grades them against; "" is unset. Parsed and validated by - # app/custom_formula.py, refused at startup by validate.py - the - # `static_profile` precedent exactly, and here for the same CFG-03 reason: - # this dict is the ONE in-code mirror of config.yaml. + # TARIFF-15. One contract's two coefficients, units, VAT rate and the card + # figures they are graded against; "" is unset. Parsed by + # app/custom_formula.py. "price_formula": "", - # TARIFF-16. The INJECTION leg, in `price_formula`'s OWN grammar and read - # by `custom_formula.parse_export_formula()` - the same parser with its - # option name as an argument, so a second unit table and a second unknown- - # key rule do not exist to diverge. "" is unset and the scalar - # `export_price_eur_kwh` above decides, which is every site today. - # - # ⚠️ IT IS IN FRAMEWORK_KEYS, for the reason the scalar is: a CONSUMPTION - # plugin that could read an injection curve could fold it into a - # consumption price, and nothing downstream could tell. - # - # ⚠️ AND IT LIVES HERE RATHER THAN IN custom_formula.py FOR CFG-03's SAKE, - # exactly as `price_formula` and `price_taxes_profile` do: this dict is the - # ONE in-code mirror of config.yaml. + # TARIFF-16. The INJECTION leg in `price_formula`'s own grammar, read by + # `custom_formula.parse_export_formula()`; "" is unset and + # `export_price_eur_kwh` + # decides. In FRAMEWORK_KEYS, so a consumption plugin cannot fold it in. "price_export_formula": "", - # OPT-04. §10.1 priority 3, and §14's `optimizer.cycle_cost_threshold_eur`. - # - # ⚠️ THEY LIVE HERE FOR CFG-03's SAKE, exactly as `static_profile` does, and - # FCST-03's note above `WIDEN_FACTOR` names this as the place they would - # land. `arbitrage_soc_floor` / `arbitrage_soc_ceiling` are the reserve - # arbitrage may not spend and the headroom it may not overfill; they are - # NOT the maintenance SoC keys, which describe a balancing cycle rather - # than a trading reserve and would silently couple two unrelated policies. - # - # ⚠️ AND `cycle_cost_threshold_eur` IS 0.10 BECAUSE §10.1's NAME FOR IT SAYS - # SO, NOT BECAUSE IT MAKES ANYTHING TRADE. On realistic Belgian day/night - # numbers the static spread is ~0.08 EUR/kWh, so a static-tariff site does - # NOT trade at this default. That is a reported result (test_arbitrage.py - # section 7), and whether 0.10 - or FCST-03's 2.5/1.5 - is right is one - # product decision that belongs to the owner. No number here was chosen to - # make arbitrage happen. + # OPT-04, 10.1 priority 3, and `optimizer.cycle_cost_threshold_eur`. The + # two + # SoC keys are not the maintenance keys. 0.10 is the 10.1 name, not a value + # that + # makes anything trade (static spread is about 0.08): an owner decision. + # DECISIONS/TARIFF-02-price-provider-plugins.md. "arbitrage_enabled": False, "cycle_cost_threshold_eur": 0.10, "arbitrage_soc_floor": 20, "arbitrage_soc_ceiling": 95, } -# ⚠️ The keys the FRAMEWORK owns. A plugin that reads one of these is a plugin -# that can double-count, so `PluginOptions` refuses them by name. Trap 1. +# The keys the FRAMEWORK owns. A plugin that reads one can double-count, so +# `PluginOptions` refuses them by name (trap 1). FRAMEWORK_KEYS = frozenset({ "price_markup_eur_kwh", "price_taxes_eur_kwh", "export_price_eur_kwh", - # TARIFF-17. The hour-varying spelling of `price_taxes_eur_kwh`, and it - # belongs here for the same reason the scalar does: it is a component the - # FRAMEWORK adds exactly once, by `caps.adds`. `BANNED_IN_PLUGIN_CODE` is - # derived from this set, so `code_leaks()` refuses a plugin that names it - # without anything else being edited. + # TARIFF-17. The hour-varying `price_taxes_eur_kwh`, added once by + # `caps.adds`. `code_leaks()` derives from this set. "price_taxes_profile", - # TARIFF-16. The hour-varying spelling of `export_price_eur_kwh`, and it - # belongs here for the same reason the scalar does: a plugin that could - # read an INJECTION curve could fold it into the CONSUMPTION series it - # emits, and `assemble()` has no way to notice a component that arrived - # inside a RawPoint. `BANNED_IN_PLUGIN_CODE` is derived from this set, so - # `code_leaks()` refuses a plugin that names it with nothing else edited. + # TARIFF-16. The hour-varying `export_price_eur_kwh`; same reason, same + # derivation. EXPORT_FORMULA_OPTION, }) -# PRICE-01. Options whose VALUE must never be printed. `scrub()` removes them -# from any text the price code builds out of an error. +# PRICE-01. Options whose VALUE must never be printed; `scrub()` removes them +# from error text. SECRET_OPTIONS = frozenset({"price_service_token"}) def scrub(text, opts) -> str: - """`text` with every secret option's value replaced by `***`. - - The price code builds reasons out of library errors, and a library may put - a request header into its error text. Taking the value out here is the - last line of defence; the plugin also never puts it in a message itself. - """ + """`text` with every secret option's value replaced by `***`. The last line of + defence: a library may put a request header into its error text.""" out = str(text) for key in SECRET_OPTIONS: secret = str((opts or {}).get(key) or "").strip() @@ -1029,17 +558,9 @@ def scrub(text, opts) -> str: class PluginOptions: - """The options dict, minus the keys only the framework may read. - - Reading `price_markup_eur_kwh` here raises `FrameworkOption` rather than - returning 0.03. - - ⚠️ THIS IS ONE OF THE TWO DOORS, NOT THE ONLY ONE. `_o` is reachable, and - `price_option()` and `PRICE_OPTION_DEFAULTS` are public names in this - module. This class alone is a convention; it is `register()`'s - `code_leaks()` scan that makes it an inability. Do not weaken either half - on the strength of the other. - """ + """The options dict, minus the keys only the framework may read: reading + `price_markup_eur_kwh` raises `FrameworkOption` rather than returning 0.03. One of + two doors; `register()`'s `code_leaks()` scan is the other. Do not weaken either.""" def __init__(self, opts: dict): self._o = dict(opts or {}) @@ -1078,136 +599,38 @@ def price_option(opts: dict, key: str): 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. - """ + One spelling for three call sites (TARIFF-10). validate.py keeps its own reader + (it validates the raw blob). It does not decide whether the id is shipped.""" 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. - """ + """Has the operator selected a price feed at all? (TARIFF-10) Starts the + background refresh, which used to wait for `arbitrage_enabled` (ships false). + NOT a trading switch: `arbitrage.enabled()` answers that (`main.tariff_refresh_wanted()`).""" 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. + """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 - - 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. - - ⚠️ TARIFF-16 LANDED THE INJECTION LEG, AND THIS FUNCTION IS UNCHANGED. The - curve lives in `price_export_formula` and is read by `export_formula()` - below; this reader still answers the FLAT injection price, still from one - option, still with no formula anywhere in its arithmetic. That is the - arrangement this docstring predicted before either existed - "an injection - curve gets a `PriceProvider` of its own and this scalar becomes its - FIXED-product default" - and it is why a site with a flat injection price - sees nothing change: `price_export_formula` ships "", `export_price_at()` - falls through to this function, and this function's body is byte-identical - to the one TARIFF-02 shipped. - - ⚠️ It is NOT reachable from a plugin: `export_price_eur_kwh` is in - FRAMEWORK_KEYS, so a consumption plugin cannot quietly fold an injection - price into a consumption price. `price_export_formula` is in the same set - for the same reason. - - ⚠️ AND NOTHING IN THIS ADD-ON READS EITHER ONE TO TAKE A DECISION. - `arbitrage.py` prices the sell side as DISPLACED IMPORT and says so in its - own comment; both hours it compares are all-in consumption prices. This - reader has no caller under `app/` and neither does `export_price_at()`. - They are what an operator is shown and what a future export-aware strategy - would call - not a number any trade turns on today. - """ + Not reachable from a plugin (`export_price_eur_kwh` is in FRAMEWORK_KEYS). The + formula leg is `export_formula()`; this stays the flat default. Nothing under + `app/` takes a decision on it. DECISIONS/TARIFF-02-price-provider-plugins.md.""" return float(price_option(opts, "export_price_eur_kwh")) def export_formula(opts: dict): - """The configured `price_export_formula` as a `FormulaSpec`, or None. - - TARIFF-16. Raises `custom_formula.FormulaInvalid` on a malformed leg - and - ⚠️ THAT EXCEPTION IS NOT MEANT TO REACH A RUNNING SITE, by the rule - `taxes_profile()` states above and `static_profile` established: - validate.py parses the same option at STARTUP and refuses to start on it, - because a formula that only fails when something reads it is a defect - discovered at the worst possible moment and §11.4 makes every price - defect quiet. - - ⚠️ THE PARSE IS THE SAME ONE THE CONSUMPTION LEG GETS. There is no second - parser, no second unit table and no second resolution rule - - `parse_export_formula()` is `parse_price_formula()` with its `option` - argument moved, so every refusal an operator can hit on this leg is a - refusal that already had checks over it. - """ + """The configured `price_export_formula` as a `FormulaSpec`, or None (TARIFF-16). + `custom_formula.FormulaInvalid` is refused at startup by validate.py, so it + should not reach a running site. Same parser as the consumption leg.""" return parse_export_formula(price_option(opts, EXPORT_FORMULA_OPTION)) def export_price_at(opts: dict, index_eur_per_mwh: float) -> float: """What the site is PAID for one exported kWh at one index value, EUR/kWh. - - THE SEAM, AND ITS HONEST SCOPE. This is the one function a future - export-aware strategy calls, and it has NO CALLER UNDER `app/` today - - stated here rather than implied, because the add-on takes no decision on an - injection price (see `export_price_eur_kwh()` above and `arbitrage.py`'s - accounting-basis comment). What it buys now is that OPT-06 and anything - after it inherit a shape the market fits, instead of a scalar that cannot - hold its own factor, a negative offset, or a VAT rate different from the - consumption leg's. - - ⚠️ THE FLAT PATH IS THE FALLTHROUGH AND IT IGNORES THE INDEX, which is - CORRECT rather than a simplification: a site with a flat injection price is - paid the same for every kWh whatever the market did, and that is what the - scalar means. With `price_export_formula` empty - every site today - this - returns exactly `export_price_eur_kwh(opts)` for every index. - - ⚠️ A NEGATIVE ANSWER IS A REAL ANSWER. Eneco's `0,1 x BELPEX-H - 1,188` is - below zero for any BELPEX-H under 11,88 EUR/MWh, which is the middle of a - sunny day and precisely when a PV household exports. Nothing here floors - it: a caller that silently clamped it to zero would hide the hours in which - exporting COSTS money, which is the one thing an injection price is for. - """ + No caller under `app/` yet. A flat price ignores the index (correct); a + negative answer is real and is never floored.""" spec = export_formula(opts) if spec is None: return export_price_eur_kwh(opts) @@ -1215,47 +638,28 @@ def export_price_at(opts: dict, index_eur_per_mwh: float) -> float: def taxes_profile(opts: dict): - """The configured `price_taxes_profile` as a period table, or None. - - TARIFF-17. Raises `static_tariff.ProfileInvalid` on a malformed table - and - ⚠️ THAT EXCEPTION IS NOT MEANT TO REACH A RUNNING SITE. validate.py parses - the same option at STARTUP and refuses to start on it, for the reason - `static_profile` is refused there: a table that only fails when a fetch - happens is a site that stops pricing at 03:00, and §11.4 turns that into a - quiet fallback nothing on the status page distinguishes from a healthy - fixed-tariff day. `load_prices()` still handles it, loudly, because a guard - whose only proof is "the other guard ran first" is not a guard. - - ⚠️ THE IMPORT IS LAZY AND THE CYCLE IS THE REASON. `static_tariff.py` - imports this module at module scope; importing it back at module scope here - would make which file is imported first decide whether the add-on starts. - `sys.modules` makes the repeat cost a dict lookup. - """ + """The configured `price_taxes_profile` as a period table, or None (TARIFF-17). + `static_tariff.ProfileInvalid` is refused at startup by validate.py; + `load_prices()` still handles it loudly. The import is lazy: static_tariff.py + imports this module at module scope.""" from .static_tariff import parse_taxes_profile return parse_taxes_profile(price_option(opts, "price_taxes_profile")) -# --------------------------------------------------------------------------- # # The plugin interface -# --------------------------------------------------------------------------- # MAX_PLAUSIBLE_EUR_KWH = 10.0 # see _validate_raw class PriceProvider(ABC): """One supplier. Declares what it is, then returns what its source said. - - ⚠️ `fetch()` DOES NOT RETURN A PRICE. It returns `RawPoint`s carrying the - published figure. The all-in arithmetic happens in `assemble()`, once, in - the framework, using `capabilities().sources`. See trap 1. - """ + `fetch()` returns `RawPoint`s, not prices; `assemble()` does the arithmetic.""" id = "?" - # PRICE-01. The ids to try, in order, when this source cannot answer, and - # whether the last answer is kept for a day without any source. Empty and - # False for every plugin that existed before, which is what keeps them - # exactly as they were: `price_chain.py` is not used for them at all. + # PRICE-01. Ids to try in order when this source cannot answer, and whether + # the + # last answer is kept for a day. Empty / False for every earlier plugin. FALLBACKS: tuple = () KEEPS_LAST_GOOD: bool = False @@ -1269,91 +673,31 @@ class PriceProvider(ABC): transport) -> list: """Return `RawPoint`s, or raise `SourceUnavailable(reason)`. - `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. - """ + `transport(url, timeout_s) -> bytes` is injected. `opts` is a `PluginOptions`. + `since`/`until` are the range the caller wants; a plugin that ignores them gets + its feed's default (TARIFF-09). Not mechanically enforced: test_price.py pins + which plugins consume the range as a literal roster.""" def startup_preview(self, points) -> list: - """Lines to log ONCE, the first time this provider answers (TARIFF-15). - - `points` are the ASSEMBLED, all-in `PricePoint`s - what the site is - really priced on - and the default is to say nothing, so no existing - plugin changes behaviour. - - ⚠️ IT IS A HOOK RATHER THAN A LINE IN `load_prices()` BECAUSE THE - FRAMEWORK CANNOT KNOW WHAT IS WORTH PREVIEWING. Only the plugin knows - what went IN to its own arithmetic - for `custom_formula` the index in - EUR/MWh - and printing that beside the all-in figure is the whole - point: a factor of 1000 is obvious in one line of - `61.96 -> 0.13522` and invisible in a plan. The framework supplies the - one number a plugin may never read, by handing back the assembled - result rather than the tax rate. - - ⚠️ IT MUST NOT RAISE INTO THE CONTROL PATH and it is not trusted not - to: `_emit_startup_preview()` catches everything. A log line is not a - reason to stop regulating a battery. - """ + """Lines to log ONCE, the first time this provider answers (TARIFF-15). `points` + are the assembled all-in `PricePoint`s; the default says nothing. A hook, since + only the plugin knows what went in. Must not raise into the control path + (`_emit_startup_preview()` catches everything).""" return [] -# ⚠️ THE NAMES A PLUGIN'S OWN CODE MAY NOT MENTION. FRAMEWORK_KEYS is the -# obvious half; the other three are the routes review of PR #16 actually used to -# write a double-counting plugin after `PluginOptions` was in place: -# -# `_o` - PluginOptions' private dict, reachable as -# `opts._o["price_markup_eur_kwh"]`. -# `price_option` - a PUBLIC framework helper that takes the raw -# options dict and answers with the markup. -# `PRICE_OPTION_DEFAULTS` - a PUBLIC module constant. This is the one that -# decided the review: no private access, no options -# dict, no helper - just a dict in the module every -# plugin already imports for `register`. -# -# Any of them turns +0.088 into +0.119 on a wholesale feed with nothing -# anywhere reporting it. +# Names a plugin's own CODE may not mention: FRAMEWORK_KEYS plus `_o`, +# `price_option` and `PRICE_OPTION_DEFAULTS`, the other routes PR #16 review +# used +# to double-count. BANNED_IN_PLUGIN_CODE = tuple(sorted(FRAMEWORK_KEYS)) + ( "PRICE_OPTION_DEFAULTS", "price_option", "_o") def code_leaks(cls) -> list: """Framework-owned names appearing in a plugin's own CODE. Empty is clean. - - ⚠️ 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 `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. - - ⚠️ FAIL-OPEN ON MISSING SOURCE, DELIBERATELY. `inspect.getsource` needs the - .py file, which the add-on image has (Dockerfile COPYs `app/`) but a frozen - 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 every - shipped plugin, so a build that silently lost it is a red check, not a - quietly disarmed guard. - """ + Docstrings are stripped first (they must name the components). Fails open on + missing source; test_price.py asserts source is found for every shipped plugin.""" try: src = dedent(inspect.getsource(cls)) tree = ast.parse(src) @@ -1370,36 +714,11 @@ 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. -# --------------------------------------------------------------------------- # +# TARIFF-07: the ids that USED to resolve here. A retired id is not a typo and +# has no silent alias (it published retail prices, `generic_dayahead` reads +# wholesale). Each value is the id-specific half of the refusal; validate.py +# owns +# the framing. DECISIONS/TARIFF-07-withdrawing-three-plugins.md. RETIRED_PROVIDERS: dict = { "engie_dynamic": ( "its endpoint https://api.engie.be/dynamic/be does not exist - the " @@ -1426,24 +745,14 @@ PROVIDERS: dict = {} def register(cls): - """Register a provider by its own declared id. Decorator. - - ⚠️ THE ONLY NAME->CLASS MAPPING IN THE SYSTEM. AC2 is "adding a plugin needs - no optimiser changes", and this is where that is bought: config resolves an - id here, the optimiser is handed a `_Series` carrying a `PriceProduct`, and - nothing downstream ever sees the id except to print it. - """ + """Register a provider by its own declared id. Decorator. The only name->class + mapping in the system (AC2: adding a plugin needs no optimiser changes).""" 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. + # At import: re-registering a retired id silences validate.py's + # refusal. Take + # the id out of RETIRED_PROVIDERS in the same commit. 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 " @@ -1454,9 +763,8 @@ def register(cls): cls.capabilities() # fail at import, not at 15:00 on fetch day leaks = code_leaks(cls) if leaks: - # ⚠️ AT IMPORT, NOT AT FETCH. A plugin that can reach the markup is a - # plugin that can bill wrong for a month before anyone notices, so it - # does not get to exist - the add-on fails to start and names the line. + # At import, not at fetch: a plugin that can reach the markup must not + # exist. raise FrameworkOption( f"{cls.id!r} mentions {', '.join(leaks)} in its own code. A plugin " f"returns what its SOURCE published and declares " @@ -1471,13 +779,8 @@ def register(cls): def _validate_raw(points, caps): """Reject at the boundary, so a malformed feed cannot become a bad decision. - - ⚠️ NEGATIVE PRICES ARE VALID AND ARE NOT CLAMPED (§17 keeps a negative-price - regression case; Belgian day-ahead goes negative on windy Sundays and those - hours are the single best charging opportunity of the year). What is - rejected is what cannot be a price at all: NaN, infinity, an implausible - magnitude, a naive datetime, and duplicate or out-of-order starts. - """ + Negative prices are valid and not clamped (9.3, 17). Rejected: NaN, infinity, + an implausible magnitude, a naive datetime, duplicate or out-of-order starts.""" if not isinstance(caps, ProviderCapabilities): raise SourceUnavailable("the plugin did not declare capabilities") if not points: @@ -1509,29 +812,10 @@ def _validate_raw(points, caps): def taxes_at(taxes, instant: datetime) -> float: - """The TAXES component at ONE instant. A scalar, or a period table. - - TARIFF-17. THE DEFECT THIS EXISTS TO CLOSE, and it is a MISSING CAPABILITY - rather than a wrong constant: `price_taxes_eur_kwh` is additive and - identical every hour, so it cancels exactly out of `max - min` and cannot - move `min(firm, key=...)`. It is not a distortion - it is provably inert on - both quantities the optimiser reads. What was missing is anywhere for an - HOUR-VARYING non-energy component to live, and the Walloon and Brussels - DSOs bill one: ORES `dag 13,27 / nacht 7,39` c EUR/kWh, a 5,88 c gap whose - cheap band coincides with the cheap spot hours. Flattening it does not - shift the curve, it UNDERSTATES the spread and declines profitable cycles. - See DOCS.md "The three buckets". - - ⚠️ DUCK-TYPED ON `price_at`, NOT ON `isinstance`. price.py must not import - static_tariff.py - static_tariff.py imports THIS module, and a module-level - cycle would make the import order load-bearing. `taxes_profile()` below is - the one place the real parser is reached, and it imports it lazily. - - ⚠️ THE LOOKUP IS ON THE ~30 s PLANNING PATH through `assemble()`, so it - stays a linear scan over the two-to-four periods a Belgian tariff has - (`StaticProfile.at()`), with no parsing, no allocation and no I/O. A - malformed table never reaches here: validate.py refuses it at STARTUP. - """ + """The TAXES component at ONE instant. A scalar, or a period table (TARIFF-17). + Duck-typed on `price_at` (price.py must not import static_tariff.py). A linear + scan over two to four periods on the 30 s path; validate.py refuses a malformed + table at startup. DECISIONS/TARIFF-02-price-provider-plugins.md.""" at = getattr(taxes, "price_at", None) if at is None: return float(taxes) @@ -1542,24 +826,10 @@ def assemble(caps: ProviderCapabilities, raw, markup: float, taxes) -> list: """RawPoints -> canonical all-in PricePoints. THE ONLY PLACE MARKUP IS ADDED. - Adds exactly `caps.adds` - the complement of what the plugin declared its - source already contains. A plugin that publishes an all-in figure gets - nothing added; a raw EPEX figure gets both. - - ⚠️ `taxes` IS A SCALAR *OR* A PERIOD TABLE (TARIFF-17), AND IT IS EVALUATED - AT EACH POINT'S OWN INSTANT. Hoisting the lookup out of the loop - reading - it once and adding the same number to every point - is the mutation that - collapses the whole feature back into the LEVEL bucket: the assembled - series would still differ from the untaxed one by the right MAGNITUDE, and - the cheapest hour would be wrong. `test_price.py` section 24 is the check - that goes red for it, and it grades WHICH HOUR IS CHEAPEST rather than the - spread, because a spread-magnitude assertion passes on the hoisted version. - - ⚠️ THE SCALAR PATH IS UNCHANGED, ARITHMETICALLY AND NOT ONLY IN INTENT. - `taxes_at()` on a float returns that float at every instant, and - `0.0 + markup` is exactly `markup` in IEEE 754 for every finite markup, so - a site on a scalar gets the identical bytes it got before this landed. - """ + Adds exactly `caps.adds`, the complement of what the plugin declared. `taxes` + is evaluated at EACH point's own instant: hoisting it out of the loop collapses + the feature (test_price.py section 24). On a scalar the result is byte-identical + to before TARIFF-17.""" add_markup = float(markup) if Component.MARKUP in caps.adds else 0.0 wants_taxes = Component.TAXES in caps.adds out = [] @@ -1572,23 +842,13 @@ def assemble(caps: ProviderCapabilities, raw, markup: float, return out -# --------------------------------------------------------------------------- # # Transport - one place, HTTPS only, host checked against the declaration -# --------------------------------------------------------------------------- # async def https_get(url: str, timeout_s: float, headers=None) -> bytes: - """The default transport. §16: outbound is allow-listed and TLS'd. - - Imported lazily so `manual_csv` - the offline fallback - never needs aiohttp - to be importable at all. AC3 says it works fully offline; that includes a - machine where the HTTP stack is missing. - - PRICE-01. `headers` is None for every plugin but `price_service`, and with - None this is the code it has always been, byte for byte. With headers it - answers in the SAFE words of `status_words()` and never lets a library - error through: an error from a request that carried a token is the one - place a token could be printed by somebody else's code. - """ + """The default transport. 16: outbound is allow-listed and TLS'd. aiohttp is + imported lazily so `manual_csv` works offline. PRICE-01: with `headers` it + answers in the safe words of `status_words()` and never lets a library error + through (it could print a token).""" import aiohttp t = aiohttp.ClientTimeout(total=float(timeout_s)) if headers is None: @@ -1598,9 +858,8 @@ async def https_get(url: str, timeout_s: float, headers=None) -> bytes: return await r.read() try: async with aiohttp.ClientSession(timeout=t) as s: - # No redirects: the host is fixed in code, and a redirect would - # take the request (and the token) somewhere the allow-list never - # saw. A 3xx is reported like any other status. + # No redirects: the host is fixed in code and a redirect would take + # the token past the allow-list. async with s.get(url, headers=headers, allow_redirects=False) as r: if r.status != 200: @@ -1613,9 +872,8 @@ async def https_get(url: str, timeout_s: float, headers=None) -> bytes: f"the price service did not answer ({type(e).__name__})") -# PRICE-01. The one phrase a refused token is reported with. The fallback chain -# reads it to tell "the owner must act" from "the server is down", so it is a -# constant both sides import. +# PRICE-01. The phrase a refused token is reported with; the fallback chain +# reads it. AUTH_REFUSED = "the price service refused the token" @@ -1631,24 +889,9 @@ def status_words(status: int) -> str: def check_egress(url: str, caps: ProviderCapabilities) -> str: - """§16: a plugin may only reach a host it DECLARED. Enforced, not documented. - - Returns the host, so a caller that forgets to call this is not the same - shape as one that did. - - ⚠️ THE SCHEME IS SPLIT OFF BEFORE THE HOST IS PARSED, AND THE ORDER MATTERS - FOR THE TEST MORE THAN FOR THE CODE. The first version stripped a fixed - `len("https://")` from the front, which on an `http://` URL ate two - characters of the hostname instead - `http://api.energy-charts.info/p` - became the host `pi.energy-charts.info`, which then failed the ALLOW-LIST. - So a non-TLS feed was refused, the check named "plain http is refused" - passed, and deleting the TLS rule entirely left it passing. Review of - PR #16 found that by mutation: an assertion that does not test what it - names, inside a security check - this project's own recurring defect. Now - the scheme is parsed as a scheme, the host of an `http://` URL comes out - correct and DECLARED, and the TLS rule is the only thing that can refuse - it - which is what makes the check able to fail. - """ + """16: a plugin may only reach a host it DECLARED. Returns the host. The scheme + is split off before the host is parsed (a fixed-length strip once made the TLS + rule untestable; see DECISIONS/TARIFF-02-price-provider-plugins.md).""" if not url: raise SourceUnavailable("no feed URL configured (price_feed_url)") scheme, sep, rest = url.partition("://") @@ -1671,48 +914,16 @@ def check_egress(url: str, caps: ProviderCapabilities) -> str: return host -# --------------------------------------------------------------------------- # -# 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 -# is not decoration - it is the human-readable half of `capabilities().sources`, -# and test_price.py PARSES those lines out of the docstring and compares them -# with the declaration, so a plugin whose prose and whose code disagree about -# what its feed contains is a red check rather than a 3 c/kWh error nobody sees. -# --------------------------------------------------------------------------- # +# The shipped plugins. Each docstring has the two fixed lines `SOURCES: ...` / +# `The framework ADDS: ...`; test_price.py parses them and compares with +# `capabilities().sources`. -# 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. -# --------------------------------------------------------------------------- # +# TARIFF-08: the COLUMNAR document the public EPEX feed publishes (parallel +# `unix_seconds` / `price` arrays plus `unit`). The only parser; the row parser +# it +# replaced read a shape no feed published. Two shapes, two functions, no +# sniffing. +# DECISIONS/TARIFF-02-price-provider-plugins.md. COLUMNAR_TIME_KEY = "unix_seconds" COLUMNAR_PRICE_KEY = "price" @@ -1728,46 +939,10 @@ 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. - """ + Refuses what could become a number: unequal arrays (a truncated evening peak), + a unit other than the declared one (a factor of 1000), and anything but UTC + instants. A `null` price is dropped and logged. The Brussels rendering is + `assemble()`'s. DECISIONS/TARIFF-02-price-provider-plugins.md.""" try: text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \ else str(body) @@ -1798,9 +973,8 @@ def _points_from_columnar_json(body, expect_unit, scale, f"the feed reports {doc.get(COLUMNAR_UNIT_KEY)!r}, not " f"{expect_unit!r}, so its prices cannot be converted") 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. + # Not a refusal, but 11.4 makes a dead feed look quiet, so warn while + # the source still works. _LOG.warning("The price feed says it will stop (deprecated=%r), " "though it still answers for now.", doc.get("deprecated")) out = [] @@ -1836,38 +1010,10 @@ def _points_from_columnar_json(body, expect_unit, scale, def dayahead_feed_url(plugin_id, opts, since, until, base, bzn) -> str: """The day-ahead URL for a window, or the operator's override, verbatim. - ⚠️ SHARED BY `generic_dayahead` AND `custom_formula`, ON PURPOSE - (TARIFF-15). They read the SAME feed for the same range; two copies of this - rule would be two places for the Brussels-calendar derivation and the - override decision to disagree, and the one that is wrong would be wrong - silently - §11.4 turns a short horizon into provisional hours, not into an - error. It takes `plugin_id` rather than reading one, so the framework's - AC2 rule (no plugin id in shared code) still holds here. - - ⚠️ AN OVERRIDE IS THE EXACT URL AND NOTHING IS APPENDED 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 both providers DECLARE - 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. - """ + Shared by `generic_dayahead` and `custom_formula` (TARIFF-15); takes + `plugin_id` as an argument (AC2). An override is used exactly as given and so + loses the range; that is logged at fetch time with the horizon consequence. + DECISIONS/TARIFF-02-price-provider-plugins.md.""" override = str(opts.get("price_feed_url") or "").strip() if override: _LOG.warning( @@ -1894,25 +1040,9 @@ class GenericDayAhead(PriceProvider): SOURCES: energy The framework ADDS: markup, taxes - This is the plugin the double-counting trap is about. The published figure - 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. - """ + The published figure is the wholesale clearing price, so both user-entered + components are added by the framework. Quarter-hourly since 2025-10-01; nothing + here assumes a granularity.""" id = "generic_dayahead" @@ -1927,22 +1057,8 @@ class GenericDayAhead(PriceProvider): BZN = "BE" async def fetch(self, opts, since, until, transport): - """Request the range the CALLER asked for, on the Brussels calendar. - - ⚠️ THE ARGUMENTS ARE USED, and `dayahead_feed_url()` above is where - they are turned into a URL. 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. - - ⚠️ THE URL DECISION MOVED OUT OF THIS METHOD IN TARIFF-15 AND DID NOT - CHANGE. `custom_formula` reads the same feed with the same range and - the same parser, and a second copy of the override rule would be a - second place for it to be wrong - which is the reason `covers()` - delegates to `at()` rather than restating its window. The reasoning, - the warning and the exact URL are all in `dayahead_feed_url()`. - """ + """Request the range the CALLER asked for, on the Brussels calendar + (TARIFF-09; `dayahead_feed_url()` builds the URL).""" url = dayahead_feed_url(self.id, opts, since, until, self.FEED_BASE, self.BZN) check_egress(url, self.capabilities()) @@ -1959,18 +1075,10 @@ class PriceService(PriceProvider): SOURCES: energy The framework ADDS: markup, taxes - The server answers the energy-charts layout (`unix_seconds`, `price`, - `unit`) plus `covers`, the complete Brussels days it holds. The price is - the wholesale energy price and nothing else, so markup and taxes are added - by the framework exactly as for `generic_dayahead`. - - The host is fixed here, HTTPS only, and a bearer token (the option - `price_service_token`) goes in the `Authorization` header. The token is not - put into any message this class builds. - - `FALLBACKS` names the next source down. `app/price_chain.py` walks it and - then the stored prices; this class only fetches. - """ + Answers the energy-charts layout plus `covers`, the complete Brussels days it + holds. The host is fixed here, HTTPS only; the bearer token (option + `price_service_token`) goes in the `Authorization` header and into no message. + `FALLBACKS` names the next source; `app/price_chain.py` walks it.""" id = "price_service" @@ -2018,11 +1126,7 @@ class PriceService(PriceProvider): def _covered_days(body): - """The `covers` list of a price service answer as a set, or None if absent. - - A day the server does not list as complete is not used: an hour missing - inside a day is a hole, and a hole is not a free hour. - """ + """The `covers` list of a price service answer as a set, or None. A day not listed as complete is not used.""" try: text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \ else str(body) @@ -2044,22 +1148,13 @@ class ManualCsv(PriceProvider): SOURCES: energy, markup, taxes The framework ADDS: nothing - ⚠️ THE CSV IS ALL-IN, AND DOCS.md says so. It has to be: this plugin has no - way to know what the numbers a human typed already contain, and guessing - would be the double-counting defect with a friendlier face. `load_prices()` - warns when markup/taxes are configured non-zero under a plugin that sources - them, so the ignored numbers are visible rather than silently discarded. - - ⚠️ NO TRANSPORT, NO aiohttp, NO NETWORK, NO tz DATABASE. It is the escape - hatch for a site with no internet and the ingest path the TEST-01 replay - harness will share, so it must not acquire a dependency on anything that - needs one. `transport` is accepted and deliberately unused. + The CSV is all-in (the plugin cannot know what a typed number contains), and + DOCS.md says so. No transport, aiohttp, network or tz database. Format - header required, two columns or three: start,eur_per_kwh,provisional - 2026-01-15T00:00:00+01:00,0.1834,false - """ + 2026-01-15T00:00:00+01:00,0.1834,false""" id = "manual_csv" @@ -2113,35 +1208,10 @@ class CustomFormula(PriceProvider): SOURCES: energy, markup The framework ADDS: taxes - ⚠️ IT DECLARES `{ENERGY, MARKUP}` AND NOT `ALL_IN`, AND THAT IS THE WHOLE - SAFETY ARGUMENT. A Belgian card's `Tariefformule` line is the ENERGY - component only - Eneco tabulates network tariffs and levies separately, - ENGIE states that the formula excludes network, distribution, transport, - grid levies and excise - so declaring ALL_IN would lie on behalf of every - operator who typed exactly what their card says, and would silently drop - ~12 c EUR/kWh from every hour. The framework adds `price_taxes_eur_kwh` - on top, once, and this class cannot see or apply it (trap 1). - - The risk that introduces is one no guard in this file can see, because it - is not in code: THE OPERATOR folding their network charges into `offset` - while the tax option carries them too. `app/custom_formula.py`'s card - self-check is what refuses that - the card's printed estimate is the - energy component alone, so an inflated offset moves 15.55 to ~27 and the - add-on will not start. Its limit is stated in the same breath there: it - grades the coefficients against the card, NOT the card against reality. - - ⚠️ NO EXPRESSION EVALUATION OF ANY KIND, AND THE COEFFICIENTS ARE NUMBERS - BECAUSE THE PARSER REFUSES ANYTHING ELSE. This string is settable over - HTTP through the add-on's own ingress config page, so `eval` on it would - be RCE in a root container holding SUPERVISOR_TOKEN - and, the deciding - argument, an arbitrary expression could not be graded against the card at - all. See app/custom_formula.py. - - ⚠️ THE INDEX PATH IS `generic_dayahead`'s, NOT A SECOND ONE. Same feed, - same `dayahead_feed_url()` request on the Brussels calendar, same columnar - parser, same declared egress hosts. A second parser would be a second - failure mode for a document that already has one that works. - """ + It declares `{ENERGY, MARKUP}`, not `ALL_IN`: a card's formula is the energy + component only. No expression evaluation: the coefficients are numbers because + the parser refuses anything else (the string is settable over HTTP). The index + path is `generic_dayahead`'s. DECISIONS/TARIFF-02-price-provider-plugins.md.""" id = "custom_formula" @@ -2160,11 +1230,8 @@ class CustomFormula(PriceProvider): egress_hosts=("api.energy-charts.info", "web-api.tp.entsoe.eu")) async def fetch(self, opts, since, until, transport): - """Fetch the index, apply the card's arithmetic, return RawPoints. - - ⚠️ `since`/`until` ARE USED, through the shared URL builder - see - `dayahead_feed_url()` and TARIFF-09 for what ignoring them costs. - """ + """Fetch the index, apply the card's arithmetic, return RawPoints. `since`/`until` + are used through the shared URL builder (TARIFF-09).""" try: spec = parse_price_formula(opts.get("price_formula")) except FormulaInvalid as e: @@ -2178,15 +1245,10 @@ class CustomFormula(PriceProvider): self.FEED_BASE, self.BZN) check_egress(url, self.capabilities()) body = await transport(url, float(opts.get("price_timeout_s"))) - # ⚠️ `scale=1.0`, SO THESE RawPoints CARRY EUR/**MWh** IN A FIELD - # SPELLED `eur_per_kwh`. That is a trap and it is contained on purpose: - # the list is destructured on the very next line and never leaves this - # method, so nothing downstream can read the wrong unit off it. The - # alternative - scaling to EUR/kWh and multiplying by 1000 again to - # feed the formula - is two conversions where the contract needs none, - # and every extra conversion here is another place to lose a factor of - # 1000. If this list ever DID escape, `_validate_raw()` would refuse it - # at `MAX_PLAUSIBLE_EUR_KWH` rather than price a day on it. + # `scale=1.0`, so these RawPoints carry EUR/**MWh** in a field spelled + # `eur_per_kwh`. Contained: destructured on the next line and never + # leaves this + # method (`_validate_raw()` would refuse an escape). index = _points_from_columnar_json(body, expect_unit="EUR / MWh", scale=1.0, provisional_default=False) @@ -2195,22 +1257,14 @@ class CustomFormula(PriceProvider): self.spec = spec self.index_by_instant = {t.astimezone(timezone.utc): v for t, v in pairs} - # ⚠️ `provisional=False` FOR THE SAME REASON `generic_dayahead` SAYS - # SO: the day-ahead auction is settled once published, and the columnar - # document carries no per-point flag to read one from. FCST-04's - # carry-forward is what marks the hours nobody published yet. + # `provisional=False` as for `generic_dayahead`: the auction is settled + # once published. return [RawPoint(start=t, eur_per_kwh=price_eur_kwh(spec, v), provisional=False) for t, v in pairs] def startup_preview(self, points) -> list: - """`index EUR/MWh -> all-in EUR/kWh` for the first 24 h, once. - - ⚠️ THE INDEX COLUMN IS WHY THIS HOOK EXISTS. The all-in price alone - says nothing about which of the two numbers is wrong; printed beside - the index it came from, a factor of 1000 is one glance. See - `preview_lines()` for the rows and the min/median/max/spread line. - """ + """`index EUR/MWh -> all-in EUR/kWh` for the first 24 h, once. The index beside the price makes a factor of 1000 obvious.""" if self.spec is None or not points: return [] cut = points[0].start + timedelta(hours=PREVIEW_HOURS) @@ -2225,24 +1279,16 @@ class CustomFormula(PriceProvider): return preview_lines(self.spec, rows) -# --------------------------------------------------------------------------- # # The one entry point -# --------------------------------------------------------------------------- # -# Provider ids whose startup preview has already been emitted in this process. -# ⚠️ NOT A PLUGIN NAME IN THE FRAMEWORK: it is filled at runtime from whatever -# id was resolved, and starts empty. AC2 is intact. +# Provider ids whose startup preview was already emitted in this process; +# filled at runtime. _PREVIEWED: set = set() def _emit_startup_preview(plugin, pid: str, points) -> None: - """Log a plugin's preview once per process, and never fail because of it. - - ⚠️ THE ID IS RECORDED BEFORE THE HOOK RUNS, DELIBERATELY. A preview that - raises must not be retried every 30 minutes for the life of the add-on - - that would turn a cosmetic defect into a log flood on the one path §11.4 - already makes hard to read. - """ + """Log a plugin's preview once per process, and never fail because of it. The id + is recorded before the hook runs, so a raising preview is not retried every 30 minutes.""" if pid in _PREVIEWED: return _PREVIEWED.add(pid) @@ -2262,15 +1308,9 @@ async def load_prices(opts: dict, since: datetime, until: datetime, transport=None) -> PriceRead: """Resolve the configured provider, fetch, assemble all-in, return a read. - ⚠️ RETURNS, NEVER RAISES, for every failure a site can actually have. §11.4 - makes "no price data" a degradation row, and a degradation row that arrives - as a traceback into the 30 s planning tick is a controller that stops - regulating a battery because a supplier's TLS certificate expired. - - The one exception is `FrameworkOption`, which is re-raised: that is not a - 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. - """ + RETURNS, NEVER RAISES, for any failure a site can have (11.4 is a degradation + row, not a traceback into the 30 s tick). `FrameworkOption` is re-raised: it is + a plugin reading the markup, not a site condition.""" pid = provider_id(opts) if not provider_configured(opts): return PriceRead.no_data( @@ -2285,18 +1325,14 @@ async def load_prices(opts: dict, since: datetime, until: datetime, caps = cls.capabilities() markup = float(price_option(opts, "price_markup_eur_kwh")) - # ⚠️ TARIFF-17: THE TABLE WINS OVER THE SCALAR, AND validate.py REFUSES THE - # CASE WHERE BOTH SAY SOMETHING. Two writers of one quantity, one of which - # silently loses, is the shape validate.py exists to refuse - so the - # precedence here is only ever exercised on a configuration that has - # already been accepted, and it is spelled out rather than left implicit. + # TARIFF-17: the table wins over the scalar; validate.py refuses configs + # where both speak. taxes_key = "price_taxes_eur_kwh" try: table = taxes_profile(opts) except Exception as e: # noqa: BLE001 - # Not silent, and not a traceback into the 30 s tick. validate.py - # should have refused this at startup; if the add-on is running with it - # anyway, the operator is told which key and what is wrong with it. + # Not silent: validate.py should have refused this at startup, so say + # which key is wrong. _LOG.error("price_taxes_profile cannot be used (%s: %s), so no taxes " "are added to this price update. Fix the table or clear it.", type(e).__name__, e) @@ -2311,9 +1347,8 @@ async def load_prices(opts: dict, since: datetime, until: datetime, else: taxes = float(price_option(opts, "price_taxes_eur_kwh")) - # ⚠️ Not a refusal, but not silent either. A plugin whose feed is already - # all-in ignores these two numbers, and an operator who typed them has every - # reason to believe they took effect. + # Not a refusal, but not silent: a plugin whose feed is already all-in + # ignores these two numbers. ignored = [n for c, n, v in ((Component.MARKUP, "price_markup_eur_kwh", markup), (Component.TAXES, taxes_key, taxes)) if c not in caps.adds and v]