TARIFF-02: one canonical price format, and a seam that keeps supplier names out of the optimiser
checks / unit (pull_request) Successful in 2m33s

Five provider plugins - generic_dayahead, engie_dynamic,
totalenergies_dynamic, eneco_eddy, 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; the seam
lands first, per the PM's ordering.

It is built in goodwe_controller/app/ rather than at specs.md 2.2's
ems/ingest/prices/, per the product owner's decision of 2026-08-30. The
behaviour 9.3 / 8.3 / 11.4 specifies is unchanged.

Three things shape the design, and each is the mechanism rather than a
comment saying to be careful:

Double-counting the markup is unreachable. A plugin does not return a
price - it returns the figure its source published and declares which of
{energy, markup, taxes} that figure already contains. assemble() adds
the complement, once, in the framework. A plugin is never handed
price_markup_eur_kwh: PluginOptions raises FrameworkOption on it, so the
line that adds the margin a second time cannot be written. Engie and
TotalEnergies publish a retail figure with their margin inside; adding
the configured 3 c/kWh on top would have made every arbitrage decision
wrong by that amount with nothing reporting it.

The product type is declared, not inferred from the plugin's name.
eneco_eddy is real-time/imbalance and gets 8.3's widened thresholds
through capabilities().product and volatility_regime(), never through a
name comparison - which is AC2. test_price.py asserts mechanically that
no plugin id appears anywhere in the framework's code, and drives a
sixth provider defined inside the test file to the right regime with
price.py untouched.

"No price data" is a type, not an empty list - the same rework
sim/riglock.py took under TEST-04. 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, 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 one and forbidden under the other - and an
implementation returning [] cannot tell them apart. Every site-level
failure returns no_data; nothing raises into the control tick.

Also: injection stays a separate scalar (export_price_eur_kwh, AC4),
negative prices survive the boundary validator (17's regression case),
egress is allow-listed against each plugin's own declaration (16), and
no tzdata is used - the shipped Alpine image has none, so Brussels
offsets come from the EU rule and instants are carried as UTC.

validate.py grows rule 7: an unknown provider id, manual_csv with no
path, and - the HA overlap the PM told us to decide about -
price_provider set alongside price_now_entity/price_avg_entity, which is
two price sources where the HA pair silently wins. The HA sensors are
kept and still work alone; TARIFF-03 and OPT-04 are what replace them.

CFG-03 is not widened: the price defaults have ONE in-code mirror,
PRICE_OPTION_DEFAULTS, which validate.py imports rather than restating
and which test_price.py pins to config.yaml by reading the file.

test_price.py is runnable, assert-free in the house style, and every
verdict it prints is derived. It grades the markup arithmetic with
0.031/0.057 rather than the shipped 0.03/0.06, because 0.06 is exactly
twice 0.03 and a degenerate pair would make double-counting
indistinguishable from correct addition - and it asserts that
non-degeneracy as its first section. The DST checks run on the 23-hour
and 25-hour days, not on a normal day where a fixed 24-slot
implementation and a correct one agree. Thirteen mutations were driven
through it; all thirteen go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
2026-08-30 19:05:12 +02:00
co-authored by Claude Opus 5
parent 9aadf469bc
commit 852fc7ced6
6 changed files with 2256 additions and 0 deletions
+57
View File
@@ -2,6 +2,63 @@
## 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 unreachable rather than discouraged.** 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. A plugin is never handed `price_markup_eur_kwh` at
all - `PluginOptions` raises on it - so the plugin that adds the margin a second
time cannot be written. Engie and TotalEnergies publish a retail figure with
their margin already inside; adding the configured 3 c/kWh on top would have
made every future arbitrage decision wrong by that amount with nothing
reporting it.
**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.
**`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.
**A computed setpoint over the rating now alarms instead of being clipped in
silence (SAFETY-03).** The clamp was never the missing half - specs.md 10.3
records an unclamped integrator climbing ~130 W per 4 s past 10 kW and reporting
+83
View File
@@ -581,6 +581,89 @@ 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.
`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 |
+994
View File
@@ -0,0 +1,994 @@
"""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 MADE UNREACHABLE, NOT DISCOURAGED.
`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 IS NEVER HANDED THOSE TWO NUMBERS. `fetch()` receives a
`PluginOptions`, which is the options dict with the framework-owned keys
REMOVED - reading `price_markup_eur_kwh` from inside a plugin raises
`FrameworkOption` rather than returning a number. That is what turns
"the plugin must not add markup" from a rule into an inability.
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.
"""
import csv
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
_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.
"""
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. That is the difference between "the plugin must not add the
markup" being a convention and it being a thing a plugin cannot do.
"""
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.
"""
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
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.
"""
if not url:
raise SourceUnavailable("no feed URL configured (price_feed_url)")
if not url.startswith("https://"):
raise SourceUnavailable(f"refusing a non-TLS price feed: {url!r}")
host = url[len("https://"):].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))
+79
View File
@@ -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,66 @@ 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.
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 == "":
+62
View File
@@ -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)
+981
View File
@@ -0,0 +1,981 @@
"""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 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, Component, FrameworkOption, MAX_PLAUSIBLE_EUR_KWH,
PRICE_OPTION_DEFAULTS, PROVIDERS, PluginOptions, PricePoint, PriceProduct,
PriceProvider, PriceRead, ProviderCapabilities, RawPoint, SourceUnavailable,
arbitrage_allowed, assemble, brussels, brussels_offset, check_egress,
export_price_eur_kwh, load_prices, plan_is_provisional, register,
volatility_regime,
)
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)
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"<raised {type(self.e).__name__}: {self.e}>"
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("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.
from app import price as _price # noqa: E402
_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)
def raises(fn, exc):
try:
fn()
except exc:
return True
except Exception:
return False
return False
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")
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"<html>")),
"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")
_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))
check("plain http is refused",
raises(lambda: check_egress("http://api.energy-charts.info/p", _g),
SourceUnavailable))
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) == [])
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")