diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 5ea10a8..158514e 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -2,6 +2,85 @@ ## Unreleased +**Price provider plugins, and one canonical all-in price format (TARIFF-02).** +`app/price.py` ships five providers - `generic_dayahead` (wholesale EPEX BE), +`engie_dynamic`, `totalenergies_dynamic`, `eneco_eddy` and the offline +`manual_csv` - all emitting exactly `{"prices": [{"start", "eur_per_kwh", +"provisional"}]}`, tz-aware at the Brussels offset, EUR/kWh, all-in. **Nothing +consumes it yet**: this is the seam, landed first, so that the arbitrage work +that follows never learns a supplier's name. + +**Double-counting the markup is refused rather than discouraged, given a +correct declaration.** A plugin does not return a price; it returns the figure +its source published and declares which of {energy, markup, taxes} that figure +contains. The framework adds the complement, once. Engie and TotalEnergies +publish a retail figure with their margin already inside; adding the configured +3 c/kWh on top would make every future arbitrage decision wrong by that amount +with nothing reporting it. + +Two mechanisms, because one was not enough. `PluginOptions` raises on the +framework-owned keys - and review of PR #16 then 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 already imports. So `register()` now also +**refuses at import any plugin whose own code mentions a framework key** +(`code_leaks()`, docstrings stripped via `ast` so a plugin's mandated +`SOURCES:` line does not false-positive). Honest wholesale baseline 0.188 +EUR/kWh; every attack landed 0.219. + +**The guarantee's precise shape**, stated because an overclaim 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 while touching no framework name, and no code +can catch that - the declaration is the plugin's unverifiable claim about +somebody else's tariff. What is mechanised is reachability; what stays human is +truthfulness. + +**The product type is declared, not inferred from the plugin's name.** +`eneco_eddy` is a real-time/imbalance product and gets widened arbitrage +thresholds; that reaches a consumer through `capabilities().product` and +`volatility_regime()`, never through a name comparison. `test_price.py` asserts +mechanically that no plugin id appears anywhere in the framework's own code, +and drives a sixth provider defined inside the test file to the correct regime +with `price.py` untouched. + +**"No price data" is a type, not an empty list.** Following the same rework +`sim/riglock.py` took, a fetch returns `PriceRead.series(...)` or +`PriceRead.no_data(reason)`, and only the first carries fields. An empty series +is not constructible; `no_data` has no `.points` to iterate, no `.at()`, and +`.get("points", [])` raises rather than returning the default. **A day of free +electricity and a day with no prices are different instructions** - arbitrage is +pointless under the first and forbidden under the second - and an implementation +that returns `[]` on a failed fetch cannot tell them apart. Every site-level +failure returns `no_data`; nothing raises into the control tick. + +**What is not verified, and cannot be by an offline suite:** every fixture is +synthetic, so the five feed URLs and JSON shapes are checked against a belief +about what those suppliers publish, not against the feeds. The parse layer is +exercised; the contract with each supplier is not. Expect the first live fetch +of each to need a shape fix, and note that a wrong shape degrades to +no-price-data rather than to a wrong price. + +**`manual_csv` is the documented offline fallback** and needs no network, no +supplier account and no HTTP stack. See DOCS.md for the format. Its `start` +column must carry a UTC offset - a bare local timestamp is refused, because one +hour every October names two moments. + +**Injection is priced separately** (`export_price_eur_kwh`), a scalar rather +than a second series, and negative values are accepted. Consumption plugins +cannot reach it. + +**`price_provider` may not be combined with `price_now_entity` / +`price_avg_entity`** - the add-on refuses to start on that pair. The Home +Assistant sensors are the existing delegation of pricing to HA and still work +alone; with both configured they would silently keep deciding while the status +page named a supplier that decided nothing. + +**No timezone database is used.** The shipped Alpine image has no `tzdata`, so +Brussels offsets are computed from the EU rule and every instant is carried as +UTC and rendered at the offset that applies to it. The checks drive both 2026 +transitions - the 23-hour and 25-hour days - rather than a normal day, where a +fixed 24-slot implementation and a correct one agree. + **The capacity-tariff peak is now measured, banked and survives a restart (TARIFF-01).** New `app/tariff.py`. The 15-minute block average itself was already built and unwired - `p1.QuarterAverager` computes clock-aligned 900 s diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 30b4566..4a5842c 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -581,6 +581,97 @@ unchanged is 12 W, which is inside the 15 W deadband — so the command there is parked at 0 W and the two halves of the rule cannot both be true. At the schema floor of 60 s that figure is 60 W and only the margin is left. +### Energy price providers + +> **Nothing in 0.5.x acts on a price.** This release ships the *plumbing*: the +> five provider plugins, the canonical all-in format they emit, and the config +> that selects one. The arbitrage strategy that would use it is a later ticket. +> Turning a provider on today fetches, validates and publishes prices and +> changes no charging decision. That is deliberate — the seam lands first. + +| option | meaning | +|---|---| +| `price_provider` | `off` (default) or one of the five plugins below | +| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** | +| `price_taxes_eur_kwh` | Levies and VAT, EUR/kWh. Same rule | +| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below | +| `price_csv_path` | Where `manual_csv` reads its file | +| `price_feed_url` | Override the plugin's default feed. Only a host the plugin itself declares is accepted | +| `price_horizon_h` | How far ahead to ask for, hours | +| `price_timeout_s` | Per-request timeout | + +**Which plugin sources what.** This is the one thing to get right, because +adding your markup to a price that already contains it makes every future +arbitrage decision wrong by that amount, in a direction nothing reports. + +| plugin | product | the published figure contains | the add-on adds | +|---|---|---|---| +| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes | +| `engie_dynamic` | day-ahead | energy + Engie's markup | taxes | +| `totalenergies_dynamic` | day-ahead | energy + TotalEnergies' markup | taxes | +| `eneco_eddy` | **real-time / imbalance** | everything, all-in | nothing | +| `manual_csv` | day-ahead | everything, all-in | nothing | + +You cannot get this wrong by editing the markup: each plugin *declares* what its +feed contains and the add-on adds only the rest. Set a markup under a plugin +that already carries one and it is **ignored, with a line in the log saying so** +— never applied twice. + +That holds **given a correct declaration**, and it is worth knowing where the +line is. What the add-on enforces mechanically is that a plugin cannot obtain +the markup or apply it: the numbers are not reachable from plugin code, and a +plugin whose code mentions them fails to load at all. What it cannot check is +whether a plugin described its supplier's feed truthfully — that is a claim +about somebody else's tariff, and no code can verify it. If you write a plugin, +the `sources` declaration is the part a reviewer has to read. + +`eneco_eddy` is a real-time/imbalance product rather than a day-ahead one. It is +flagged as high-volatility, which widens arbitrage thresholds once the +optimiser lands. That comes from the plugin's own declaration, not from its +name. + +**`manual_csv` — the offline fallback.** It needs no internet, no supplier +account and no HTTP stack at all, and it is the supported way to run this +add-on price-aware on a site with no outbound connectivity. Write a file and +point `price_csv_path` at it: + +```csv +start,eur_per_kwh,provisional +2026-01-15T00:00:00+01:00,0.1834,false +2026-01-15T01:00:00+01:00,-0.0210,true +2026-01-15T02:00:00+01:00,0.3115,false +``` + +- `start` **must carry its UTC offset** (`+01:00` in winter, `+02:00` in + summer). A bare `2026-01-15 00:00` is refused, because for one hour every + October a local wall clock names two different moments. +- `eur_per_kwh` is **all-in** — energy *plus* markup *plus* taxes. This plugin + has no way to know what your numbers already include, so it assumes + everything and adds nothing. +- Negative prices are valid and are kept. They are the best charging hours of + the year. +- `provisional` is optional; omit the column and every hour is treated as + settled. + +**When there are no prices at all** — provider off, feed unreachable, CSV +missing — the add-on does not guess. It reports *no price data*, which is a +distinct state from "prices, all of them zero": the plan is marked provisional +and arbitrage is disabled, per the degradation matrix. It never falls back to a +price of zero, which would read as free electricity. + +**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is +often near zero, which by itself strongly favours self-consumption over export. +It is a single number rather than a curve because no supported supplier +publishes a dynamic injection tariff; negative values are accepted. + +**The old Home Assistant price sensors.** `price_now_entity` and +`price_avg_entity` still work and are unchanged: the add-on compares them and +uses the result as a cheap-window hint for the maintenance charge. They are +**not compatible with `price_provider`** — configure both and the add-on +refuses to start, naming the conflict, because the Home Assistant pair would +silently keep deciding while the status page named your supplier. Use one or +the other. + ### Site | option | meaning | diff --git a/goodwe_controller/app/price.py b/goodwe_controller/app/price.py new file mode 100644 index 0000000..7ac7978 --- /dev/null +++ b/goodwe_controller/app/price.py @@ -0,0 +1,1127 @@ +"""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. + +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: + + { "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). + + +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. + `eneco_eddy` is a real-time / imbalance product (§9.2) and §8.3 says those + are treated as high-volatility with widened arbitrage thresholds. If the + optimiser has to write `if provider_id == "eneco_eddy"` to know that, then + adding a sixth supplier means editing the optimiser - which is precisely + what AC2 forbids. So `capabilities().product` is a `PriceProduct`, it rides + along on the series, and `volatility_regime()` reads the enum. A plugin id + appears nowhere in this file except in `PROVIDERS` and in the class that + answers to it. + +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. +""" + +import ast +import csv +import inspect +import io +import json +import logging +import math +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from enum import Enum +from textwrap import dedent + +_LOG = logging.getLogger("goodwe.price") + + +# --------------------------------------------------------------------------- # +# Brussels wall time, without a tz database. See the module docstring. +# --------------------------------------------------------------------------- # + +CET = timezone(timedelta(hours=1)) +CEST = timezone(timedelta(hours=2)) + + +def _last_sunday(year: int, month: int) -> int: + """Day-of-month of the last Sunday. March and October both have 31 days.""" + day = 31 + while datetime(year, month, day).weekday() != 6: # 6 == Sunday + day -= 1 + return day + + +def brussels_offset(instant: datetime) -> timezone: + """The offset Belgium is on at this UTC instant. CET or CEST, never else.""" + u = instant.astimezone(timezone.utc) + y = u.year + start = datetime(y, 3, _last_sunday(y, 3), 1, tzinfo=timezone.utc) + end = datetime(y, 10, _last_sunday(y, 10), 1, tzinfo=timezone.utc) + return CEST if start <= u < end else CET + + +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. + """ + if instant.tzinfo is None or instant.utcoffset() is None: + raise ValueError( + f"brussels({instant!r}): a naive datetime is not an instant. Parse " + f"the source's offset, or say UTC explicitly - guessing here is how " + f"one hour of a transition day gets priced twice.") + return instant.astimezone(brussels_offset(instant)) + + +# --------------------------------------------------------------------------- # +# Absence, borrowed wholesale from sim/riglock.py (TEST-04). +# --------------------------------------------------------------------------- # + +class Absence(Exception): + """A value was asked of a read that never observed one.""" + + +class FrameworkOption(Exception): + """A plugin reached for an option the FRAMEWORK owns. See trap 1.""" + + +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. + """ + + +class _Outcome: + """One outcome of one read. The absent variant CARRIES NO FIELDS.""" + + kind = "?" + words = "?" # human phrasing. Cosmetic, never an input to a decision. + + def __getattr__(self, name): + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + raise Absence( + f"{type(self).__name__} has no {name!r}. This read did not observe " + f"a price series, and a value invented here is the bug this " + f"representation exists to prevent - 'no data' becoming 'free'. " + f"Handle the {self.kind!r} case explicitly; see match().") + + def get(self, *_a, **_k): + raise Absence( + f"{type(self).__name__}.get() does not exist: a default is how a " + f"failed fetch becomes an observation. Use .match(...) and handle " + 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. + 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 " + f"expensive'. Use .match(series=..., no_data=...).") + + def __repr__(self): + 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).""" + ENERGY = "energy" + MARKUP = "markup" + TAXES = "taxes" + + +ALL_IN = frozenset(Component) + + +class PriceProduct(Enum): + """The §9.2 product table. The optimiser switches on THIS, never on a name.""" + DAY_AHEAD = "dynamic_dayahead" + REALTIME = "realtime_imbalance" + STATIC = "static_profile" + 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. +_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. + """ + 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" + + +@dataclass(frozen=True) +class PricePoint: + """One canonical point. THREE FIELDS. §9.3 lists three; this has three.""" + + start: datetime # tz-aware, rendered at the Brussels offset + eur_per_kwh: float # ALL-IN: energy + markup + taxes + provisional: bool + + def canonical(self) -> dict: + return {"start": self.start.isoformat(), + "eur_per_kwh": self.eur_per_kwh, + "provisional": self.provisional} + + +@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()`. + """ + + start: datetime # tz-aware instant + eur_per_kwh: float # as published, containing exactly caps.sources + provisional: bool + + +@dataclass(frozen=True) +class ProviderCapabilities: + """What a plugin declares about itself, before anyone asks it for prices.""" + + product: PriceProduct + sources: frozenset # which Components the published figure contains + horizon_h: int = 48 # aligns with the §8.1/§8.2 forecast horizon + needs_credentials: bool = False + egress_hosts: tuple = () # §16 allow-list, declared not discovered + + def __post_init__(self): + if not isinstance(self.product, PriceProduct): + raise ValueError(f"product must be a PriceProduct, not " + f"{self.product!r}") + if Component.ENERGY not in self.sources: + raise ValueError( + "capabilities().sources must contain Component.ENERGY - a feed " + "that does not publish an energy price is not a price feed.") + extra = set(self.sources) - set(ALL_IN) + if extra: + raise ValueError(f"unknown price components declared: {extra}") + + @property + def adds(self) -> frozenset: + """The components the FRAMEWORK must add. The complement of `sources`.""" + 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. + """ + + 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. + """ + return {"series": series, "no_data": no_data}[self.kind](self) + + @staticmethod + def series(points, product, provider, fetched_at): + return _Series(points, product, provider, fetched_at) + + @staticmethod + def no_data(reason): + if not reason or not isinstance(reason, str): + raise ValueError( + "PriceRead.no_data() takes a reason a human can act on. It is " + "the only field this variant has; an empty one makes an " + "operator's only evidence a blank status line.") + return _NoData(reason) + + +class _Series(PriceRead): + kind = "series" + + 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. + if not points: + raise ValueError( + "PriceRead.series() refuses an empty point list. A fetch that " + "produced no points is PriceRead.no_data(reason) - a different " + "variant with different fields, on purpose. See §11.4.") + if not isinstance(product, PriceProduct): + raise ValueError( + f"product must be a PriceProduct, not {product!r}. The " + f"optimiser switches on this enum instead of on a plugin name; " + f"a string here reopens exactly the hardcoding AC2 forbids.") + self.points = tuple(points) + self.product = product + self.provider = provider + self.fetched_at = fetched_at + self.regime = volatility_regime(product) + self.words = (f"{len(self.points)} points from {provider} " + f"({product.value}, {self.regime} thresholds)") + + def canonical(self) -> dict: + """The §9.3 wire format. Exactly `{"prices": [...]}`.""" + return {"prices": [p.canonical() for p in self.points]} + + def at(self, instant: datetime) -> PricePoint: + """The point covering `instant`. Raises `Absence` if the series does not. + + ⚠️ 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. + """ + u = instant.astimezone(timezone.utc) + for p in self.points: + s = p.start.astimezone(timezone.utc) + if s <= u < s + timedelta(hours=1): + return p + raise Absence( + f"no price point covers {u.isoformat()}; the series runs " + f"{self.points[0].start.isoformat()} .. " + f"{self.points[-1].start.isoformat()}. An hour outside the series " + f"is an hour with no price, which is §11.4 for that hour - not a " + f"reason to reach for a neighbouring one.") + + def covers(self, instant: datetime) -> bool: + try: + self.at(instant) + return True + except Absence: + return False + + def window(self, since: datetime, until: datetime) -> tuple: + """Points whose start falls in [since, until). May legitimately be empty.""" + a = since.astimezone(timezone.utc) + b = until.astimezone(timezone.utc) + return tuple(p for p in self.points + if a <= p.start.astimezone(timezone.utc) < b) + + +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. + """ + + kind = "no_data" + + def __init__(self, reason): + self.reason = reason + self.words = f"no price data - {reason}" + + +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. + """ + 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.""" + 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 +# --------------------------------------------------------------------------- # + +# ⚠️ 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. +PRICE_OPTION_DEFAULTS = { + "price_provider": "off", + "price_markup_eur_kwh": 0.03, # §14 tariffs.energy_price.markup_eur_kwh + "price_taxes_eur_kwh": 0.06, # §14 tariffs.energy_price.taxes_eur_kwh + "export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4) + "price_csv_path": "/share/goodwe_prices.csv", + "price_feed_url": "", + "price_horizon_h": 48, + "price_timeout_s": 10, +} + +# ⚠️ 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. +FRAMEWORK_KEYS = frozenset({ + "price_markup_eur_kwh", "price_taxes_eur_kwh", "export_price_eur_kwh", +}) + + +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. + """ + + def __init__(self, opts: dict): + self._o = dict(opts or {}) + + def get(self, key, default=None): + if key in FRAMEWORK_KEYS: + raise FrameworkOption( + f"{key!r} belongs to the framework, not to a plugin. A plugin " + f"returns RawPoints carrying what its SOURCE published and " + f"declares capabilities().sources; assemble() adds the missing " + f"components exactly once. If a plugin could read this number " + f"it could add it a second time, and the resulting price would " + f"be wrong by one markup with nothing anywhere reporting it.") + v = self._o.get(key, None) + if v is None or v == "": + return PRICE_OPTION_DEFAULTS.get(key, default) + return v + + def __getitem__(self, key): + return self.get(key) + + def __contains__(self, key): + return key not in FRAMEWORK_KEYS and key in self._o + + +def price_option(opts: dict, key: str): + """A framework-side option read. One place, defaults from one dict.""" + if key not in PRICE_OPTION_DEFAULTS: + raise KeyError(f"{key!r} is not a price option; add it to " + f"PRICE_OPTION_DEFAULTS and to config.yaml together.") + v = (opts or {}).get(key, None) + if v is None or v == "": + return PRICE_OPTION_DEFAULTS[key] + return v + + +def export_price_eur_kwh(opts: dict) -> float: + """AC4 / §9.3: the injection price is a SEPARATE SCALAR, not a second series. + + In Belgium it is often near zero, which by itself favours self-consumption + over export. It is deliberately not routed through the plugin framework: no + shipped supplier publishes a dynamic injection curve, and inventing the + shape for one would be a second canonical format nobody emits. When one + appears it gets a `PriceProvider` of its own and this scalar becomes its + FIXED-product default - the seam is already the right shape for that. + + ⚠️ 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. + """ + return float(price_option(opts, "export_price_eur_kwh")) + + +# --------------------------------------------------------------------------- # +# 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. + """ + + id = "?" + + @classmethod + @abstractmethod + def capabilities(cls) -> ProviderCapabilities: + ... + + @abstractmethod + async def fetch(self, opts, since: datetime, until: datetime, + 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. + """ + + +# ⚠️ 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. +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 `engie_dynamic` says "markup" in prose and would false-positive + against `price_markup_eur_kwh` on a raw text scan. The check that fires on + the documentation it also mandates is the check that gets deleted. So the + 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 all five + shipped plugins, so a build that silently lost it is a red check, not a + quietly disarmed guard. + """ + try: + src = dedent(inspect.getsource(cls)) + tree = ast.parse(src) + except (OSError, TypeError, IndentationError, SyntaxError) as e: + _LOG.warning("could not read %s's source for the framework-key scan " + "(%s); the double-counting guard is NOT enforced for it", + getattr(cls, "id", cls), e) + return [] + for node in ast.walk(tree): + body = getattr(node, "body", None) + if isinstance(body, list) and body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) and isinstance(body[0].value.value, str): + body.pop(0) + code = ast.unparse(tree) + return sorted({name for name in BANNED_IN_PLUGIN_CODE if name in code}) + + +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. + """ + if cls.id in PROVIDERS: + raise ValueError(f"duplicate provider id {cls.id!r}") + 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. + raise FrameworkOption( + f"{cls.id!r} mentions {', '.join(leaks)} in its own code. A plugin " + f"returns what its SOURCE published and declares " + f"capabilities().sources; the framework adds the complement, once. " + f"Reaching any of {BANNED_IN_PLUGIN_CODE} lets it be added twice, " + f"and a price that is one markup high is invisible in every log " + f"this add-on writes. Remove the reference; if the plugin genuinely " + f"needs a per-site number, add a NON-framework option key for it.") + PROVIDERS[cls.id] = cls + return 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. + """ + if not isinstance(caps, ProviderCapabilities): + raise SourceUnavailable("the plugin did not declare capabilities") + if not points: + raise SourceUnavailable("the source returned no points") + seen = None + for p in points: + if not isinstance(p, RawPoint): + raise SourceUnavailable(f"a plugin returned {type(p).__name__}, not " + f"a RawPoint - see PriceProvider.fetch()") + if p.start.tzinfo is None or p.start.utcoffset() is None: + raise SourceUnavailable(f"point at {p.start!r} has no offset") + if isinstance(p.eur_per_kwh, bool) or \ + not isinstance(p.eur_per_kwh, (int, float)) or \ + not math.isfinite(p.eur_per_kwh): + raise SourceUnavailable(f"point at {p.start.isoformat()} has a " + f"non-finite price {p.eur_per_kwh!r}") + if abs(p.eur_per_kwh) > MAX_PLAUSIBLE_EUR_KWH: + raise SourceUnavailable( + f"point at {p.start.isoformat()} is {p.eur_per_kwh} EUR/kWh, " + f"beyond +-{MAX_PLAUSIBLE_EUR_KWH} - the usual cause is a feed " + f"in EUR/MWh being read as EUR/kWh, which is a factor of 1000 " + f"and would make every hour look like an emergency") + u = p.start.astimezone(timezone.utc) + if seen is not None and u <= seen: + raise SourceUnavailable( + f"points are not strictly increasing at {u.isoformat()} - a " + f"duplicated hour on a DST day is the usual cause, and keying " + f"prices by a naive local hour is how it happens") + seen = u + + +def assemble(caps: ProviderCapabilities, raw, markup: float, + taxes: float) -> 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. + """ + add = 0.0 + if Component.MARKUP in caps.adds: + add += float(markup) + if Component.TAXES in caps.adds: + add += float(taxes) + return [PricePoint(start=brussels(p.start), + eur_per_kwh=round(p.eur_per_kwh + add, 6), + provisional=bool(p.provisional)) + for p in raw] + + +# --------------------------------------------------------------------------- # +# Transport - one place, HTTPS only, host checked against the declaration +# --------------------------------------------------------------------------- # + +async def https_get(url: str, timeout_s: float) -> 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. + """ + import aiohttp + t = aiohttp.ClientTimeout(total=float(timeout_s)) + async with aiohttp.ClientSession(timeout=t) as s: + async with s.get(url) as r: + r.raise_for_status() + return await r.read() + + +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. + """ + if not url: + raise SourceUnavailable("no feed URL configured (price_feed_url)") + scheme, sep, rest = url.partition("://") + if not sep: + raise SourceUnavailable( + f"not an absolute URL, so no host can be checked against the " + f"§16 allow-list: {url!r}") + if scheme.lower() != "https": + raise SourceUnavailable( + f"refusing a non-TLS price feed: {url!r} uses {scheme!r}. §16 " + f"allow-lists outbound destinations and SEC-01 requires TLS on " + f"every one of them.") + host = rest.split("/", 1)[0].split("?", 1)[0].split("@")[-1] + host = host.split(":", 1)[0].lower() + if host not in {h.lower() for h in caps.egress_hosts}: + raise SourceUnavailable( + f"{host!r} is not in this provider's declared egress_hosts " + f"{caps.egress_hosts} - §16 allow-lists outbound destinations, and " + f"a URL from the options file is not a declaration") + return host + + +# --------------------------------------------------------------------------- # +# The five 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. +# --------------------------------------------------------------------------- # + +def _points_from_json(body, price_key, scale, provisional_default): + """Shared parse for the three JSON feeds. Shape differences are the args.""" + try: + text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \ + else str(body) + doc = json.loads(text) + except (ValueError, UnicodeDecodeError) as e: + raise SourceUnavailable(f"the feed did not parse as JSON: {e}") + rows = doc.get("data") if isinstance(doc, dict) else doc + if not isinstance(rows, list): + raise SourceUnavailable("the feed carried no 'data' list") + out = [] + for row in rows: + try: + start = datetime.fromisoformat(str(row["start"])) + value = float(row[price_key]) * scale + except (KeyError, TypeError, ValueError) as e: + raise SourceUnavailable(f"malformed row {row!r}: {e}") + if start.tzinfo is None: + raise SourceUnavailable( + f"row {row['start']!r} carries no UTC offset; this feed is " + f"required to publish absolute instants") + out.append(RawPoint(start=start, eur_per_kwh=value, + provisional=bool(row.get("provisional", + provisional_default)))) + return out + + +@register +class GenericDayAhead(PriceProvider): + """Public EPEX BE day-ahead feed, in EUR/MWh. + + 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. + """ + + id = "generic_dayahead" + + @classmethod + def capabilities(cls): + return ProviderCapabilities( + product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY}), + egress_hosts=("api.energy-charts.info", "web-api.tp.entsoe.eu")) + + async def fetch(self, opts, since, until, transport): + url = opts.get("price_feed_url") or \ + "https://api.energy-charts.info/price?bzn=BE" + check_egress(url, self.capabilities()) + body = await transport(url, float(opts.get("price_timeout_s"))) + return _points_from_json(body, "price_eur_mwh", 0.001, False) + + +@register +class EngieDynamic(PriceProvider): + """Engie's dynamic day-ahead product, published in EUR/kWh. + + SOURCES: energy, markup + The framework ADDS: taxes + + ⚠️ Engie's published figure ALREADY CONTAINS THEIR MARKUP - it is a retail + price, not a wholesale one. Adding `price_markup_eur_kwh` on top of it is + the defect this ticket's design exists to make unreachable; the declaration + above is what stops it, and nothing here needs to remember the rule. + """ + + id = "engie_dynamic" + + @classmethod + def capabilities(cls): + return ProviderCapabilities( + product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY, Component.MARKUP}), + needs_credentials=True, + egress_hosts=("api.engie.be",)) + + async def fetch(self, opts, since, until, transport): + url = opts.get("price_feed_url") or "https://api.engie.be/dynamic/be" + check_egress(url, self.capabilities()) + body = await transport(url, float(opts.get("price_timeout_s"))) + return _points_from_json(body, "price_eur_kwh", 1.0, False) + + +@register +class TotalEnergiesDynamic(PriceProvider): + """TotalEnergies' dynamic day-ahead product, published in EUR/kWh. + + SOURCES: energy, markup + The framework ADDS: taxes + + Same retail shape as Engie: the supplier's own margin is inside the number. + """ + + id = "totalenergies_dynamic" + + @classmethod + def capabilities(cls): + return ProviderCapabilities( + product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY, Component.MARKUP}), + needs_credentials=True, + egress_hosts=("api.totalenergies.be",)) + + async def fetch(self, opts, since, until, transport): + url = opts.get("price_feed_url") or \ + "https://api.totalenergies.be/prices/dynamic" + check_egress(url, self.capabilities()) + body = await transport(url, float(opts.get("price_timeout_s"))) + return _points_from_json(body, "price_eur_kwh", 1.0, False) + + +@register +class EnecoEddy(PriceProvider): + """Eneco Eddy / Eddy Flex - a REAL-TIME / IMBALANCE product, all-in EUR/MWh. + + SOURCES: energy, markup, taxes + The framework ADDS: nothing + + ⚠️ THE PRODUCT TYPE IS THE INTERESTING FIELD HERE, NOT THE PRICE. §9.2 + classes Eddy Flex as real-time / imbalance and §8.3 says those get widened + arbitrage thresholds. That regime reaches the optimiser through + `capabilities().product` -> `_Series.product` -> `volatility_regime()`. If + it reached the optimiser through the string "eneco_eddy" instead, then the + sixth supplier would need an optimiser edit and AC2 would be dead. This + class is the reason the enum exists. + + Imbalance prices are settled after the fact, so the current quarter's figure + is genuinely provisional and the feed says so per point. + """ + + id = "eneco_eddy" + + @classmethod + def capabilities(cls): + return ProviderCapabilities( + product=PriceProduct.REALTIME, + sources=ALL_IN, + horizon_h=24, + needs_credentials=True, + egress_hosts=("api.eneco.be",)) + + async def fetch(self, opts, since, until, transport): + url = opts.get("price_feed_url") or "https://api.eneco.be/eddy/prices" + check_egress(url, self.capabilities()) + body = await transport(url, float(opts.get("price_timeout_s"))) + return _points_from_json(body, "price_eur_mwh", 0.001, True) + + +@register +class ManualCsv(PriceProvider): + """A file on disk. AC3: the documented fallback, fully offline. + + 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. + + Format - header required, two columns or three: + + start,eur_per_kwh,provisional + 2026-01-15T00:00:00+01:00,0.1834,false + """ + + id = "manual_csv" + + @classmethod + def capabilities(cls): + return ProviderCapabilities( + product=PriceProduct.DAY_AHEAD, + sources=ALL_IN, + egress_hosts=()) # declared empty: this one reaches nothing + + async def fetch(self, opts, since, until, transport): + path = opts.get("price_csv_path") + try: + with open(path, "r", encoding="utf-8") as fh: + text = fh.read() + except OSError as e: + raise SourceUnavailable(f"cannot read {path}: {e}") + return self.parse(text) + + @staticmethod + def parse(text: str) -> list: + """Split out so the offline check can drive it without a filesystem.""" + rdr = csv.DictReader(io.StringIO(text)) + if not rdr.fieldnames or "start" not in rdr.fieldnames or \ + "eur_per_kwh" not in rdr.fieldnames: + raise SourceUnavailable( + f"the CSV needs a header with at least 'start' and " + f"'eur_per_kwh'; found {rdr.fieldnames!r}") + out = [] + for n, row in enumerate(rdr, start=2): + try: + start = datetime.fromisoformat((row["start"] or "").strip()) + value = float((row["eur_per_kwh"] or "").strip()) + except (TypeError, ValueError) as e: + raise SourceUnavailable(f"CSV line {n}: {e}") + if start.tzinfo is None: + raise SourceUnavailable( + f"CSV line {n}: {row['start']!r} has no UTC offset. Write " + f"'2026-01-15T00:00:00+01:00', not '2026-01-15 00:00' - a " + f"local wall clock is ambiguous for one hour every October.") + flag = (row.get("provisional") or "false").strip().lower() + out.append(RawPoint(start=start, eur_per_kwh=value, + provisional=flag in ("1", "true", "yes"))) + return out + + +# --------------------------------------------------------------------------- # +# The one entry point +# --------------------------------------------------------------------------- # + +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. + """ + pid = str(price_option(opts, "price_provider") or "off").strip() + if pid in ("", "off"): + return PriceRead.no_data( + "no price provider configured (price_provider is 'off'); " + "§11.4: static profile, plan provisional, no arbitrage") + cls = PROVIDERS.get(pid) + if cls is None: + return PriceRead.no_data( + f"unknown price provider {pid!r}; shipped: " + f"{', '.join(sorted(PROVIDERS))}") + + caps = cls.capabilities() + markup = float(price_option(opts, "price_markup_eur_kwh")) + 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. + ignored = [n for c, n, v in ((Component.MARKUP, "price_markup_eur_kwh", markup), + (Component.TAXES, "price_taxes_eur_kwh", taxes)) + if c not in caps.adds and v] + if ignored: + _LOG.warning("%s publishes a figure that already contains %s, so the " + "configured %s ignored - adding them would double-count", + pid, + "/".join(sorted(c.value for c in + caps.sources - {Component.ENERGY})), + " and ".join(ignored) + + (" is" if len(ignored) == 1 else " are")) + + if transport is None: + transport = https_get + try: + raw = await cls().fetch(PluginOptions(opts), since, until, transport) + _validate_raw(raw, caps) + points = assemble(caps, raw, markup, taxes) + except FrameworkOption: + raise # a plugin bug, not a site condition + except SourceUnavailable as e: + return PriceRead.no_data(f"{pid}: {e}") + except Exception as e: # noqa: BLE001 - see the docstring + return PriceRead.no_data(f"{pid}: {type(e).__name__}: {e}") + + return PriceRead.series(points, caps.product, pid, + datetime.now(timezone.utc)) diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index b17b449..c5833a6 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -116,6 +116,21 @@ NOT READY banner already names the problem, (b) does not apply. (13.8 kWh usable at 2000 W is ~6.9 h against the 720 min DRAIN_TIMEOUT). It is warned about below, not refused. + 7. A price configuration that cannot mean what it says. [(a)] + Three cases, all in `_price_problems()`: an unknown `price_provider` id + (refused rather than defaulted to 'off', per the §14 warning box); + `price_provider` set alongside `price_now_entity`/`price_avg_entity`, + which is TWO PRICE SOURCES ON ONE SITE where the Home Assistant pair + silently wins; and `manual_csv` with no `price_csv_path`, which turns the + documented offline fallback into a permanent no-price-data condition + reached by accident. Same shape as 1, 4 and 5 every time - a source the + operator chose, overridden by a layer they cannot see. + + ⚠️ `price_provider: "off"` IS NOT REFUSED AND MUST NOT BECOME SO. §11.4 + makes no-price-data a supported state, and §9.2 says a large share of the + installed base is on a fixed or static tariff. Refusing to start without a + price feed would lock those sites out of the add-on entirely. + WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe: - `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the @@ -141,6 +156,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe: import logging from .p1 import is_enabled, validate_meter_options +from .price import PROVIDERS _LOG = logging.getLogger("goodwe.config") @@ -349,6 +365,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str] f"two numbers. Lower maintenance_charge_w to {max_w:.0f} W or less, " f"or raise max_w to at least {charge_w:.0f} W.") + # --- 7. two price sources on one site ------------------------------------ + problems.extend(_price_problems(opts)) + # --- warnings: real, but not worth refusing over ------------------------- integ = opts.get("integrator_max_w") integ_w = None if not integ else _number(opts, "integrator_max_w", None) @@ -382,6 +401,75 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str] return problems +def _price_problems(opts: dict) -> list[str]: + """Rule 7 (TARIFF-02): the price configuration, refused where it is untrue. + + ⚠️ NOTE WHAT IS *NOT* HERE. `price_provider: "off"` is not refused and never + will be: §11.4 makes no-price-data a first-class supported state (static + profile, provisional plan, no arbitrage), and an add-on that will not start + without a price feed is an add-on that a Belgian fixed-tariff site - a large + share of the installed base, per §9.2 - cannot run at all. + + Three refusals, all case (a) from this module's docstring: a source the + operator SET being overridden by a layer they cannot see. + """ + problems: list[str] = [] + pid = str(opts.get("price_provider", "") or "off").strip() or "off" + + if pid != "off" and pid not in PROVIDERS: + problems.append( + f"price_provider is {pid!r}, which is not a shipped plugin. The " + f"ids that exist are: off, {', '.join(sorted(PROVIDERS))}. " + "Refused rather than defaulted, per the §14 warning box - a " + "silent fall back to 'off' would look exactly like a working " + "provider that happens never to find a cheap hour.") + return problems # nothing below can be judged against an unknown id + + # ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE. + # price_now_entity/price_avg_entity are the existing delegation of pricing + # to Home Assistant: main.py reduces them to ONE BOOLEAN, `cheap`, read only + # by the maintenance cheap-window charge floor. They are KEPT (TARIFF-03 and + # OPT-04 are what replace them, and neither has landed), but they may not + # run alongside a provider. With both configured the provider series drives + # nothing at all while the two entities quietly keep deciding, and the + # operator's evidence that they picked a supplier is a status line saying + # they picked a supplier. That is (a), and it is the "silently double up" + # the PM named on 2026-08-30. + # ⚠️ THIS REFUSAL'S REASON EXPIRES, AND THE MESSAGE BELOW MUST BE EDITED IN + # THE SAME COMMIT THAT LANDS THE FIRST CONSUMER (TARIFF-03 or OPT-04). + # Today the message says the provider "would decide nothing" - true, because + # nothing reads the series. Once something does, both sources really decide + # and this becomes a straightforward two-writer conflict; the refusal stays + # right and its explanation goes wrong, which is worse than either. There is + # a tripwire on exactly this in test_price.py section 12: it greps app/ for + # a `load_prices` consumer and goes RED the moment one appears. Update the + # message, not the check. + ha_price = [k for k in ("price_now_entity", "price_avg_entity") + if str(opts.get(k, "") or "").strip()] + if pid != "off" and ha_price: + problems.append( + f"price_provider is {pid!r} AND {' and '.join(ha_price)} " + f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price " + "sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are " + "what main.py reduces to the `cheap` flag the maintenance cycle " + "reads, and nothing yet consumes the provider series, so the " + "supplier you configured would decide nothing while the status " + "page named it. Clear price_now_entity and price_avg_entity to use " + f"the {pid!r} plugin, or set price_provider to 'off' to keep the " + "Home Assistant sensors.") + + if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip(): + problems.append( + "price_provider is 'manual_csv' but price_csv_path is empty, so " + "the offline fallback has no file to read. Every fetch would " + "return no-price-data with a filesystem error as its reason - " + "which is §11.4 behaviour arrived at by accident rather than by " + "configuration. Point it at the CSV, or set price_provider to " + "'off', which says the same thing on purpose.") + + return problems + + def _number(opts: dict, key, default): raw = opts.get(key, default) if raw is None or raw == "": diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index d7b3d71..0e08117 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -162,9 +162,53 @@ options: # --- tariff / capacity tariff (all optional) ------------------------------ peak_forecast_entity: "" peak_cap_w: 3500 + + # ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the + # add-on delegating the whole price question to Home Assistant: main.py reads + # both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by + # nothing except the maintenance cycle's cheap-window charge floor. That is + # all they have ever done. They are kept, because retiring them would take + # the maintenance cycle's only price signal away on a live install before + # anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage) + # are the cards that replace it, and neither has landed. + # + # ⚠️ They may NOT be combined with price_provider below. Two price sources on + # one site, one of which silently wins, is exactly the "double up" the PM + # named; validate.py rule 7 refuses that configuration at startup rather than + # letting an operator believe the provider they configured is driving + # something. See app/validate.py. price_now_entity: "" price_avg_entity: "" + # --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) -------------- + # `off` is the shipped default and means the §11.4 no-price-data row applies: + # static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes + # the series yet - OPT-04 is the consumer - so turning this on today fetches, + # validates and publishes prices and changes no energy decision. + # + # ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's + # PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two + # agree key for key. Do not add a third copy in main.py - CFG-03 is open + # because that has already happened three times. + # + # markup/taxes are the §14 `tariffs.energy_price.*` figures and are added by + # the FRAMEWORK, only for the components the chosen plugin declares its feed + # does not already contain. engie_dynamic, totalenergies_dynamic, eneco_eddy + # and manual_csv publish a figure that already carries the markup, so setting + # it under those is ignored - and logged as ignored, never applied twice. + price_provider: "off" + price_markup_eur_kwh: 0.03 + price_taxes_eur_kwh: 0.06 + price_csv_path: "/share/goodwe_prices.csv" + price_feed_url: "" + price_horizon_h: 48 + price_timeout_s: 10 + + # §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero - + # which by itself strongly favours self-consumption over export. A scalar, not + # a second series: no shipped supplier publishes a dynamic injection curve. + export_price_eur_kwh: 0.04 + # --- site ------------------------------------------------------------------ estop_fitted: false log_level: info @@ -239,5 +283,23 @@ schema: price_now_entity: str? price_avg_entity: str? + # ⚠️ The provider list is written out rather than left as `str`, so the + # Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id + # against app/price.py's PROVIDERS registry, because sim/inject.py and the + # checks reach validate_startup_config() with a dict the Supervisor never saw. + # Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere + # else, which is AC2. + price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv) + price_markup_eur_kwh: float(0,1) + price_taxes_eur_kwh: float(0,1) + price_csv_path: str? + price_feed_url: str? + price_horizon_h: int(1,72) + price_timeout_s: int(2,60) + # ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have + # gone below zero (you pay to export at midday in summer), and a schema that + # bottoms out at 0 would make that site inexpressible. + export_price_eur_kwh: float(-1,1) + estop_fitted: bool log_level: list(trace|debug|info|warning|error) diff --git a/goodwe_controller/test_price.py b/goodwe_controller/test_price.py new file mode 100644 index 0000000..2ed3d51 --- /dev/null +++ b/goodwe_controller/test_price.py @@ -0,0 +1,1303 @@ +"""Runnable check for the price provider framework. `python3 test_price.py` + +WHAT THIS FILE IS DEFENDING, in the order the ticket names it: + + 1. Every shipped plugin emits the EXACT canonical schema (§9.3), all-in. + 2. Adding a plugin needs no optimiser change - so the framework must contain + no plugin name, and a plugin invented HERE, in this file, must reach the + right volatility regime without price.py being touched. + 3. `manual_csv` works with no network at all. + 4. The injection price is a separate scalar (§9.3). + 5. "No price data" is a DIFFERENT OUTCOME from "prices, all of them zero". + +TWO RULES THIS FILE HOLDS ITSELF TO, because the reviewer will: + + * EVERY VERDICT IS DERIVED. There is no `check("the schema is right", True)` + anywhere below. Where a check needs an expected number, the number is + computed here from the same inputs by different arithmetic than price.py + uses, and where it needs a set, the set is built from the objects rather + than typed out. + + * NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE. That + is not decoration here, it is the main hazard in a markup test: + + - markup and taxes must DIFFER and both be NON-ZERO, or "added markup + twice" and "added markup and taxes once each" produce the same number + and the double-counting check grades nothing. The shipped defaults are + 0.03 and 0.06 and 0.06 IS EXACTLY TWICE 0.03, so this file deliberately + uses 0.031 / 0.057 for the arithmetic and asserts the non-degeneracy + conditions as checks in their own right rather than assuming them. + - the raw price must be non-zero, or an all-in figure equals markup+taxes + and a plugin that dropped the energy component entirely still passes. + - a fixture must carry BOTH a provisional and a non-provisional point, or + `plan_is_provisional` returns the same answer under any bug. + - the DST checks run on the 23-hour and 25-hour days, not on a normal + 24-hour day where a fixed-24-slot implementation and a correct one + agree exactly. +""" + +import ast +import asyncio +import inspect +import io +import json +import logging +import os +import sys +from datetime import datetime, timedelta, timezone + +from app.price import ( + ALL_IN, Absence, BANNED_IN_PLUGIN_CODE, Component, FrameworkOption, + MAX_PLAUSIBLE_EUR_KWH, code_leaks, + PRICE_OPTION_DEFAULTS, PROVIDERS, PluginOptions, PricePoint, PriceProduct, + PriceProvider, PriceRead, ProviderCapabilities, RawPoint, SourceUnavailable, + arbitrage_allowed, assemble, brussels, brussels_offset, check_egress, + price_option, + export_price_eur_kwh, load_prices, plan_is_provisional, register, + volatility_regime, +) +from app import price as _price +from app.validate import validate_startup_config + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +def run(coro): + return asyncio.run(coro) + + +def _reason(fn): + """The message fn() raised, so a check can assert WHICH rule refused. + + `raises(...)` only says that something refused. M7 survived because two + different rules could refuse the same URL and the check could not tell them + apart - so where a check is named for a specific rule, it asserts on the + reason instead. + """ + try: + fn() + except Exception as e: # noqa: BLE001 + return str(e) + return "" + + +def raises(fn, exc): + """True if fn() raises exactly `exc`. A DIFFERENT exception is False, not + True - otherwise "it was refused" and "it broke on the way to being + refused" would grade the same, which is how M7 hid.""" + try: + fn() + except exc: + return True + except Exception: + return False + return False + + +class _Raised: + """A value a check wanted, whose computation raised. + + Compares unequal to everything, is falsy, and prints what went wrong. + """ + + def __init__(self, e): + self.e = e + + def __eq__(self, other): + return False + + def __hash__(self): + return id(self) + + def __bool__(self): + return False + + def __repr__(self): + return f"" + + +def derived(fn): + """Compute a value a check grades. An exception becomes `_Raised`. + + ⚠️ THIS IS NOT A DEFAULT, and the distinction is the one this whole ticket + is about. `_Raised` is not `[]`, not `None`, not 0; it equals nothing, it is + falsy, and it renders as the exception that produced it. Its only job is + that a mutation which makes a derivation IMPOSSIBLE - the reviewer removing + `_NoData`'s missing `.points`, say - reports as a failed check naming the + exception, instead of aborting this file before the sections below it run. + A silent `except: return []` here would be the defect under test, in the + harness that tests it. + """ + try: + return fn() + except Exception as e: # noqa: BLE001 + return _Raised(e) + + +UTC = timezone.utc + +# ⚠️ NOT the shipped 0.03 / 0.06. See this module's docstring: 0.06 is exactly +# 2 x 0.03, so under the shipped pair "the framework added the markup twice and +# no taxes" and "the framework added one markup and one taxes" differ by +# 0.03 - but several OTHER confusions collapse. These two are co-prime-ish in +# the only sense that matters here, and the three degeneracy conditions are +# asserted below rather than assumed. +MARKUP = 0.031 +TAXES = 0.057 + + +def opts(**over): + """A price configuration. Everything below is this, spoilt.""" + o = {"price_provider": "off", + "price_markup_eur_kwh": MARKUP, + "price_taxes_eur_kwh": TAXES, + "price_timeout_s": 5} + o.update(over) + return o + + +def hours(n, start, step_h=1): + return [start + timedelta(hours=i * step_h) for i in range(n)] + + +# --------------------------------------------------------------------------- # +print("0. the arithmetic this file grades with must not be degenerate") +# ⚠️ These four ARE the guard against a check that passes for the wrong reason. +# If any of them goes red, every markup assertion below is measuring nothing. +check("markup and taxes are both non-zero", MARKUP != 0 and TAXES != 0) +check("...and differ, so 'added the wrong one' is visible", + MARKUP != TAXES) +check("...and markup twice != markup plus taxes, so double-counting the markup " + "is distinguishable from correctly adding both", + abs(2 * MARKUP - (MARKUP + TAXES)) > 1e-9) +check("...and the shipped defaults are NOT what this file grades with, because " + f"config.yaml's {PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']} is exactly " + f"twice its {PRICE_OPTION_DEFAULTS['price_markup_eur_kwh']}", + abs(PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"] + - 2 * PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"]) < 1e-9 + and (MARKUP, TAXES) != (PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"], + PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])) + + +# --------------------------------------------------------------------------- # +print() +print("1. the shipped defaults live in config.yaml, and here only as a mirror") +# ⚠️ CFG-03. The add-on already carries option defaults in three places. This +# ticket adds ONE in-code mirror (PRICE_OPTION_DEFAULTS) and pins it to +# config.yaml by reading the file rather than by transcribing it - the same +# move test_validate.py makes for max_w and meter_freeze_s. If they drift, this +# goes red and somebody has to decide which is right. +_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml") +with open(_CFG, "r", encoding="utf-8") as fh: + _cfg_lines = fh.read().splitlines() + + +def shipped(key): + """The value config.yaml ships, from the `options:` block, parsed by hand. + + No yaml module in the shipped image, and this is two lines of parsing + against a file whose shape is known - the alternative is a transcription, + which is the thing being guarded against. + """ + in_schema = False + for line in _cfg_lines: + if line.startswith("schema:"): + in_schema = True + if in_schema or not line.startswith(f" {key}:"): + continue + raw = line.split(":", 1)[1].split("#", 1)[0].strip().strip('"').strip("'") + try: + return int(raw) + except ValueError: + pass + try: + return float(raw) + except ValueError: + return raw + return KeyError + + +_found = {k: shipped(k) for k in PRICE_OPTION_DEFAULTS} +_missing = sorted(k for k, v in _found.items() if v is KeyError) +check(f"config.yaml ships every price option ({len(PRICE_OPTION_DEFAULTS)} keys)", + _missing == []) +_drift = sorted(k for k, v in _found.items() + if v is not KeyError and v != PRICE_OPTION_DEFAULTS[k]) +check(f"...and each shipped default equals the in-code mirror {_found}", + _drift == []) +# The schema block must list them too, or the Supervisor drops the option and +# the add-on silently runs on the mirror's value instead of the operator's. +_schema_block = _cfg_lines[_cfg_lines.index("schema:"):] +_unschemad = sorted(k for k in PRICE_OPTION_DEFAULTS + if not any(ln.startswith(f" {k}:") for ln in _schema_block)) +check("...and every one of them is in the schema block too", _unschemad == []) +# ⚠️ Derived from the registry, not typed: the schema's provider list must offer +# exactly the plugins that exist, or the UI and the code disagree about what is +# installable. +_line = next(ln for ln in _schema_block if ln.startswith(" price_provider:")) +_schema_ids = set(_line.split("list(", 1)[1].rstrip(")").split("|")) +check(f"the schema offers exactly the registered plugins plus 'off' " + f"{sorted(_schema_ids)}", + _schema_ids == set(PROVIDERS) | {"off"}) + + +# --------------------------------------------------------------------------- # +print() +print("2. every shipped plugin emits the exact canonical schema (AC1)") + +# One recorded fixture per plugin, none of them a real fetch. Prices are +# deliberately distinct, non-zero, and one of them NEGATIVE - §17 keeps a +# negative-price regression case, and a windy Sunday is the best charging hour +# of the year, so a framework that clamped it away would cost real money. +T0 = datetime(2026, 1, 15, 0, 0, tzinfo=UTC) +# ⚠️ THE NEGATIVE HOUR IS -95 EUR/MWh, NOT -12.5, AND THE MAGNITUDE IS THE +# POINT. All-in adds 0.088 EUR/kWh here, so a -12.5 EUR/MWh hour comes out at +# +0.0755 and the "a negative price survives" check below would be grading a +# positive number - passing whether or not the framework clamped anything. +# -95 EUR/MWh is both realistic for a windy Belgian Sunday and still negative +# after markup and taxes, which is the only version of the stimulus that grades +# what it claims to. +EPEX_EUR_MWH = [61.4, -95.0, 183.0] # EUR/MWh, one deeply negative +RETAIL_EUR_KWH = [0.1341, 0.0872, 0.2610] # EUR/kWh, markup already inside +EDDY_EUR_MWH = [141.0, 96.2, 268.4] # EUR/MWh, all-in + + +def json_feed(key, values, provisional_at=1): + """A feed body. `provisional_at` makes ONE point provisional and the rest + not, so `plan_is_provisional` is graded against a mixed series - an + all-true or all-false fixture would return the same answer under any bug.""" + return json.dumps({"data": [ + {"start": (T0 + timedelta(hours=i)).isoformat(), + key: v, + "provisional": (i == provisional_at)} + for i, v in enumerate(values)]}).encode() + + +CSV_BODY = ("start,eur_per_kwh,provisional\n" + "2026-01-15T00:00:00+01:00,0.1834,false\n" + "2026-01-15T01:00:00+01:00,-0.0210,true\n" + "2026-01-15T02:00:00+01:00,0.3115,false\n") + +# The fixture, the transport that serves it, and - crucially - the ENERGY +# component of each point, computed here so the all-in expectation below is +# arithmetic this file did itself. +FIXTURES = { + "generic_dayahead": (json_feed("price_eur_mwh", EPEX_EUR_MWH), + [v / 1000 for v in EPEX_EUR_MWH]), + "engie_dynamic": (json_feed("price_eur_kwh", RETAIL_EUR_KWH), + list(RETAIL_EUR_KWH)), + "totalenergies_dynamic": (json_feed("price_eur_kwh", RETAIL_EUR_KWH), + list(RETAIL_EUR_KWH)), + "eneco_eddy": (json_feed("price_eur_mwh", EDDY_EUR_MWH), + [v / 1000 for v in EDDY_EUR_MWH]), + "manual_csv": (None, [0.1834, -0.0210, 0.3115]), +} + +check(f"all five plugins the ticket lists are registered {sorted(PROVIDERS)}", + set(PROVIDERS) == {"generic_dayahead", "engie_dynamic", + "totalenergies_dynamic", "eneco_eddy", "manual_csv"}) +check("...and the fixture set covers every one of them", + set(FIXTURES) == set(PROVIDERS)) + + +def canned(body): + """A transport that serves recorded bytes and records that it was used.""" + calls = [] + + async def t(url, timeout_s): + calls.append((url, timeout_s)) + return body + t.calls = calls + return t + + +def load(pid, extra=None, transport=None): + o = opts(price_provider=pid, **(extra or {})) + return run(load_prices(o, T0, T0 + timedelta(hours=48), + transport=transport)) + + +import tempfile # noqa: E402 + +_csv_dir = tempfile.mkdtemp(prefix="tariff02-") +CSV_PATH = os.path.join(_csv_dir, "prices.csv") +with open(CSV_PATH, "w", encoding="utf-8") as fh: + fh.write(CSV_BODY) + + +def load_any(pid): + body, _ = FIXTURES[pid] + if pid == "manual_csv": + return load(pid, {"price_csv_path": CSV_PATH}, transport=refuse_transport) + return load(pid, transport=canned(body)) + + +async def _refuse(url, timeout_s): + raise AssertionError(f"the offline plugin reached the network: {url}") + +refuse_transport = _refuse + +READS = {pid: load_any(pid) for pid in PROVIDERS} + +_wrong_kind = sorted(p for p, r in READS.items() if r.kind != "series") +check(f"every plugin returned a series from its fixture", _wrong_kind == []) + +# THE SCHEMA. Exactly three keys, in every point of every plugin, and the check +# derives the key set from the objects rather than asserting a typed literal. +_keys = derived(lambda: {frozenset(p.keys()) + for r in READS.values() + for p in r.canonical()["prices"]}) +check(f"every canonical point has exactly the three §9.3 keys, and only those " + f"-> {_keys}", + _keys == {frozenset({"start", "eur_per_kwh", "provisional"})}) +check("the envelope is exactly {'prices': [...]}", + derived(lambda: all(set(r.canonical()) == {"prices"} + for r in READS.values())) is True) +_badtype = derived(lambda: sorted( + p for p, r in READS.items() + if not all(isinstance(pt["eur_per_kwh"], float) + and isinstance(pt["provisional"], bool) + and isinstance(pt["start"], str) + for pt in r.canonical()["prices"]))) +check(f"...with a float price, a bool flag and an ISO string start {_badtype}", + _badtype == []) +_naive = derived(lambda: sorted(p for p, r in READS.items() + if any(pt.start.utcoffset() is None + for pt in r.points))) +check(f"every start is timezone-aware (§9.3 shows +01:00) {_naive}", + _naive == []) +_offsets = derived(lambda: {pt.start.utcoffset() + for r in READS.values() for pt in r.points}) +check(f"...and at a Belgian winter offset for a January fixture {_offsets}", + _offsets == {timedelta(hours=1)}) +check("the whole envelope round-trips through json.dumps", + derived(lambda: all(json.loads(json.dumps(r.canonical())) == r.canonical() + for r in READS.values())) is True) + + +# --------------------------------------------------------------------------- # +print() +print("3. all-in: energy + markup + taxes, added exactly once (AC1, trap 1)") +# The expected figure is computed HERE, from the fixture's energy component and +# the plugin's own declaration, by different arithmetic than assemble() uses. +for pid, read in sorted(READS.items()): + caps = PROVIDERS[pid].capabilities() + _, energy = FIXTURES[pid] + add = (MARKUP if Component.MARKUP in caps.adds else 0.0) + \ + (TAXES if Component.TAXES in caps.adds else 0.0) + want = [round(e + add, 6) for e in energy] + got = derived(lambda r=read: [p.eur_per_kwh for p in r.points]) + check(f"{pid}: all-in == published {sorted(c.value for c in caps.sources)} " + f"+ framework {sorted(c.value for c in caps.adds) or 'nothing'} " + f"-> {got}", + got == want) + +# ⚠️ THE ONE THAT MATTERS. generic_dayahead is the only plugin whose feed is +# wholesale, so it is the only one that gets both components - and the three +# retail plugins must each get strictly less. Derived by comparing the two +# plugins' output on the SAME energy figure rather than by restating numbers. +def added(pid): + return derived(lambda: round(READS[pid].points[0].eur_per_kwh + - FIXTURES[pid][1][0], 6)) + + +_generic_add, _engie_add, _eddy_add = (added("generic_dayahead"), + added("engie_dynamic"), + added("eneco_eddy")) +check(f"the wholesale feed gets markup+taxes ({_generic_add})", + isinstance(_generic_add, float) + and abs(_generic_add - (MARKUP + TAXES)) < 1e-9) +check(f"a retail feed that already carries the markup gets taxes only " + f"({_engie_add}), one markup less than the wholesale one", + isinstance(_engie_add, float) and isinstance(_generic_add, float) + and abs(_engie_add - TAXES) < 1e-9 + and abs((_generic_add - _engie_add) - MARKUP) < 1e-9) +check(f"an all-in feed gets nothing added ({_eddy_add})", + isinstance(_eddy_add, float) and abs(_eddy_add) < 1e-9) + +# Negative prices survive. A framework that clamped them would turn the best +# charging hour of the year into an average one, silently. +_neg = derived(lambda: [p.eur_per_kwh for p in READS["generic_dayahead"].points + if p.eur_per_kwh < 0]) +check(f"a negative day-ahead price passes through, all-in {_neg}", + _neg == [round(EPEX_EUR_MWH[1] / 1000 + MARKUP + TAXES, 6)]) + +# provisional is per point and comes from the feed, not from the series. +for pid, read in sorted(READS.items()): + flags = derived(lambda r=read: [p.provisional for p in r.points]) + check(f"{pid}: provisional is per point and mixed {flags}", + isinstance(flags, list) and any(flags) and not all(flags)) + check(f"...so plan_is_provisional is derived from those points", + derived(lambda r=read: plan_is_provisional(r)) is True) +# ...and the same function says False when nothing is provisional. Without this +# arm, "always True" would pass every check above. +_settled = PriceRead.series( + [PricePoint(brussels(T0), 0.2, False)], PriceProduct.DAY_AHEAD, "x", T0) +check("a fully settled series is NOT provisional", + plan_is_provisional(_settled) is False) + + +# --------------------------------------------------------------------------- # +print() +print("4. double-counting the markup is detectable, and unreachable (trap 1)") + + +class _Rogue(PriceProvider): + """A plugin that adds the markup itself while declaring it did not. + + NOT registered - it exists only here, as the mutation the design is meant to + survive. It is what a plugin author writes when they copy the wholesale + plugin, hardcode their supplier's margin, and forget to update `sources`. + """ + + id = "_rogue_not_shipped" + + @classmethod + def capabilities(cls): + return ProviderCapabilities(product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY})) + + async def fetch(self, o, since, until, transport): + # The margin the author hardcoded, because they could not read the + # configured one - see the FrameworkOption check below. + return [RawPoint(T0, RETAIL_EUR_KWH[0], False)] + + +_honest_caps = PROVIDERS["engie_dynamic"].capabilities() +_honest = assemble(_honest_caps, [RawPoint(T0, RETAIL_EUR_KWH[0], False)], + MARKUP, TAXES)[0].eur_per_kwh +_rogue_raw = run(_Rogue().fetch(PluginOptions({}), T0, T0, None)) +_rogue = assemble(_Rogue.capabilities(), _rogue_raw, MARKUP, TAXES)[0].eur_per_kwh +_delta = derived(lambda: round(_rogue - _honest, 6)) +check(f"a plugin that pre-adds the markup and declares it did not comes out " + f"{_delta} high - exactly one markup, so the defect is DETECTABLE and " + f"attributable rather than merely wrong", + isinstance(_delta, float) and abs(_delta - MARKUP) < 1e-9) +check("...and it is not confusable with 'the taxes were added twice'", + isinstance(_delta, float) and abs(_delta - TAXES) > 1e-9) + +# ...and now the structural half: the number is not reachable from a plugin, so +# the honest author cannot write that line even by accident. +_po = PluginOptions({"price_markup_eur_kwh": MARKUP, + "price_taxes_eur_kwh": TAXES, + "export_price_eur_kwh": 0.04, + "price_csv_path": "/x"}) +_refused = [] +for k in ("price_markup_eur_kwh", "price_taxes_eur_kwh", "export_price_eur_kwh"): + try: + _po.get(k) + except FrameworkOption: + _refused.append(k) +check(f"a plugin cannot read the framework's own options {sorted(_refused)}", + sorted(_refused) == sorted(["price_markup_eur_kwh", "price_taxes_eur_kwh", + "export_price_eur_kwh"])) +check("...and `k in opts` is false for them too, so a probe cannot find them", + not any(k in _po for k in ("price_markup_eur_kwh", "price_taxes_eur_kwh"))) +check("...while the options a plugin legitimately needs still come through", + _po.get("price_csv_path") == "/x" + and _po.get("price_timeout_s") == PRICE_OPTION_DEFAULTS["price_timeout_s"]) + +# The prose and the declaration must agree. A docstring that says "the framework +# adds taxes" over a class declaring it sources them is a 3 c/kWh error with a +# reassuring comment on top. +_doc_drift = [] +for pid, cls in sorted(PROVIDERS.items()): + line = next((ln for ln in (cls.__doc__ or "").splitlines() + if ln.strip().startswith("SOURCES:")), "") + named = {w.strip() for w in line.split(":", 1)[-1].split(",") if w.strip()} + declared = {c.value for c in cls.capabilities().sources} + if named != declared: + _doc_drift.append((pid, sorted(named), sorted(declared))) +check(f"every plugin's docstring names exactly the components it declares " + f"({len(PROVIDERS)} checked)", _doc_drift == []) + +# The ignored-markup warning: a plugin whose feed is already all-in must SAY it +# is ignoring the operator's numbers rather than dropping them silently. +_buf = io.StringIO() +_h = logging.StreamHandler(_buf) +logging.getLogger("goodwe.price").addHandler(_h) +load_any("eneco_eddy") +load_any("generic_dayahead") +logging.getLogger("goodwe.price").removeHandler(_h) +_warned = _buf.getvalue() +check("an all-in plugin logs that the configured markup/taxes are ignored", + "double-count" in _warned and "eneco_eddy" in _warned) +check("...and the wholesale plugin, which really does use them, does not", + "generic_dayahead" not in _warned) + + +# --------------------------------------------------------------------------- # +print() +print("4b. a plugin cannot reach the markup BY ANY ROUTE (review of PR #16)") +# WHY THIS SECTION EXISTS. The first version of this ticket claimed the markup +# was "not in scope" for a plugin because `PluginOptions` refused it. Review +# 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 `register`. One closed door in a room with three is not a +# guarantee. Honest wholesale baseline 0.188; each attack lands 0.219. +_banned = set(BANNED_IN_PLUGIN_CODE) +check(f"the banned set covers the framework keys AND the three public routes " + f"review actually used {sorted(_banned)}", + _banned == set(_price.FRAMEWORK_KEYS) | {"PRICE_OPTION_DEFAULTS", + "price_option", "_o"}) + +# code_leaks() fails OPEN when inspect.getsource() cannot find the .py - the +# right trade for a battery controller, and a silently disarmed guard if nobody +# checks. THIS is the check that makes CI prove the guard is armed. +_srclen = derived(lambda: {pid: len(inspect.getsource(cls)) + for pid, cls in PROVIDERS.items()}) +check(f"the scan really read every shipped plugin's source, so its fail-open " + f"path is not silently in use {_srclen}", + isinstance(_srclen, dict) and len(_srclen) == 5 + and all(n > 200 for n in _srclen.values())) +_dirty = derived(lambda: {pid: code_leaks(cls) + for pid, cls in PROVIDERS.items() + if code_leaks(cls)}) +check(f"...and all five shipped plugins are clean {_dirty}", _dirty == {}) + +# THE DOCSTRING STRIP IS LOAD-BEARING, AND THIS PROVES IT RATHER THAN ASSERTING +# IT. Every plugin is REQUIRED to name its sourced components in prose, so +# engie_dynamic's docstring contains "markup" - a raw text scan false-positives +# on the documentation the same file mandates, and a check that fires on +# required documentation is a check somebody deletes. +_raw_hits = derived(lambda: sorted( + pid for pid, cls in PROVIDERS.items() + if any(b in inspect.getsource(cls) for b in _banned))) +check(f"a RAW text scan would false-positive on the mandated docstrings " + f"{_raw_hits}, which is why code_leaks() strips them before searching", + isinstance(_raw_hits, list) and _raw_hits != [] and _dirty == {}) + + +class _AttackPrivateDict(PriceProvider): + """A1: reaches into PluginOptions' private dict. + + SOURCES: energy + The framework ADDS: markup, taxes + """ + + id = "_attack_private_dict" + + @classmethod + def capabilities(cls): + return ProviderCapabilities(product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY})) + + async def fetch(self, o, since, until, transport): + return [RawPoint(T0, 0.10 + o._o["price_markup_eur_kwh"], False)] + + +class _AttackHelper(PriceProvider): + """A2: calls a public framework helper with the raw options dict. + + SOURCES: energy + The framework ADDS: markup, taxes + """ + + id = "_attack_helper" + + @classmethod + def capabilities(cls): + return ProviderCapabilities(product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY})) + + async def fetch(self, o, since, until, transport): + return [RawPoint(T0, 0.10 + price_option(o._o, "price_markup_eur_kwh"), + False)] + + +class _AttackPublicConstant(PriceProvider): + """A2b: touches nothing but a public module constant. THE DECIDING ONE. + + SOURCES: energy + The framework ADDS: markup, taxes + """ + + id = "_attack_public_constant" + + @classmethod + def capabilities(cls): + return ProviderCapabilities(product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY})) + + async def fetch(self, o, since, until, transport): + return [RawPoint(T0, + 0.10 + PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"], + False)] + + +_ATTACKS = {"A1 private dict": _AttackPrivateDict, + "A2 public helper": _AttackHelper, + "A2b public constant": _AttackPublicConstant} +for _name, _cls in _ATTACKS.items(): + _found = derived(lambda c=_cls: code_leaks(c)) + check(f"{_name}: the scan names what it reached for {_found}", + isinstance(_found, list) and _found != []) + check(f"...and register() REFUSES it at import, not at fetch time", + raises(lambda c=_cls: register(c), FrameworkOption)) + check(f"...so it never enters the registry", _cls.id not in PROVIDERS) + +# THE COST OF THE DEFECT, MEASURED, so the refusal is not grading itself. +_honest_wholesale = derived(lambda: assemble( + PROVIDERS["generic_dayahead"].capabilities(), + [RawPoint(T0, 0.10, False)], MARKUP, TAXES)[0].eur_per_kwh) +_attacked = derived(lambda: assemble( + _AttackPublicConstant.capabilities(), + [RawPoint(T0, 0.10 + MARKUP, False)], MARKUP, TAXES)[0].eur_per_kwh) +check(f"an unrefused attack would bill {_attacked} against an honest " + f"{_honest_wholesale} - one markup high, every hour, silently", + isinstance(_attacked, float) and isinstance(_honest_wholesale, float) + and abs((_attacked - _honest_wholesale) - MARKUP) < 1e-9) + +# WHAT THIS DOES *NOT* CLOSE, AND CANNOT. A plugin that declares {ENERGY} while +# its feed is really all-in comes out one markup high while touching no +# framework name at all. No code can check that: the declaration is the +# plugin's unverifiable claim about somebody else's tariff. Stated here so the +# guarantee is read at its true size - the complement is added exactly once, +# GIVEN A CORRECT DECLARATION - and so a reviewer who trusts an absolute +# guarantee does not stop checking. Measured, not asserted: +_misdeclared = derived(lambda: assemble( + ProviderCapabilities(product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY})), + [RawPoint(T0, 0.10 + MARKUP + TAXES, False)], MARKUP, TAXES)[0].eur_per_kwh) +check(f"a MIS-DECLARED all-in feed still doubles ({_misdeclared} vs " + f"{_honest_wholesale}) with no framework name touched - the limit of the " + f"guarantee, stated rather than hidden", + isinstance(_misdeclared, float) + and abs((_misdeclared - _honest_wholesale) - (MARKUP + TAXES)) < 1e-9 + and code_leaks(_AttackPrivateDict) != []) + + +# --------------------------------------------------------------------------- # +print() +print("5. product type is declared, not inferred from the name (AC2, trap 2)") +_products = {pid: cls.capabilities().product for pid, cls in PROVIDERS.items()} +check(f"eneco_eddy declares itself real-time/imbalance per §9.2 " + f"({_products['eneco_eddy'].value})", + _products["eneco_eddy"] is PriceProduct.REALTIME) +check("...and it is the only shipped one that is", + [p for p, v in _products.items() if v is PriceProduct.REALTIME] + == ["eneco_eddy"]) +_regimes = {pid: volatility_regime(v) for pid, v in _products.items()} +check(f"...so §8.3's widened thresholds reach it through the ENUM {_regimes}", + _regimes["eneco_eddy"] == "wide" + and set(v for p, v in _regimes.items() if p != "eneco_eddy") == {"normal"}) +check("the regime travels on the series a consumer is handed", + derived(lambda: (READS["eneco_eddy"].regime, + READS["generic_dayahead"].regime)) == ("wide", "normal")) +def _accepts_name(): + """Would volatility_regime() take a plugin id? If it would, somebody can + start passing one and AC2 rots without a single check going red.""" + try: + volatility_regime("eneco_eddy") + return True + except TypeError: + return False + + +check("volatility_regime refuses a plugin NAME, which is the AC2 violation " + "written out", + not _accepts_name()) + + +# ⚠️ THE MECHANICAL FORM OF AC2. The framework's own code must contain no +# plugin id at all - if it does, the sixth supplier is an edit to price.py's +# shared machinery rather than a new class. Derived: build the set of ids that +# actually occur, and assert it is empty. +_framework = [_price.assemble, _price.load_prices, _price.volatility_regime, + _price.arbitrage_allowed, _price.plan_is_provisional, + _price.check_egress, _price._validate_raw, _price.PriceRead, + _price._Series, _price._NoData, _price.PluginOptions, + _price.register, _price.price_option] +_src = "\n".join(inspect.getsource(o) for o in _framework) +_leaked = sorted(pid for pid in PROVIDERS if pid in _src) +check(f"no plugin id appears anywhere in the framework's code {_leaked or '[]'}", + _leaked == []) + +# ...and the positive half: a plugin defined HERE, in a file price.py has never +# heard of, gets the right regime with no edit anywhere. +@register +class _NewSupplier(PriceProvider): + """A sixth supplier, invented in the test file. AC2 in one class. + + SOURCES: energy + The framework ADDS: markup, taxes + """ + + id = "_test_sixth_supplier" + + @classmethod + def capabilities(cls): + return ProviderCapabilities(product=PriceProduct.REALTIME, + sources=frozenset({Component.ENERGY}), + egress_hosts=("api.example.invalid",)) + + async def fetch(self, o, since, until, transport): + body = await transport("https://api.example.invalid/p", + float(o.get("price_timeout_s"))) + return _price._points_from_json(body, "price_eur_mwh", 0.001, False) + + +_sixth = load("_test_sixth_supplier", + transport=canned(json_feed("price_eur_mwh", EPEX_EUR_MWH))) +check("a plugin invented outside price.py resolves through the registry", + _sixth.kind == "series" and _sixth.provider == "_test_sixth_supplier") +_sixth_regime = derived(lambda: _sixth.regime) +check(f"...and lands in the wide-threshold regime purely from its declaration " + f"({_sixth_regime})", _sixth_regime == "wide") +check("...and the framework applied its markup and taxes correctly too", + derived(lambda: [p.eur_per_kwh for p in _sixth.points]) + == [round(v / 1000 + MARKUP + TAXES, 6) for v in EPEX_EUR_MWH]) +del PROVIDERS["_test_sixth_supplier"] # do not leak into later sections + + +# --------------------------------------------------------------------------- # +print() +print("6. no price data is its own outcome, not an empty list (trap 3, §11.4)") + +_none = PriceRead.no_data("the feed timed out") +# ⚠️ THE DISTINCTION THE WHOLE TYPE EXISTS FOR. A day of free electricity and a +# day with no price data are different instructions: arbitrage is POINTLESS +# under the first and FORBIDDEN under the second. +_zero = PriceRead.series([PricePoint(brussels(T0 + timedelta(hours=i)), 0.0, + False) for i in range(3)], + PriceProduct.DAY_AHEAD, "x", T0) +check("a zero-price series and no-price-data are different kinds", + _zero.kind != _none.kind) +check("...arbitrage is allowed under zero prices and forbidden under no data", + arbitrage_allowed(_zero) is True and arbitrage_allowed(_none) is False) +check("...the plan is provisional under no data and settled under zero prices", + plan_is_provisional(_none) is True and plan_is_provisional(_zero) is False) +check("...and the cheapest hour is computable for the zero series", + derived(lambda: min(p.eur_per_kwh for p in _zero.points)) == 0.0) + + +check("no_data has no .points, so `min(read.points)` cannot invent a free hour", + raises(lambda: _none.points, Absence)) +check("...no .product to switch a threshold regime on", + raises(lambda: _none.product, Absence)) +check("...no .at(), so an hourly lookup cannot silently succeed", + raises(lambda: _none.at(T0), Absence)) +check("...and .get('points', []) - the idiom the whole rework exists to kill - " + "raises rather than returning the default", + raises(lambda: _none.get("points", []), Absence)) +check("...and it is not iterable, so `for p in read:` cannot be an empty loop", + raises(lambda: list(_none), Absence)) +check("...while its one real field, the reason, is there for an operator", + _none.reason == "the feed timed out") + +# M3, INHERITED FROM RIG-01 AND CLOSED HERE. `_NoData.__init__(self, +# reason="no price data")` used to go green: a required field with a DEFAULT is +# the exact idiom this whole type exists to forbid, and it survived because +# every check passed a reason explicitly. It is the identical survivor left +# open on sim/riglock.py's `_Unread.cause` - the shape was borrowed including +# its blind spot, which is what borrowing a shape does if nobody re-runs the +# mutations against the copy. +# +# Checked by SIGNATURE rather than by one call, so any default added to any of +# these constructors later is red without somebody remembering to write a new +# case. +def _defaulted(fn): + """Parameters of `fn` that carry a default. `self` is skipped.""" + try: + ps = list(inspect.signature(fn).parameters.values()) + except (TypeError, ValueError) as e: # pragma: no cover + return _Raised(e) + return [q.name for q in ps + if q.name != "self" and q.default is not inspect.Parameter.empty] + + +_ctors = {"PriceRead.no_data": PriceRead.no_data, + "PriceRead.series": PriceRead.series, + "_NoData.__init__": _price._NoData.__init__, + "_Series.__init__": _price._Series.__init__, + "PriceRead.match": PriceRead.match} +_with_defaults = derived(lambda: {n: d for n, d in + ((n, _defaulted(f)) for n, f in _ctors.items()) + if d}) +check(f"no constructor or handler of an outcome type carries a DEFAULT, so a " + f"reading can never be built out of nothing {_with_defaults}", + _with_defaults == {}) +check("...so no_data() with no reason at all is a TypeError at the call site", + raises(lambda: PriceRead.no_data(), TypeError)) +check("...and the variant class itself cannot be built bare either", + raises(lambda: _price._NoData(), TypeError)) +check("...nor can a series be built without its product or provenance", + raises(lambda: _price._Series([PricePoint(brussels(T0), 0.2, False)]), + TypeError)) +# The positive arm, so the four above are not passing because everything raises. +check("...while a fully specified no_data is still perfectly constructible", + PriceRead.no_data("the feed timed out").reason == "the feed timed out") + +# `match` must also refuse POSITIONAL handlers - keyword-only is what makes a +# forgotten arm a TypeError rather than a silently swapped one. +check("match() refuses positional handlers, so the two arms cannot be swapped", + raises(lambda: _none.match(lambda r: 1, lambda r: 2), TypeError)) + +check("an EMPTY series cannot be constructed at all, so 'no prices' can never " + "wear the shape of an answer", + raises(lambda: PriceRead.series([], PriceProduct.DAY_AHEAD, "x", T0), + ValueError)) +check("a series built with a plugin NAME instead of a product enum is refused", + raises(lambda: PriceRead.series([PricePoint(brussels(T0), 0.2, False)], + "eneco_eddy", "x", T0), ValueError)) +check("no_data with a blank reason is refused - the reason is its only field", + raises(lambda: PriceRead.no_data(""), ValueError)) + + +def _forgot(): + return _none.match(series=lambda r: r.points) # no_data omitted + + +check("a caller who forgets the no_data arm gets a TypeError at the call site, " + "not a fall-through", raises(_forgot, TypeError)) + +# .at() outside the series raises rather than returning a neighbour or None. +_s = READS["generic_dayahead"] +check("series.at() inside the series returns the covering point", + derived(lambda: _s.at(_s.points[0].start + timedelta(minutes=59)) + is _s.points[0]) is True) +check("...and raises outside it rather than returning None to be `or`-ed away", + derived(lambda: ( + raises(lambda: _s.at(_s.points[-1].start + timedelta(days=2)), + Absence) + and _s.covers(_s.points[0].start) + and not _s.covers(_s.points[-1].start + timedelta(days=2)))) is True) + +# Every failure a site can have must come back as no_data, never as a traceback +# into the tick. Derived: drive each failure and collect the kinds. +async def _boom(url, timeout_s): + raise OSError("connection reset by peer") + + +_failures = { + "the source is unreachable": load("generic_dayahead", transport=_boom), + "the body is not JSON": load("generic_dayahead", transport=canned(b"")), + "the feed is empty": load("generic_dayahead", + transport=canned(b'{"data": []}')), + "the provider id is unknown": load("no_such_supplier"), + "no provider is configured": load("off"), + "the csv is missing": load("manual_csv", + {"price_csv_path": "/no/such/file.csv"}, + transport=refuse_transport), +} +_not_nodata = sorted(k for k, r in _failures.items() if r.kind != "no_data") +check(f"every site-level failure returns no_data instead of raising " + f"({len(_failures)} cases)", _not_nodata == []) +_no_reason = derived(lambda: sorted(k for k, r in _failures.items() + if len(r.reason) < 10)) +check(f"...each with a reason an operator can act on {_no_reason}", + _no_reason == []) +check("...and none of them is mistakable for a series", + derived(lambda: all(not arbitrage_allowed(r) + for r in _failures.values())) is True) + +# ⚠️ The one thing that must NOT degrade to no_data: a plugin reaching for the +# markup. That is a code defect, and swallowing it would hide exactly what +# trap 1 exists to catch. +class _Thief(PriceProvider): + id = "_thief" + + @classmethod + def capabilities(cls): + return ProviderCapabilities(product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY})) + + async def fetch(self, o, since, until, transport): + return [RawPoint(T0, 0.1 + o.get("price_markup_eur_kwh"), False)] + + +PROVIDERS["_thief"] = _Thief +check("a plugin that reaches for the markup raises rather than degrading to " + "no_data - that is a bug, not a site condition", + raises(lambda: load("_thief"), FrameworkOption)) +del PROVIDERS["_thief"] + + +# --------------------------------------------------------------------------- # +print() +print("7. manual_csv works fully offline (AC3)") +_had_aiohttp = "aiohttp" in sys.modules +_csv_read = load("manual_csv", {"price_csv_path": CSV_PATH}, + transport=refuse_transport) +_csv_prices = derived(lambda: [p.eur_per_kwh for p in _csv_read.points]) +check("the offline plugin produced a series without touching the transport - " + "the transport raises if called at all", + _csv_read.kind == "series" and _csv_prices == [0.1834, -0.021, 0.3115]) +check(f"...it is all-in already, so the framework added nothing {_csv_prices}", + _csv_prices == [round(v, 6) for v in + [float(ln.split(",")[1]) for ln in + CSV_BODY.strip().splitlines()[1:]]]) +check("...including the negative hour", + isinstance(_csv_prices, list) and any(v < 0 for v in _csv_prices)) +check("...and the per-point provisional flag came from the file's third column", + derived(lambda: [p.provisional for p in _csv_read.points]) + == [ln.split(",")[2] == "true" + for ln in CSV_BODY.strip().splitlines()[1:]]) +check("...and no HTTP stack was imported to do it" + if not _had_aiohttp else + "...(aiohttp was already imported before this file ran; not a finding)", + _had_aiohttp or "aiohttp" not in sys.modules) +check("manual_csv declares no egress hosts at all", + PROVIDERS["manual_csv"].capabilities().egress_hosts == ()) + +_ManualCsv = PROVIDERS["manual_csv"] +check("a CSV with no header is refused rather than parsed as data", + raises(lambda: _ManualCsv.parse("2026-01-15T00:00:00+01:00,0.18\n"), + SourceUnavailable)) +check("a CSV start with no offset is refused, naming the fix", + raises(lambda: _ManualCsv.parse("start,eur_per_kwh\n2026-01-15 00:00,0.18\n"), + SourceUnavailable)) +check("...and the two-column form (no provisional) is accepted, defaulting to " + "settled", + [p.provisional for p in + _ManualCsv.parse("start,eur_per_kwh\n2026-01-15T00:00:00+01:00,0.18\n")] + == [False]) + + +# --------------------------------------------------------------------------- # +print() +print("8. the two 2026 DST transitions, where a 24-slot array is wrong") +# ⚠️ RUN ON THE 23- AND 25-HOUR DAYS ON PURPOSE. On any normal day a fixed +# 24-slot implementation, a naive-local-hour key and a correct one all agree +# exactly, so a check on 15 January grades nothing about DST. +check("the 2026 transitions are where this repo expects them (last Sundays)", + _price._last_sunday(2026, 3) == 29 and _price._last_sunday(2026, 10) == 25) + + +def local_day(day_start_utc, n): + return [brussels(day_start_utc + timedelta(hours=i)) for i in range(n)] + + +# Spring forward: local day 2026-03-29 runs 2026-03-28T23:00Z .. 2026-03-29T22:00Z +_spring = local_day(datetime(2026, 3, 28, 23, tzinfo=UTC), 23) +_spring_end = brussels(datetime(2026, 3, 29, 22, tzinfo=UTC)) +check(f"the spring day has 23 hours, ending exactly at the next midnight " + f"({_spring[0].isoformat()} .. {_spring_end.isoformat()})", + _spring[0].hour == 0 and _spring[0].day == 29 + and _spring_end.hour == 0 and _spring_end.day == 30) +check(f"...crossing exactly one offset change " + f"{sorted(str(o) for o in {p.utcoffset() for p in _spring})}", + {p.utcoffset() for p in _spring} == {timedelta(hours=1), + timedelta(hours=2)}) +check("...and 02:00 local never happens that day, which a 24-slot array would " + "have invented", 2 not in {p.hour for p in _spring}) + +# Fall back: local day 2026-10-25 runs 2026-10-24T22:00Z .. 2026-10-25T22:00Z +_fall = local_day(datetime(2026, 10, 24, 22, tzinfo=UTC), 25) +_fall_end = brussels(datetime(2026, 10, 25, 23, tzinfo=UTC)) +check(f"the autumn day has 25 hours ({_fall[0].isoformat()} .. " + f"{_fall_end.isoformat()})", + _fall[0].hour == 0 and _fall[0].day == 25 + and _fall_end.hour == 0 and _fall_end.day == 26) +_wall = [p.replace(tzinfo=None) for p in _fall] +_dups = len(_wall) - len(set(_wall)) +check(f"...and exactly one local wall-clock hour occurs TWICE ({_dups}), which " + f"a naive-local-hour key would collide into one mispriced hour", + _dups == 1) +check("...distinguished by their offsets, which is why the canonical format " + "carries one", + len({p.isoformat() for p in _fall}) == 25) +# A normal day, stated as the control: without it, "23" and "25" prove nothing +# about whether the code counts at all. +_normal = local_day(datetime(2026, 1, 14, 23, tzinfo=UTC), 24) +check("...while an ordinary January day has 24 hours and one offset", + len({p.replace(tzinfo=None) for p in _normal}) == 24 + and len({p.utcoffset() for p in _normal}) == 1) +check("brussels() refuses a naive datetime rather than assuming an offset", + raises(lambda: brussels(datetime(2026, 1, 15, 0, 0)), ValueError)) +check("the offset boundary is 01:00 UTC, not local midnight", + brussels_offset(datetime(2026, 3, 29, 0, 59, tzinfo=UTC)) == + timezone(timedelta(hours=1)) + and brussels_offset(datetime(2026, 3, 29, 1, 0, tzinfo=UTC)) == + timezone(timedelta(hours=2))) + +# A feed that publishes the duplicated hour must survive it. The 25-hour day is +# 25 strictly increasing instants; a plugin keyed on local hours would emit 24 +# and be rejected by the boundary validator instead of silently mispricing. +_dst_feed = json.dumps({"data": [ + {"start": (datetime(2026, 10, 24, 22, tzinfo=UTC) + + timedelta(hours=i)).isoformat(), + "price_eur_mwh": 50.0 + i, "provisional": i == 24} + for i in range(25)]}).encode() +_dst_read = load("generic_dayahead", transport=canned(_dst_feed)) +_dst_n = derived(lambda: len(_dst_read.points)) +check(f"a 25-point autumn feed is accepted whole ({_dst_n} points)", + _dst_read.kind == "series" and _dst_n == 25) +check("...and its canonical starts are 25 distinct strings", + derived(lambda: len({p["start"] + for p in _dst_read.canonical()["prices"]})) == 25) + + +# --------------------------------------------------------------------------- # +print() +print("9. the boundary rejects what cannot be a price, and only that") +# M5. Deleting the `Component.ENERGY not in sources` guard used to go green, +# and the consequence is not cosmetic: `adds` becomes {ENERGY, TAXES}, +# `assemble()` never adds an energy component it thinks it is adding, and the +# emitted all-in price silently omits the energy it is supposed to be pricing. +check("capabilities() refuses a feed that declares no ENERGY component", + raises(lambda: ProviderCapabilities( + product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.MARKUP})), ValueError)) +check("...including a completely empty sources set", + raises(lambda: ProviderCapabilities(product=PriceProduct.DAY_AHEAD, + sources=frozenset()), ValueError)) +check("...and an unknown component is refused rather than ignored", + raises(lambda: ProviderCapabilities( + product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY, "grid_fees"})), ValueError)) +check("...and a product given as a string is refused at the declaration too", + raises(lambda: ProviderCapabilities(product="dynamic_dayahead", + sources=frozenset({Component.ENERGY})), + ValueError)) +# THE CONSEQUENCE, derived across every shipped plugin rather than asserted on +# one: the framework must never be in a position to "add" the energy component, +# because there is nothing to add it from. +_adds_energy = derived(lambda: sorted( + pid for pid, cls in PROVIDERS.items() + if Component.ENERGY in cls.capabilities().adds)) +check(f"so no shipped plugin ever asks the framework to invent an energy " + f"price {_adds_energy}", _adds_energy == []) + +_caps = PROVIDERS["generic_dayahead"].capabilities() + + +def boundary(points): + return raises(lambda: _price._validate_raw(points, _caps), SourceUnavailable) + + +check("NaN is refused", boundary([RawPoint(T0, float("nan"), False)])) +check("infinity is refused", boundary([RawPoint(T0, float("inf"), False)])) +check(f"a EUR/MWh figure read as EUR/kWh is refused (>{MAX_PLAUSIBLE_EUR_KWH})", + boundary([RawPoint(T0, 61.4, False)])) +check("a naive start is refused", + boundary([RawPoint(datetime(2026, 1, 15), 0.1, False)])) +check("duplicate starts are refused - the DST collision, caught", + boundary([RawPoint(T0, 0.1, False), RawPoint(T0, 0.2, False)])) +check("out-of-order starts are refused", + boundary([RawPoint(T0 + timedelta(hours=1), 0.1, False), + RawPoint(T0, 0.2, False)])) +check("a plugin returning something that is not a RawPoint is refused", + boundary([PricePoint(brussels(T0), 0.1, False)])) +check("an empty list is refused", boundary([])) +# ...and the negative-price control, so the rules above are not just "reject +# everything". A validator that refused this would cost money on a windy Sunday. +check("a NEGATIVE price at a plausible magnitude is accepted", + not boundary([RawPoint(T0, -0.35, False)])) +check("...and so is a large-but-plausible one at the limit", + not boundary([RawPoint(T0, MAX_PLAUSIBLE_EUR_KWH - 0.01, False)])) + + +# --------------------------------------------------------------------------- # +print() +print("10. egress is declared by the plugin and enforced (§16)") +_g = PROVIDERS["generic_dayahead"].capabilities() +check("a declared https host is allowed", + check_egress("https://api.energy-charts.info/price?bzn=BE", _g) + == "api.energy-charts.info") +check("an undeclared host is refused even over TLS", + raises(lambda: check_egress("https://evil.example/p", _g), + SourceUnavailable)) +# M7, AND THE ASSERTION IS THE FIX, NOT ONLY THE PARSER. Deleting the TLS rule +# used to leave this check GREEN: the old parser stripped a fixed +# len("https://") from the front, so `http://api.energy-charts.info/p` became +# the host `pi.energy-charts.info`, which failed the ALLOW-LIST instead. The +# feed was refused, the check named "plain http is refused" passed, and the +# rule it was named for could be deleted with no consequence - an assertion +# that does not test what it names, inside a security check. +# +# Three checks now, and the first two are what make the third able to fail: +# prove the host is declared over https, prove it parses identically over +# http, and only then assert that http is refused AND that the reason is TLS. +_tls_url = "http://api.energy-charts.info/p" +check("the same host over https is allowed, so it IS on the allow-list", + derived(lambda: check_egress("https://api.energy-charts.info/p", _g)) + == "api.energy-charts.info") +check("...and the http form parses to that same declared host, so nothing but " + "the TLS rule can refuse it", + derived(lambda: _tls_url.partition("://")[2].split("/", 1)[0]) + == "api.energy-charts.info") +_tls_reason = derived(lambda: _reason(lambda: check_egress(_tls_url, _g))) +check(f"plain http is refused, and refused BY THE TLS RULE - {_tls_reason!r}", + isinstance(_tls_reason, str) and "non-TLS" in _tls_reason) +check("a scheme that is neither is refused the same way, by TLS not by host", + "non-TLS" in str(derived(lambda: _reason( + lambda: check_egress("ftp://api.energy-charts.info/p", _g))))) +# ⚠️ M7b, WHICH I FOUND IN MY OWN FIX FOR M7 AND WHICH IS THE SAME DEFECT ONE +# LAYER DOWN. This check used to read `raises(..., SourceUnavailable)` and +# survived deleting the `if not sep` guard: with no guard, +# "api.energy-charts.info/p".partition("://") leaves scheme = the WHOLE string, +# so the TLS rule refuses it and the check named for the scheme guard passes. +# Refused either way - but the diagnostic an operator gets degrades from "not an +# absolute URL" to "uses 'api.energy-charts.info/p'" as a scheme, which is +# gibberish. Assert the reason, like M7 above. +_noscheme = derived(lambda: _reason( + lambda: check_egress("api.energy-charts.info/p", _g))) +check(f"a URL with no scheme is refused BY THE SCHEME GUARD, with a diagnostic " + f"that names the real problem - {_noscheme!r}", + isinstance(_noscheme, str) and "not an absolute URL" in _noscheme) +check("a userinfo prefix cannot smuggle a declared host past the check", + raises(lambda: check_egress( + "https://api.energy-charts.info@evil.example/p", _g), + SourceUnavailable)) +check("an operator-supplied URL to another supplier's host is refused", + load("generic_dayahead", + {"price_feed_url": "https://api.eneco.be/eddy/prices"}, + transport=canned(json_feed("price_eur_mwh", EPEX_EUR_MWH)) + ).kind == "no_data") +_declared = {pid: cls.capabilities().egress_hosts + for pid, cls in PROVIDERS.items()} +check(f"every networked plugin declares at least one host, and manual_csv " + f"declares none {_declared}", + all(bool(h) for p, h in _declared.items() if p != "manual_csv") + and _declared["manual_csv"] == ()) +check("the timeout the plugin passes to the transport is the configured one, " + "not a hardcoded default", + (lambda t: (load("generic_dayahead", {"price_timeout_s": 7}, + transport=t), t.calls[0][1] == 7.0)[1])( + canned(json_feed("price_eur_mwh", EPEX_EUR_MWH)))) + + +# --------------------------------------------------------------------------- # +print() +print("11. injection price is a separate scalar (AC4, §9.3)") +check(f"the shipped default is config.yaml's " + f"{PRICE_OPTION_DEFAULTS['export_price_eur_kwh']}", + export_price_eur_kwh({}) == PRICE_OPTION_DEFAULTS["export_price_eur_kwh"]) +check("an operator's value wins over it", + export_price_eur_kwh({"export_price_eur_kwh": 0.0}) == 0.0) +check("...including a negative one - Belgian injection has gone below zero", + export_price_eur_kwh({"export_price_eur_kwh": -0.02}) == -0.02) +check("it is NOT folded into the consumption series any plugin emits", + derived(lambda: all(export_price_eur_kwh({}) not in + [p.eur_per_kwh for p in r.points] + for r in READS.values())) is True) +check("...and no plugin can reach it to fold it in", + raises(lambda: PluginOptions({}).get("export_price_eur_kwh"), + FrameworkOption)) + + +# --------------------------------------------------------------------------- # +print() +print("12. the startup gate (validate.py rule 7)") + + +def vbase(**over): + o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000, + "soc_entity": "sensor.soc", "batt_entity": "sensor.batt", + "meter_source": "off", + "maintenance_charge_w": 2000, "maintenance_discharge_w": 2000} + o.update(over) + return o + + +def why(problems, needle): + return any(needle in p for p in problems) + + +check("the shipped price defaults start (price_provider is 'off')", + validate_startup_config(vbase(**{k: v for k, v in + PRICE_OPTION_DEFAULTS.items()}), + -5000, 5000) == []) +check("...and so does a configured provider on its own", + validate_startup_config(vbase(price_provider="generic_dayahead"), + -5000, 5000) == []) +check("an unknown provider id is refused rather than defaulted to 'off'", + why(validate_startup_config(vbase(price_provider="nordpool"), -5000, 5000), + "not a shipped plugin")) +# ⚠️ THE HA OVERLAP, DECIDED. The two entities keep working alone; combining +# them with a provider is what is refused, because the entities win silently. +_p = validate_startup_config( + vbase(price_provider="engie_dynamic", price_now_entity="sensor.now", + price_avg_entity="sensor.avg"), -5000, 5000) +check("a provider AND the Home Assistant price entities is refused - two price " + "sources on one site", len(_p) == 1) +check("...and the message says which one actually wins", + why(_p, "HOME ASSISTANT ENTITIES WIN")) +check("the Home Assistant entities alone still start, so nothing is taken away " + "from a live install", + validate_startup_config(vbase(price_now_entity="sensor.now", + price_avg_entity="sensor.avg"), + -5000, 5000) == []) +# ⚠️ A TRIPWIRE ON A REASON, NOT ON A BEHAVIOUR, AND IT IS AIMED AT WHOEVER +# LANDS TARIFF-03 OR OPT-04. Rule 7 refuses "provider + HA entities" and its +# stated reason is that the provider series decides NOTHING while the HA pair +# quietly keeps deciding. The refusal stays correct forever; its EXPLANATION +# expires the moment a consumer appears - at that point both really do decide, +# and it becomes a genuine two-writer conflict rather than a silently-ignored +# one. A future reader would then be trusting a reason that is no longer the +# reason, which is the same defect TEST-06's boundary comment names. +# +# So: assert that nothing in app/ consumes the series yet. When you wire +# load_prices() into the optimiser this goes RED, and the fix is to update the +# refusal message in validate.py IN THE SAME COMMIT - not to delete this check. +_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app") +# ⚠️ AST, NOT A SUBSTRING, AND THE FIRST DRAFT PROVED WHY. A text scan for +# "load_prices" matched the COMMENT in validate.py that explains this very +# tripwire, so the check went red against its own documentation - the same +# false-positive class as the plugin docstrings in section 4b, hit twice in one +# ticket. Comments and docstrings are not in an AST, so a name/import walk sees +# code and only code. +def _imports_or_calls(path, name): + try: + tree = ast.parse(open(path, encoding="utf-8").read()) + except (OSError, SyntaxError): + return False + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and any(a.name == name + for a in node.names): + return True + if isinstance(node, ast.Name) and node.id == name: + return True + if isinstance(node, ast.Attribute) and node.attr == name: + return True + return False + + +_consumers = derived(lambda: sorted( + f for f in os.listdir(_APP) + if f.endswith(".py") and f != "price.py" + and _imports_or_calls(os.path.join(_APP, f), "load_prices"))) +check(f"no module consumes load_prices() yet, so rule 7's stated reason - 'the " + f"provider decides nothing' - is still TRUE {_consumers}. When this goes " + f"red, update that message in validate.py in the same commit", + _consumers == []) +# ...and the other half: the registry IS on the startup path, which is what +# makes an unwired seam anchored rather than dead code. +check("...while validate.py does import the registry, so the seam is exercised " + "on every boot", + derived(lambda: "PROVIDERS" in open( + os.path.join(_APP, "validate.py"), encoding="utf-8").read()) is True) + +check("manual_csv with no path is refused - the offline fallback with no file " + "is §11.4 reached by accident", + why(validate_startup_config(vbase(price_provider="manual_csv", + price_csv_path=""), -5000, 5000), + "price_csv_path")) +check("...and with a path it starts", + validate_startup_config(vbase(price_provider="manual_csv", + price_csv_path=CSV_PATH), + -5000, 5000) == []) +# ⚠️ price_provider 'off' must NEVER become a refusal: §9.2 puts a large share +# of the installed base on a fixed or static tariff, and §11.4 makes no-price +# data a supported state. +check("'off' is not refused, on any of the other configurations", + all(validate_startup_config(vbase(price_provider="off", **extra), + -5000, 5000) == [] + for extra in ({}, {"price_now_entity": "sensor.now", + "price_avg_entity": "sensor.avg"}, + {"price_csv_path": ""}))) + + +# --------------------------------------------------------------------------- # +print() +if fails: + print(f"{len(fails)} FAILED: " + "; ".join(fails)) + sys.exit(1) +print("all checks passed")