TARIFF-02: one canonical price format, and a seam that keeps supplier names out of the optimiser
checks / unit (pull_request) Successful in 2m33s
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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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))
|
||||
@@ -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 == "":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user