TARIFF-03: static day/night and fully-fixed tariffs, first-class #19

Merged
admin merged 1 commits from TARIFF-03 into release/1.0 2026-08-30 23:24:54 +02:00
9 changed files with 2174 additions and 50 deletions
+94
View File
@@ -2,6 +2,100 @@
## Unreleased
**Static day/night and fully-fixed tariffs, first-class (TARIFF-03).**
§9.2 puts a large share of the Belgian installed base on a static profile, and
an EMS that only works under dynamic pricing is an EMS for a minority. A new
`static_profile` option describes a two-period day/night tariff — or a single
fixed price — as JSON, and `app/static_tariff.py` turns it into the same
canonical `{start, eur_per_kwh, provisional}` series the fetched providers emit,
so nothing downstream learns that a tariff can be static.
**It is not a sixth provider plugin, and the reason is mechanical.**
`PriceProvider.capabilities()` is a classmethod, so a plugin's `PriceProduct` is
fixed when the class is written — but §9.2 makes STATIC and FIXED two different
products with two different required behaviours, and which one a site is on is a
property of the operator's data, not of any class. `StaticProfile.product`
derives it from the number of distinct prices instead. The second reason: the
plugin framework exists to stop a *fetched* figure being marked up twice, and a
number the operator typed as all-in has no publisher to disagree with.
**So the profile sits where §11.4 already says it does — under the providers, as
the fallback.** The configured-product path and the feed-is-down path are the
same generator differing in one flag: `provisional`. A static-tariff site runs
that path every day, so the degradation path for a dynamic site cannot rot
unused.
**"No arbitrage" is a type, not a boolean somebody has to remember to check.**
`TariffPlan.arbitrage()` returns a variant: the *allowed* arm carries the
spread and the cheapest and dearest hours, and the *refused* arm carries a
reason and nothing else — `.spread_eur_kwh` on a refusal raises rather than
answering 0.0. An optimiser cannot compute a trade from a refusal because it
cannot get the inputs out of one. Four refusals, all derived: the peak guard is
holding, there is no price data, the plan is provisional, or **the series is
flat**. That last one is AC2 — a fully-fixed tariff disables arbitrage by
arithmetic rather than by matching on an enum, and a dynamic feed that happens
to print one price all day is refused for the same true reason.
**Peak shaving outranks arbitrage, expressed so OPT-04 cannot skip it.**
`arbitrage()` takes `peak_guard_active` as a required keyword argument, so the
spread is not obtainable without first answering whether §10.1's priority 2 is
holding. §9.1's capacity tariff is a network cost charged whatever the energy
product is.
**Time-window shifting reaches the one shiftable load that exists.** main.py's
cheap-window flag — previously fed only by the `price_now_entity` /
`price_avg_entity` pair — now falls back to the static profile, and the Web UI
gained a **Tariff** row naming the window, the price, when it next changes and
why it is or is not cheap. The rule is *strictly* below the seven-day mean:
under a flat tariff `<=` would call every hour cheap and a fixed-tariff site
would top up from the grid at any hour, which is the opposite of
self-consumption only. Seven days rather than 48 hours because a 48 h mean taken
on a Friday is two-thirds weekend, and the same Tuesday 09:00 would come out
cheap or dear depending on when you asked.
**A profile must tile every day type exactly once, checked at startup.** A gap
is refused with the uncovered range named rather than filled in — an invented
price is one an optimiser would trade on — and an overlap is refused because
otherwise one of two prices wins silently on list order. The trap this catches
in practice: `22:00–07:00 mon-fri` does **not** cover Saturday morning, because
a window applies on the day the clock reads.
**validate.py rule 7's stated reason expired, exactly as TARIFF-02 predicted.**
That ticket left a tripwire in `test_price.py` section 12 asserting that nothing
in `app/` consumed `load_prices()`, with instructions to fix the refusal's
wording in the same commit when it went red. It went red naming
`static_tariff.py`. The message now says what is true instead — the series *is*
consumed, but main.py acts on the static side of it and not on a fetched one —
and the tripwire is re-aimed rather than removed: the consumer set is pinned, so
a second consumer (OPT-04) goes red again, and the expired phrase is asserted
gone so a bad merge cannot restore it. Rule 7 also grew a second refusal:
`static_profile` alongside the Home Assistant price pair is the same
silent-loser conflict, and a live one.
**No new place for an option default (CFG-03 is not widened).**
`static_profile` was added to `app/price.py`'s `PRICE_OPTION_DEFAULTS` — the one
in-code mirror TARIFF-02 created and pinned to `config.yaml` by a check — rather
than to a defaults dict in the new module, which has none. main.py grew no
`o.get("static_profile", …)` fallback. That is TARIFF-02's pattern, stated
here so a reviewer does not have to infer it.
**Measured, on the data that exists.** `test_static_tariff.py` replays the two
committed days of this house's real P1 meter (`scenarios/p1-net-2026-08-{20,23}`)
priced under a Belgian day/night profile. 2026-08-20 is a Thursday: 15.220 kWh
imported, 9.606 in the day window and 5.614 in the night one, EUR 4.7185 under
day/night against EUR 4.6725 flat, and shifting the day-window energy into the
night window saves EUR 0.8069 — which is 9.606 kWh x the 0.0840 spread, derived
twice and required to agree. 2026-08-23 is a Sunday: 0.728 kWh, **all** of it
night-priced, which is the weekend row proving itself on real data. §17 names
six Belgian scenarios and a static-profile case is not among them; this is the
seventh, and it is two captured days rather than a season — the file says so
rather than implying an annual figure.
Also worth knowing before OPT-04: on realistic Belgian day/night numbers the
whole-tariff spread is about 0.08 EUR/kWh, **below** the shipped
`cycle_cost_threshold_eur` of 0.10. A correct optimiser will decline to cycle
for the tariff alone on such a site. That is a product finding, not a defect.
**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
+80 -9
View File
@@ -583,11 +583,15 @@ 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.
> **No *fetched* price moves energy in 0.5.x.** This release ships the
> *plumbing*: the five provider plugins, the canonical all-in format they emit,
> and the config that selects one. Turning a provider on fetches, validates and
> publishes prices and changes no charging decision; the arbitrage strategy that
> would use it is a later ticket.
>
> The one exception is `static_profile` below. A static day/night tariff needs
> no network, so its cheap window is computed on every cycle and does reach the
> maintenance cycle's grid top-up.
| option | meaning |
|---|---|
@@ -599,6 +603,7 @@ floor of 60 s that figure is 60 W and only the margin is left.
| `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 |
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
**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
@@ -667,10 +672,76 @@ 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.
**not compatible with `price_provider`, and not with `static_profile` either**
— configure two of them and the add-on refuses to start, naming the conflict,
because the add-on reads exactly one and the loser is silent. Use one.
### Static day/night and fully-fixed tariffs (`static_profile`)
Most Belgian households are **not** on a dynamic contract, and an EMS that only
works under hourly pricing is an EMS for a minority. `static_profile` is how a
two-period day/night tariff or a single fixed price becomes a first-class
configuration rather than "turn the price feature off and hope".
It is one option, holding a JSON object, on one line:
```json
{"periods": [
{"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
{"name":"night", "from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
{"name":"night", "from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
```
A **fully fixed** tariff is one period covering the whole week:
```json
{"periods": [
{"name":"fixed","from":"00:00","to":"24:00","days":"all","eur_per_kwh":0.31}]}
```
- `eur_per_kwh` is **all-in** — energy *plus* your supplier's markup *plus*
taxes — exactly like `manual_csv`. `price_markup_eur_kwh` and
`price_taxes_eur_kwh` are **not** added to it.
- `days` takes `mon`…`sun`, ranges (`mon-fri`, `fri-mon`), comma lists
(`sat,sun`) and the words `weekday`, `weekend`, `all`.
- Times are **local Belgian time** and windows are half-open: `07:00`–`22:00`
includes 07:00 and excludes 22:00. A window may cross midnight.
- The clock changes twice a year and the add-on handles it. It generates prices
against absolute instants, so a spring-forward day is 23 hours and an
autumn-back day is 25.
> ⚠️ **A window belongs to the day the clock reads, not the day it started.**
> `22:00`–`07:00` on `mon-fri` covers Friday 23:00 but **not** Saturday 03:00 —
> Saturday 03:00 is Saturday's. That is why the example above has a third row
> for the weekend. Leave it out and the add-on **refuses to start**, naming the
> uncovered hours.
**Every day type must be covered exactly once.** The periods have to tile all
seven days with no gap and no overlap, and the add-on checks that at startup. A
gap is refused rather than filled in, because a made-up price is one the
optimiser would trade on; an overlap is refused because otherwise one of the two
prices silently wins on the order you typed them in.
**What it changes today.** The add-on works out which window you are in and
whether that window is *cheap* — strictly below the tariff's seven-day average.
The Web UI shows both on a new **Tariff** row, and the maintenance cycle's
cheap-window grid top-up uses it, which is exactly the "shift load into the
cheap window" behaviour a day/night tariff asks for. Nothing else moves energy
on a price yet.
Under a **fully fixed** tariff every hour equals the average, so there is *no*
cheap window by construction and the add-on runs self-consumption only — which
is the correct behaviour for that product, not a limitation. Peak shaving, the
capacity-tariff tracker, the reserve floor and the maintenance schedule are all
unaffected: the capacity tariff is a network cost charged whatever your energy
contract is.
**Recommended even on a dynamic contract.** When a price feed is unreachable the
add-on falls back to your static profile, marks the plan provisional and
disables arbitrage. With `static_profile` empty there is nothing to fall back
to, and the site simply has no tariff until the feed returns. It is not required
— the add-on warns rather than refusing — but filling it in is what makes that
fallback work.
### Site
+30 -2
View File
@@ -45,6 +45,7 @@ from .hass import HomeAssistant
from .maintenance import IDLE, MaintConfig, Maintenance
from .mqtt import MqttPublisher
from .p1 import P1Ingest, build_source, is_enabled
from .static_tariff import static_status
from .tariff import PeakStore, PeakTracker
from .validate import ConfigRefused, refuse, validate_startup_config
from .watchdog import SignWatchdog, SignWatchdogConfig, loop_is_following
@@ -208,6 +209,10 @@ class Controller:
self.grid = self.soc = self.batt = None
self.peak_fc = None
self.cheap = False
# TARIFF-03. One line of prose for the status page saying WHICH tariff
# window we are in and why it is or is not cheap - so "Cheap window:
# no" is an evidenced answer rather than a bare no.
self.tariff_words = "no static profile"
self.last_write = None
self.last_write_at = None
self.last_write_ok = None
@@ -261,9 +266,31 @@ class Controller:
now_p = await self.hass.number(now_e)
avg_p = await self.hass.number(avg_e)
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
self.tariff_words = "Home Assistant price entities"
else:
# Fixed-tariff site: never force a paid grid top-up, wait for sun.
self.cheap = False
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
# The add-on has exactly one shiftable load in 0.5.x - the
# maintenance cycle's cheap-window grid top-up, ~15 lines below in
# cycle() - so this is where the window reaches something that moves
# energy. With `static_profile` empty this returns False and the
# behaviour is bit-for-bit what it has always been: "fixed-tariff
# site, never force a paid grid top-up, wait for sun".
#
# ⚠️ SYNCHRONOUS AND I/O-FREE, ON PURPOSE. The provider-backed
# resolver (static_tariff.resolve_tariff) can perform an HTTP fetch,
# and this coroutine is on the path that feeds the ESP32 heartbeat -
# a supplier's TLS timeout here would be a supplier able to push the
# add-on towards layer 1's 30 s veto. Generation from a static
# profile is pure arithmetic, so §2.3's "regenerate on the tick"
# applies and there is nothing to block on. Putting a fetched series
# on this path needs a BACKGROUND refresh and belongs with OPT-04.
#
# ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile
# alongside the two Home Assistant entities, because this `if` picks
# one and the loser is silent.
self.cheap, self.tariff_words = static_status(
o, datetime.now(timezone.utc))
if self.inputs_ok:
if self.inputs_bad_since is not None:
@@ -998,6 +1025,7 @@ class Controller:
("Maintenance phase", self.maint.phase),
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
]
return {
"banner": banner, "level": level, "rows": rows, "checks": checks,
+9
View File
@@ -517,6 +517,15 @@ PRICE_OPTION_DEFAULTS = {
"price_feed_url": "",
"price_horizon_h": 48,
"price_timeout_s": 10,
# TARIFF-03. §14 `tariffs.static_profile: null`. A JSON object describing a
# static day/night or fully-fixed tariff; "" is §14's `null`. Parsed and
# validated by app/static_tariff.py, refused at startup by validate.py.
#
# ⚠️ IT LIVES HERE RATHER THAN IN static_tariff.py FOR CFG-03's SAKE. This
# dict is the ONE in-code mirror of config.yaml, pinned to it by
# test_price.py section 1. A second defaults dict in the new module would be
# a fourth place option defaults live, which is exactly the open ticket.
"static_profile": "",
}
# ⚠️ The keys the FRAMEWORK owns. A plugin that reads one of these is a plugin
+924
View File
@@ -0,0 +1,924 @@
"""TARIFF-03 - static day/night and fully-fixed tariffs, first-class.
WHERE THIS LIVES, AND WHY IT IS NOT WHERE THE SPEC SAYS. specs.md §2.2 puts the
price machinery at `ems/tariff/...`. 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 price.py, arbiter.py and validate.py.
The BEHAVIOUR §9.2 / §9.3 / §11.4 / §10.1 describes is unchanged and still
authoritative - only the path moved.
WHY THIS TICKET EXISTS, in §9.2's own words: "a large share of the installed
base is on [a static profile], and an EMS that only works under dynamic pricing
is an EMS for a minority." The §9.2 product table maps four products onto four
EMS behaviours, and TARIFF-02 shipped the machinery for the top two only:
Dynamic day-ahead hourly EPEX BE + markup full arbitrage
Real-time/imbalance e.g. Eneco Eddy Flex arbitrage, wider thresholds
Static day/night two-period tariff TIME-WINDOW SHIFTING <- here
Fully fixed single price SELF-CONSUMPTION ONLY <- here
THE PLACEMENT DECISION, AND THE TWO REASONS IT IS NOT A PROVIDER PLUGIN
=======================================================================
The obvious move is a sixth `@register`ed `PriceProvider` alongside
`generic_dayahead` and `manual_csv`. It was considered and rejected, on two
grounds that are mechanical rather than aesthetic:
1. A PROVIDER'S PRODUCT IS FIXED AT CLASS-DEFINITION TIME AND THIS ONE IS NOT.
`PriceProvider.capabilities()` is a `classmethod` taking no options, and
`load_prices()` reads `caps.product` to stamp the series. But §9.2 makes
STATIC and FIXED two DIFFERENT products with two different required EMS
behaviours, and which of the two a site is on is a property of the
OPERATOR'S DATA - one period or several - not of any class. A plugin would
have to declare one of them and be wrong for half its users, or the
framework would have to grow a per-call product override, which is a
change to TARIFF-02's contract for the benefit of one caller.
`StaticProfile.product` derives it from the periods instead (see below).
2. THE PLUGIN FRAMEWORK EXISTS TO SOLVE A PROBLEM THIS PATH DOES NOT HAVE.
`capabilities().sources` / `assemble()` exist because a FETCHED figure may
or may not already contain the supplier's markup, and adding it twice is
invisible (price.py, trap 1). A static profile's numbers are typed by the
operator as all-in; there is no publisher to disagree with, so the whole
`RawPoint` -> `assemble()` -> `PricePoint` pipeline would be an identity
function wrapped in a declaration nobody can check.
⚠️ WHAT IS *NOT* A REASON: "it is offline". `manual_csv` is offline and is a
plugin, correctly. Offline-ness is not what separates these two.
So the static profile sits UNDER the provider framework, as the layer §11.4
already says it is:
§11.4 No price data -> fall back to the static profile, mark the plan
provisional, no arbitrage.
which makes the §9.2 product option and the §11.4 degradation row THE SAME CODE
PATH, differing in exactly one flag. A fully-static site is one that leaves
`price_provider: off` (already the shipped default) and fills in
`static_profile`; a dynamic site whose feed died falls into the same generator
with `provisional=True`. One generator, two call sites, one flag - so the
fallback cannot rot, because it is the path a whole class of installs runs on
every day.
It emits `PricePoint`s in TARIFF-02's canonical `{start, eur_per_kwh,
provisional}` shape (§9.3, "so the optimiser compares like with like"), so
every downstream consumer works unchanged and none of them learns that a
tariff can be static.
HOW "NO ARBITRAGE" IS EXPRESSED SO A FUTURE OPTIMISER CANNOT IGNORE IT
======================================================================
A boolean called `arbitrage_ok` that OPT-04 is asked to remember to check is not
a mechanism; it is a comment with a type. So the numbers an arbitrage strategy
NEEDS - the spread, and which hours are the cheap and dear ends of it - EXIST
ONLY ON THE PERMITTING VARIANT of `Arbitrage`. `refused` carries `.reason` and
nothing else, and reaches for anything else raises `Absence`, the same shape
price.py borrowed from `sim/riglock.py`. An optimiser cannot compute a trade
from a refusal, because it cannot get the inputs out of one.
And the verdict is only obtainable from `TariffPlan.arbitrage(...)`, which takes
`peak_guard_active` as a REQUIRED KEYWORD ARGUMENT. §9.1 and §10.1 put peak
shaving (priority 2) above price arbitrage (priority 3) - the capacity tariff is
a network cost charged whatever the energy product is - and the way that
ordering is made to survive OPT-04 landing is that OPT-04 cannot obtain a spread
without first stating whether the peak guard is holding. It is not free to
forget the question; the call does not compile without an answer.
⚠️ FOUR REFUSALS, IN THIS ORDER, EACH DERIVED FROM SOMETHING MEASURED:
1. the peak guard is active §10.1: priority 2 outranks priority 3
2. no price data at all §11.4, and no static_profile to fall to
3. the plan is provisional §11.4 fallback / §8.3 unknown hours
4. the series is FLAT max - min == 0, so there is nothing to
arbitrage. THIS is AC2: a fully-fixed
tariff disables arbitrage by ARITHMETIC,
not by a special case keyed on
`PriceProduct.FIXED`. A dynamic feed
that happens to print one price all day
gets refused for the same true reason.
Nothing here decides a trade. The spread is handed over; OPT-04 owns
`cycle_cost_threshold_eur` (§10.1/§14) and what to do about it. ⚠️ Worth knowing
before that lands: on the Belgian day/night numbers in DOCS.md the whole-tariff
spread is 0.08 EUR/kWh, which is BELOW the shipped 0.10 threshold - so a
correct OPT-04 will decline to cycle for the tariff alone on such a site. That
is a product finding, not a bug here.
WHAT STAYS ON WHEN ARBITRAGE IS OFF (AC2, and §10.1 in full): priorities 1
(self-consumption), 2 (peak shaving), 4 (reserve management) and 5 (load
shifting) are untouched by any refusal above. Only priority 3 goes away. In
0.5.x that is literally true because none of them reads a price: the grid
following loop, the capacity-tariff peak tracker and the freeze/sign watchdogs
all run off P1 and know nothing about this module.
TIME-WINDOW SHIFTING, AND THE ONE SHIFTABLE LOAD THAT EXISTS TODAY
==================================================================
§9.2's handling for a two-period tariff is "time-window shifting", and §10.1
priority 5 is load shifting. The add-on has exactly one shiftable load in
0.5.x: the maintenance cycle's cheap-window grid top-up. `static_status()` is
what main.py reads for it, and `cheap_now()` is the rule:
cheap <=> the price now is STRICTLY BELOW the mean over the horizon
⚠️ STRICTLY BELOW, AND THE STRICTNESS IS AC2. Under a fully-fixed tariff every
hour equals the mean, so `<=` would make EVERY hour a cheap window and a fixed
tariff site would top the battery up from the grid at any hour of the day -
which is the exact opposite of "self-consumption only". With `<`, a flat series
has no cheap window at all, derived rather than special-cased. (The Home
Assistant `price_now_entity`/`price_avg_entity` pair main.py has always used
computes `now <= avg`; the difference is deliberate and is why this is not
simply the same expression.)
⚠️ THE MEAN IS TAKEN OVER SEVEN LOCAL DAYS, not over the 48 h forecast horizon,
and that is not tidiness. Belgian night tariffs usually cover the whole weekend,
so a 48 h mean computed on a Friday afternoon is weighted about 2/3 weekend and
the SAME Tuesday 09:00 comes out cheap or dear depending on the day you asked.
Seven days contains each weekday exactly once, so the mean is a property of the
tariff instead of a property of the hour you called at.
DAYLIGHT SAVING, AND WHY THERE IS NO `zoneinfo` HERE EITHER
============================================================
The shipped Alpine image has no `tzdata` (price.py's docstring, and the
Dockerfile), so `ZoneInfo("Europe/Brussels")` raises there. price.py already
carries `brussels()` / `brussels_offset()` - the EU rule, in arithmetic, checked
in review of PR #16 against `zoneinfo` over 149,040 hourly instants with zero
mismatches - and this module uses it rather than growing a second answer to the
same question.
Generation iterates HOURLY IN UTC and renders each instant to local, so a
spring-forward day yields 23 points and an autumn-back day 25, BY CONSTRUCTION.
An implementation that walked 24 local hours would emit 24 on both and misprice
an hour twice a year; `test_static_tariff.py` drives both 2026 transitions and
counts, rather than asserting 24.
⚠️ A WINDOW BELONGS TO THE DAY THE CLOCK READS, NOT THE DAY IT STARTED. A
`22:00-07:00 mon-fri` night window covers Friday 23:00 (Friday) but NOT
Saturday 03:00 - Saturday 03:00 is Saturday's, and Saturday needs its own
period. That is the only rule under which the tiling check below can be
decidable at all, and it is why the shipped Belgian example carries an
explicit weekend row.
VALIDATION: NEVER INVENT A PRICE FOR AN UNCOVERED HOUR
======================================================
For every day type the periods must tile the 1440-minute clock exactly once -
no gap, no overlap - and `parse_static_profile()` refuses anything else with the
uncovered or doubled range named in local time. `validate.py` calls it at
startup, so a malformed profile is a refusal to start, not a surprise at 02:00.
The alternative - defaulting an uncovered hour to some price - is the defect
price.py's whole `_NoData` type exists to prevent, one level down: an invented
number that arbitrage then acts on. A missing profile is not a zero price, and
a zero price is exactly the value that makes everything look free.
"""
import json
import logging
import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
_Outcome, brussels, load_prices, price_option,
)
_LOG = logging.getLogger("goodwe.tariff.static")
DAY_NAMES = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
_DAY_INDEX = {n: i for i, n in enumerate(DAY_NAMES)} # Mon=0, as weekday()
_DAY_ALIASES = {
"weekday": "mon-fri", "weekdays": "mon-fri", "week": "mon-fri",
"weekend": "sat-sun", "weekends": "sat-sun",
"all": "mon-sun", "daily": "mon-sun", "every": "mon-sun", "*": "mon-sun",
}
MINUTES_PER_DAY = 1440
# The mean that `cheap_now()` grades against is taken over this many local days.
# Seven, so each weekday appears exactly once - see the module docstring. It is
# a MODULE CONSTANT and not an option on purpose: CFG-03 already tracks three
# places option defaults live, and this describes the Belgian week rather than
# the site.
MEAN_WINDOW_DAYS = 7
# The only timezone a `static_profile` may name. See the docstring: there is no
# tz database in the shipped image, so anything else cannot be honoured and is
# refused rather than silently treated as Brussels.
SUPPORTED_TZ = ("europe/brussels", "cet", "cest", "")
class ProfileInvalid(Exception):
"""A `static_profile` that cannot be turned into a tariff.
Refused at startup by validate.py. Carries a message naming the key and the
fix, because the operator who typed it is the only one who can repair it.
"""
# --------------------------------------------------------------------------- #
# Parsing
# --------------------------------------------------------------------------- #
def _minutes(value, field: str) -> int:
""""HH:MM" -> minutes past local midnight. `to: "24:00"` means end-of-day."""
text = str(value).strip()
if not text:
raise ProfileInvalid(f"{field} is empty; write it as \"HH:MM\", e.g. "
f"\"07:00\"")
parts = text.split(":")
if len(parts) != 2:
raise ProfileInvalid(
f"{field} is {value!r}; it must be \"HH:MM\" local Belgian time, "
f"e.g. \"22:00\". A bare hour or a decimal is refused rather than "
f"guessed at - 7.5 could be 07:30 or 07:50.")
try:
h, m = int(parts[0]), int(parts[1])
except ValueError:
raise ProfileInvalid(f"{field} is {value!r}; \"HH:MM\" needs two "
f"integers, e.g. \"07:00\"")
if not (0 <= m < 60) or not (0 <= h <= 24) or (h == 24 and m != 0):
raise ProfileInvalid(
f"{field} is {value!r}, which is not a time of day. Hours run 0-23, "
f"minutes 0-59; \"24:00\" is accepted only as an END meaning "
f"midnight at the end of the day.")
return h * 60 + m
def _days(value, field: str) -> frozenset:
"""A day selector -> a frozenset of weekday() indices. Mon=0 .. Sun=6.
Accepts a list (`["mon", "tue"]`), a comma list (`"sat,sun"`), a range
(`"mon-fri"`), the aliases in `_DAY_ALIASES`, and any mixture.
"""
if value is None:
raise ProfileInvalid(
f"{field} is missing. Name the days this period applies to - "
f"\"mon-fri\", \"sat,sun\" or \"all\". It is required rather than "
f"defaulted to every day, because a Belgian night tariff that "
f"silently covered the weekend too would misprice 48 h a week with "
f"nothing reporting it.")
tokens = []
if isinstance(value, (list, tuple)):
for item in value:
tokens.extend(str(item).strip().lower().split(","))
else:
tokens.extend(str(value).strip().lower().split(","))
out = set()
for token in tokens:
token = _DAY_ALIASES.get(token.strip(), token.strip())
if not token:
continue
if "-" in token:
a, _, b = token.partition("-")
a, b = a.strip(), b.strip()
if a not in _DAY_INDEX or b not in _DAY_INDEX:
raise ProfileInvalid(
f"{field}: {token!r} is not a day range. Days are "
f"{', '.join(DAY_NAMES)}; a range is written \"mon-fri\".")
i, j = _DAY_INDEX[a], _DAY_INDEX[b]
# A range may wrap (`fri-mon`), because a Belgian weekend tariff is
# often written that way.
out.update((i + k) % 7 for k in range((j - i) % 7 + 1))
else:
if token not in _DAY_INDEX:
raise ProfileInvalid(
f"{field}: {token!r} is not a day. Use "
f"{', '.join(DAY_NAMES)}, a range like \"mon-fri\", or one "
f"of {', '.join(sorted(_DAY_ALIASES))}.")
out.add(_DAY_INDEX[token])
if not out:
raise ProfileInvalid(f"{field} selected no days at all")
return frozenset(out)
def _price(value, field: str) -> float:
"""The all-in EUR/kWh for a period, bounded exactly as price.py bounds a feed."""
if value is None:
raise ProfileInvalid(
f"{field} is missing. Every period needs an all-in eur_per_kwh "
f"(energy + supplier markup + taxes, §9.3) - the same basis the "
f"dynamic providers are assembled onto, so the two are comparable.")
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ProfileInvalid(f"{field} is {value!r}, which is not a number")
try:
out = float(value)
except (TypeError, ValueError):
raise ProfileInvalid(f"{field} is {value!r}, which is not a number")
if not math.isfinite(out):
raise ProfileInvalid(f"{field} is {value!r}, which is not finite")
if abs(out) > MAX_PLAUSIBLE_EUR_KWH:
raise ProfileInvalid(
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. The "
f"usual cause is a figure in cents or in EUR/MWh - 34 instead of "
f"0.34 - which would make every hour look like an emergency. Same "
f"bound the fetched feeds are held to (price.py `_validate_raw`).")
return out
@dataclass(frozen=True)
class Period:
"""One priced window of the local week. Half-open [start, end) on the clock.
`end <= start` means the window wraps past local midnight (22:00-07:00), and
`days` selects the day THE CLOCK READS, not the day the window opened - see
the module docstring.
"""
name: str
start_min: int
end_min: int
days: frozenset
eur_per_kwh: float
@property
def wraps(self) -> bool:
return self.end_min <= self.start_min
def covers(self, weekday: int, minute: int) -> bool:
if weekday not in self.days:
return False
if not self.wraps:
return self.start_min <= minute < self.end_min
return minute >= self.start_min or minute < self.end_min
@property
def words(self) -> str:
def hhmm(m):
return f"{m // 60:02d}:{m % 60:02d}"
days = ",".join(DAY_NAMES[d] for d in sorted(self.days))
return (f"{self.name} {hhmm(self.start_min)}-"
f"{hhmm(self.end_min if self.end_min else MINUTES_PER_DAY)} "
f"{days} @ {self.eur_per_kwh:.4f}")
class StaticProfile:
"""A validated two-or-more-period tariff. Immutable, pure, I/O-free."""
def __init__(self, periods):
self.periods = tuple(periods)
if not self.periods:
raise ProfileInvalid(
"static_profile has no periods. A profile with no periods is "
"not a fixed tariff, it is an absent one - leave the option "
"empty to say that on purpose (§11.4 then applies).")
_check_tiling(self.periods)
# -- what kind of tariff this is, DERIVED --------------------------------
@property
def prices(self) -> tuple:
return tuple(p.eur_per_kwh for p in self.periods)
@property
def product(self) -> PriceProduct:
"""§9.2's product, derived from the DATA rather than declared.
One distinct price across every period is a fully-fixed tariff whatever
the operator called the rows; two or more is a static profile. Nobody
has to tick a "this is fixed" box and nobody can tick it wrongly.
"""
return (PriceProduct.FIXED if len(set(self.prices)) == 1
else PriceProduct.STATIC)
# -- lookups -------------------------------------------------------------
def at(self, instant: datetime) -> Period:
"""The period covering an INSTANT. Tiling makes this total."""
local = brussels(instant)
minute = local.hour * 60 + local.minute
for p in self.periods:
if p.covers(local.weekday(), minute):
return p
# Unreachable given _check_tiling, and it stays an exception rather than
# a default for the reason the module docstring gives.
raise Absence(
f"no period covers {local.isoformat()} - the tiling check that "
f"makes this impossible has been weakened")
def price_at(self, instant: datetime) -> float:
return self.at(instant).eur_per_kwh
def next_change(self, instant: datetime, *, limit_days: int = 8):
"""The next instant at which the period NAME changes, or None.
Coarse 15-minute scan to bracket the change, then a 1-minute refine, so
this is ~800 lookups rather than ~11,500. Returns UTC.
"""
u = instant.astimezone(timezone.utc).replace(second=0, microsecond=0)
# ⚠️ KEYED ON (name, price), NOT ON THE NAME. Two rows may legitimately
# share a name - a Belgian night tariff is often written as a weekday
# row and a weekend row both called "night" - and the tariff has not
# changed when the clock crosses between them. But two rows with one
# name and DIFFERENT prices is a change an operator must see, and a
# name-only comparison would report the next change as never.
here = self._key(u)
coarse = timedelta(minutes=15)
end = u + timedelta(days=limit_days)
probe = u
while probe < end:
probe += coarse
if self._key(probe) != here:
fine = probe - coarse
while fine < probe:
fine += timedelta(minutes=1)
if self._key(fine) != here:
return fine
return probe
return None
def _key(self, instant):
p = self.at(instant)
return (p.name, p.eur_per_kwh)
# -- the canonical series ------------------------------------------------
def series(self, since: datetime, until: datetime, *,
provisional: bool) -> list:
"""Hourly `PricePoint`s over [since, until), in §9.3's canonical shape.
⚠️ ITERATES IN UTC. That is what makes a spring-forward day 23 points and
an autumn-back day 25 without anything here knowing the dates.
"""
a = since.astimezone(timezone.utc).replace(minute=0, second=0,
microsecond=0)
b = until.astimezone(timezone.utc)
out = []
u = a
while u < b:
out.append(PricePoint(start=brussels(u),
eur_per_kwh=round(self.price_at(u), 6),
provisional=bool(provisional)))
u += timedelta(hours=1)
if not out:
raise ProfileInvalid(
f"static_profile.series({since.isoformat()}, "
f"{until.isoformat()}) spans no whole hour. An empty series is "
f"not constructible downstream on purpose (price.py trap 3).")
return out
@property
def words(self) -> str:
kind = ("fully fixed" if self.product is PriceProduct.FIXED
else f"static, {len(set(self.prices))} price levels")
return f"{kind}: " + "; ".join(p.words for p in self.periods)
def _check_tiling(periods) -> None:
"""Every day type must be covered by EXACTLY ONE period, every minute.
⚠️ REFUSES BOTH DIRECTIONS. A gap means an hour with no price, and the only
way to serve it is to invent one. An overlap means two prices for one hour
and a silent winner decided by list order - the "two writers, one of which
quietly wins" shape validate.py exists to refuse.
"""
for weekday in range(7):
gap_from = over_from = None
for minute in range(MINUTES_PER_DAY):
n = sum(1 for p in periods if p.covers(weekday, minute))
if n == 0 and gap_from is None:
gap_from = minute
if n > 1 and over_from is None:
over_from = (minute, n)
if n != 0 and gap_from is not None:
_raise_gap(weekday, gap_from, minute)
if n <= 1 and over_from is not None:
_raise_overlap(weekday, over_from, minute, periods)
if gap_from is not None:
_raise_gap(weekday, gap_from, MINUTES_PER_DAY)
if over_from is not None:
_raise_overlap(weekday, over_from, MINUTES_PER_DAY, periods)
def _hhmm(m):
return f"{m // 60:02d}:{m % 60:02d}"
def _raise_gap(weekday, start, end):
raise ProfileInvalid(
f"static_profile leaves {DAY_NAMES[weekday]} "
f"{_hhmm(start)}-{_hhmm(end)} with no period, so those hours have no "
f"price. Refused rather than defaulted: an invented price is one an "
f"optimiser would trade on, and §11.4 already says what a missing price "
f"means. Add a period covering it - remember a window written "
f"\"22:00-07:00 mon-fri\" does NOT cover Saturday morning.")
def _raise_overlap(weekday, over, end, periods):
start, n = over
names = [p.name for p in periods if p.covers(weekday, start)]
raise ProfileInvalid(
f"static_profile gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Two prices for "
f"one hour means one of them silently wins on list order. Narrow one of "
f"the windows or split the day selector.")
def parse_static_profile(value):
"""`static_profile` -> a `StaticProfile`, or None when unset.
Accepts what the option can actually arrive as:
* `None` / `""` / `"null"` - unset. §14 ships `static_profile: null`.
* a JSON string - what config.yaml's flat `str?` option
carries on a Supervisor install.
* a dict `{"periods": [...]}` - already-parsed, from /data/options.json
under a future nested schema, from
sim/inject.py, or from a check.
* a bare list of period dicts - the same thing without the wrapper.
Raises `ProfileInvalid`; never returns a half-built profile.
"""
if value is None:
return None
if isinstance(value, str):
text = value.strip()
if not text or text.lower() in ("null", "none", "off"):
return None
try:
value = json.loads(text)
except ValueError as e:
raise ProfileInvalid(
f"static_profile is not valid JSON ({e}). It is a JSON object "
f"with a \"periods\" list, on one line, e.g.\n"
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
f"Leave it empty for a dynamic-only or price-free site.")
if isinstance(value, dict):
tz = str(value.get("timezone", "") or "").strip().lower()
if tz not in SUPPORTED_TZ:
raise ProfileInvalid(
f"static_profile.timezone is {value.get('timezone')!r}. Only "
f"Europe/Brussels is supported: the shipped image carries no tz "
f"database (see app/price.py), so any other zone could not be "
f"honoured and would be silently treated as Brussels - which is "
f"wrong by an hour for part of the year.")
rows = value.get("periods")
if rows is None:
raise ProfileInvalid(
"static_profile has no \"periods\" list. Even a fully-fixed "
"tariff is written as one period covering 00:00-24:00 all days "
"- one shape for both products, so nothing downstream branches "
"on which kind of site this is.")
else:
rows = value
if not isinstance(rows, (list, tuple)):
raise ProfileInvalid(
f"static_profile.periods is {type(rows).__name__}, not a list")
periods = []
for i, row in enumerate(rows, start=1):
if not isinstance(row, dict):
raise ProfileInvalid(
f"static_profile.periods[{i}] is {type(row).__name__}, not an "
f"object with from/to/days/eur_per_kwh")
where = f"static_profile.periods[{i}]"
name = str(row.get("name", "") or f"period{i}").strip() or f"period{i}"
start = _minutes(row.get("from", row.get("start")), f"{where}.from")
end = _minutes(row.get("to", row.get("end")), f"{where}.to")
if end == 0:
end = MINUTES_PER_DAY # "00:00" as an END is end-of-day
if start == end:
raise ProfileInvalid(
f"{where} runs {_hhmm(start)}-{_hhmm(end)}, which is either "
f"zero minutes or the whole day and there is no way to tell "
f"which was meant. Write a full day as 00:00-24:00.")
if start == MINUTES_PER_DAY:
raise ProfileInvalid(
f"{where}.from is \"24:00\"; a period cannot START at "
f"end-of-day. Did you mean \"00:00\"?")
periods.append(Period(name=name, start_min=start,
end_min=end,
days=_days(row.get("days"), f"{where}.days"),
eur_per_kwh=_price(row.get("eur_per_kwh"),
f"{where}.eur_per_kwh")))
return StaticProfile(periods)
# --------------------------------------------------------------------------- #
# The arbitrage verdict. A variant type, because a boolean is a suggestion.
# --------------------------------------------------------------------------- #
class Arbitrage(_Outcome):
"""May an optimiser trade on this tariff, and with what numbers.
* `allowed` - carries `.spread_eur_kwh`, `.cheapest`, `.dearest`,
`.regime`, `.product`. These are the ONLY inputs an
arbitrage strategy needs, and they exist here and nowhere
else.
* `refused` - carries `.reason`. Nothing else. `.spread_eur_kwh` on a
refusal raises `Absence`.
⚠️ NO `__bool__`, for price.py's reason: `if verdict:` must not look like a
guard while being none. Use `.match(allowed=..., refused=...)`, both
handlers required, so the refusal cannot be fallen through.
"""
def match(self, *, allowed, refused):
return {"allowed": allowed, "refused": refused}[self.kind](self)
@staticmethod
def allow(spread, cheapest, dearest, regime, product):
return _Allowed(spread, cheapest, dearest, regime, product)
@staticmethod
def refuse(reason):
if not reason or not isinstance(reason, str):
raise ValueError(
"Arbitrage.refuse() takes a reason a human can act on - §10.2 "
"requires every plan change to carry one, and 'arbitrage did "
"nothing' with no reason is indistinguishable from a bug.")
return _Refused(reason)
class _Allowed(Arbitrage):
kind = "allowed"
def __init__(self, spread, cheapest, dearest, regime, product):
self.spread_eur_kwh = spread
self.cheapest = cheapest # the PricePoint at the low end
self.dearest = dearest # the PricePoint at the high end
self.regime = regime # §8.3 "wide" | "normal"
self.product = product
self.words = (f"spread {spread:.4f} EUR/kWh over "
f"{product.value} ({regime} thresholds)")
class _Refused(Arbitrage):
kind = "refused"
def __init__(self, reason):
self.reason = reason
self.words = f"no arbitrage - {reason}"
# --------------------------------------------------------------------------- #
# The plan: one tariff, whatever produced it
# --------------------------------------------------------------------------- #
class TariffPlan:
"""What this site's tariff looks like right now, and what may be done on it.
`origin` is how the series was obtained, and it is NOT the same question as
`product`:
"provider" a live feed (§9.2 rows 1-2)
"static" the CONFIGURED static_profile (§9.2 rows 3-4)
"fallback" the static_profile standing in for a dead feed (§11.4) -
identical data, `provisional=True`, arbitrage refused
"none" no feed and no profile. §6: the loop keeps regulating.
"""
def __init__(self, read: PriceRead, profile, origin: str):
self.read = read
self.profile = profile
self.origin = origin
# -- derived facts -------------------------------------------------------
@property
def has_prices(self) -> bool:
return self.read.match(series=lambda _r: True, no_data=lambda _r: False)
@property
def product(self):
return self.read.match(series=lambda r: r.product,
no_data=lambda _r: None)
@property
def provisional(self) -> bool:
"""§8.3/§11.4, derived from the points rather than asserted."""
return self.read.match(
series=lambda r: any(p.provisional for p in r.points),
no_data=lambda _r: True)
def price_at(self, instant: datetime) -> float:
"""EUR/kWh now. Raises `Absence` when there is no price - never 0.0."""
return self.read.match(
series=lambda r: r.at(instant).eur_per_kwh,
no_data=lambda r: _absent(r.reason))
def window_at(self, instant: datetime) -> str:
"""The name of the tariff window now (`day`, `night`, ...).
Only a static profile has named windows; a day-ahead series does not,
and inventing one would be a label with no referent.
"""
if self.profile is None:
raise Absence(
"this plan has no static_profile, so it has no named tariff "
"window. Time-window shifting (§9.2) is a static-profile "
"behaviour; a day-ahead series is shifted against prices, not "
"against window names.")
return self.profile.at(instant).name
def mean_eur_kwh(self) -> float:
return self.read.match(
series=lambda r: sum(p.eur_per_kwh for p in r.points) / len(r.points),
no_data=lambda r: _absent(r.reason))
def spread_eur_kwh(self) -> float:
return self.read.match(
series=lambda r: (max(p.eur_per_kwh for p in r.points)
- min(p.eur_per_kwh for p in r.points)),
no_data=lambda r: _absent(r.reason))
# -- the two things a consumer is allowed to act on ----------------------
def cheap_now(self, instant: datetime) -> bool:
"""Is now a cheap window? STRICTLY below the mean - see the docstring.
False on a provisional plan: §8.3 forbids an irreversible action on a
provisional price, and a grid top-up of a battery is about as
irreversible as this add-on gets. False when there is no price at all,
which is the §11.4 row and also main.py's long-standing "fixed-tariff
site: never force a paid grid top-up, wait for sun".
"""
if not self.has_prices or self.provisional:
return False
try:
return self.price_at(instant) < self.mean_eur_kwh()
except Absence:
# An instant outside the series is an hour with no price (§11.4 for
# that hour), not a cheap one.
return False
def arbitrage(self, *, peak_guard_active: bool) -> Arbitrage:
"""May OPT-04 trade, and on what numbers. See the module docstring.
⚠️ `peak_guard_active` IS REQUIRED AND KEYWORD-ONLY. §9.1/§10.1 put peak
shaving above arbitrage, and this is how that ordering survives a
strategy that has not been written yet: the spread is not obtainable
without answering the question.
"""
if not isinstance(peak_guard_active, bool):
raise TypeError(
f"peak_guard_active must be a bool, not {peak_guard_active!r}. "
f"A truthy 'unknown' is how §10.1's priority 2 gets skipped.")
if peak_guard_active:
return Arbitrage.refuse(
"the capacity-tariff peak guard is holding, and §10.1 puts peak "
"shaving (priority 2) above price arbitrage (priority 3) - the "
"capaciteitstarief is a network cost charged whatever the "
"energy product is (§9.1)")
if not self.has_prices:
return Arbitrage.refuse(
f"§11.4: {self.read.reason}. No series exists, so no spread "
f"exists; self-consumption, peak shaving and reserve management "
f"(§10.1 priorities 1, 2 and 4) are unaffected")
if self.provisional:
return Arbitrage.refuse(
f"§11.4: the plan is provisional (origin {self.origin!r}), and "
f"§8.3 forbids an irreversible action on a provisional price. "
f"Load shifting and peak shaving continue")
spread = self.spread_eur_kwh()
if spread == 0.0:
return Arbitrage.refuse(
f"the tariff is flat - every one of the "
f"{len(self.read.points)} hours costs "
f"{self.read.points[0].eur_per_kwh:.4f} EUR/kWh, so buying low "
f"and selling high is arithmetically impossible. §9.2: a fully "
f"fixed product is self-consumption only")
pts = self.read.points
return Arbitrage.allow(
spread=spread,
cheapest=min(pts, key=lambda p: p.eur_per_kwh),
dearest=max(pts, key=lambda p: p.eur_per_kwh),
regime=self.read.regime,
product=self.read.product)
@property
def words(self) -> str:
if not self.has_prices:
return f"no tariff - {self.read.reason}"
tag = {"provider": self.read.provider, "static": "static_profile",
"fallback": "static_profile (feed down)"}.get(self.origin,
self.origin)
return (f"{self.product.value} from {tag}, {len(self.read.points)} h"
+ (", provisional" if self.provisional else ""))
def _absent(reason):
raise Absence(
f"this plan observed no prices ({reason}). A number invented here is "
f"the bug price.py's _NoData exists to prevent - 'no data' becoming "
f"'free'. Ask has_prices, or use read.match(...).")
# --------------------------------------------------------------------------- #
# The two entry points
# --------------------------------------------------------------------------- #
def static_plan(opts: dict, since: datetime, until: datetime, *,
provisional: bool = False, now=None) -> TariffPlan:
"""The static-profile plan. PURE, I/O-FREE, no network, no tz database.
This is what main.py calls on the planning tick. It is synchronous on
purpose: `load_prices()` may perform an HTTP fetch, and a fetch on the tick
that feeds the ESP32 heartbeat is a supplier's TLS timeout able to stall the
heartbeat towards layer 1's 30 s veto. Generation is pure, so §2.3 says
regenerate every tick rather than cache, and there is nothing here that can
block.
"""
profile = parse_static_profile(price_option(opts, "static_profile"))
if profile is None:
return TariffPlan(
PriceRead.no_data(
"no static_profile configured; §11.4: plan provisional, no "
"arbitrage. Self-consumption, peak shaving and reserve "
"management are unaffected"),
None, "none")
points = profile.series(since, until, provisional=provisional)
return TariffPlan(
PriceRead.series(points, profile.product, "static_profile",
now or datetime.now(timezone.utc)),
profile, "fallback" if provisional else "static")
def static_status(opts: dict, now: datetime):
"""(cheap, words) for the status page and for main.py's cheap-window flag.
Never raises: a `static_profile` that does not parse is refused at STARTUP
by validate.py, so reaching this with a broken one means somebody handed the
controller a dict the validator never saw. That is worth a warning and a
"no cheap window", not a controller that stops regulating a battery (§6:
absent machinery must not disable the loop).
"""
try:
plan = static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
now=now)
except ProfileInvalid as e:
_LOG.warning("static_profile is unusable (%s); no cheap window. This "
"should have been refused at startup - see validate.py", e)
return False, f"static_profile unusable: {e}"
if not plan.has_prices:
return False, "no static profile"
cheap = plan.cheap_now(now)
window = plan.window_at(now)
price = plan.price_at(now)
when = plan.profile.next_change(now)
until = "" if when is None else f" until {brussels(when):%H:%M}"
kind = ("fixed" if plan.product is PriceProduct.FIXED else "day/night")
return cheap, (f"{kind}: {window}{until} at {price:.4f} EUR/kWh, "
f"mean {plan.mean_eur_kwh():.4f} over "
f"{MEAN_WINDOW_DAYS} d - "
f"{'CHEAP window' if cheap else 'not a cheap window'}")
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
transport=None, now=None) -> TariffPlan:
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
omission. Wiring a provider-backed plan into the control loop needs a
BACKGROUND refresh (a day-ahead feed publishes once a day; polling it every
30 s would be rude and would put a network timeout on the heartbeat path).
That task belongs with the first strategy that actually trades - OPT-04 -
and this function is the seam it consumes. Everything below it is exercised
by test_static_tariff.py today.
price_provider off + profile -> the CONFIGURED product (§9.2 rows 3-4)
price_provider off + none -> no prices at all (§11.4, nothing to
fall back to)
provider series -> the feed (§9.2 rows 1-2)
provider no_data + profile -> §11.4 FALLBACK, provisional, no arbitrage
provider no_data + none -> no prices at all
"""
stamp = now or datetime.now(timezone.utc)
pid = str(price_option(opts, "price_provider") or "off").strip()
if pid in ("", "off"):
return static_plan(opts, since, until, provisional=False, now=stamp)
read = await load_prices(opts, since, until, transport)
return read.match(
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
no_data=lambda r: _degrade(opts, r, since, until, stamp))
def _quiet_profile(opts):
"""The profile, for window naming, without letting a bad one break a good feed."""
try:
return parse_static_profile(price_option(opts, "static_profile"))
except ProfileInvalid:
return None
def _degrade(opts, read, since, until, stamp) -> TariffPlan:
"""§11.4: fall back to the static profile, mark provisional, no arbitrage."""
try:
profile = parse_static_profile(price_option(opts, "static_profile"))
except ProfileInvalid as e:
_LOG.warning("the price feed is down AND static_profile is unusable "
"(%s), so there is no tariff at all", e)
profile = None
if profile is None:
_LOG.warning("§11.4: %s, and no static_profile is configured to fall "
"back to. The plan is provisional and arbitrage is off; "
"self-consumption, peak shaving and reserve management "
"continue (§6, §10.1).", read.reason)
return TariffPlan(read, None, "none")
_LOG.warning("§11.4: %s - falling back to the static profile, plan marked "
"PROVISIONAL, arbitrage off.", read.reason)
points = profile.series(since, until, provisional=True)
return TariffPlan(
PriceRead.series(points, profile.product, "static_profile", stamp),
profile, "fallback")
+94 -24
View File
@@ -157,6 +157,7 @@ import logging
from .p1 import is_enabled, validate_meter_options
from .price import PROVIDERS
from .static_tariff import ProfileInvalid, parse_static_profile
_LOG = logging.getLogger("goodwe.config")
@@ -410,8 +411,13 @@ def _price_problems(opts: dict) -> list[str]:
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.
Four refusals and two log lines (TARIFF-03 added one refusal, one parse and
two notices), and every refusal is still case (a) from this module's
docstring: a source the operator SET being overridden by a layer they cannot
see. The additions are `static_profile` alongside the Home Assistant price
pair - the same silent-loser shape as provider-plus-pair, and a LIVE one -
and a `static_profile` that does not tile the day, which would leave hours
with no price at all.
"""
problems: list[str] = []
pid = str(opts.get("price_provider", "") or "off").strip() or "off"
@@ -426,24 +432,35 @@ def _price_problems(opts: dict) -> list[str]:
return problems # nothing below can be judged against an unknown id
# ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE.
# price_now_entity/price_avg_entity are the existing delegation of pricing
# to Home Assistant: main.py reduces them to ONE BOOLEAN, `cheap`, read only
# by the maintenance cheap-window charge floor. They are KEPT (TARIFF-03 and
# OPT-04 are what replace them, and neither has landed), but they may not
# run alongside a provider. With both configured the provider series drives
# nothing at all while the two entities quietly keep deciding, and the
# operator's evidence that they picked a supplier is a status line saying
# they picked a supplier. That is (a), and it is the "silently double up"
# the PM named on 2026-08-30.
# ⚠️ THIS REFUSAL'S REASON EXPIRES, AND THE MESSAGE BELOW MUST BE EDITED IN
# THE SAME COMMIT THAT LANDS THE FIRST CONSUMER (TARIFF-03 or OPT-04).
# Today the message says the provider "would decide nothing" - true, because
# nothing reads the series. Once something does, both sources really decide
# and this becomes a straightforward two-writer conflict; the refusal stays
# right and its explanation goes wrong, which is worse than either. There is
# a tripwire on exactly this in test_price.py section 12: it greps app/ for
# a `load_prices` consumer and goes RED the moment one appears. Update the
# message, not the check.
# price_now_entity/price_avg_entity are the original delegation of pricing
# to Home Assistant: main.py reduces them to ONE BOOLEAN, `cheap`, read by
# the maintenance cheap-window charge floor. They are KEPT - retiring them
# would take a live install's only price signal away - but they may not run
# alongside another price source, because main.py reads exactly one and the
# loser is silent. That is (a), and it is the "silently double up" the PM
# named on 2026-08-30.
#
# ⚠️ THE REASON THIS REFUSAL GIVES WAS REWRITTEN BY TARIFF-03, AND THE OLD
# WORDING IS WHY. Until TARIFF-03 this message said the configured provider
# "would decide nothing", which was true only because nothing consumed the
# series; test_price.py section 12 carried a tripwire that went red the
# moment a consumer landed, with instructions to fix the wording here in the
# same commit. It landed, it went red, and this is that fix. What is true
# NOW, stated exactly and no wider:
#
# * app/static_tariff.py consumes load_prices(). resolve_tariff() turns a
# provider series into a TariffPlan, a cheap-window flag and an
# arbitrage verdict, so the seam is no longer inert.
# * main.py reads the STATIC side of that module every cycle
# (static_status()), which makes static_profile a real writer of `cheap`.
# * main.py does NOT yet call resolve_tariff() on the control tick, and
# that is deliberate: a provider fetch is network I/O and the tick is
# what feeds the ESP32 heartbeat. So a configured PROVIDER still shapes
# no energy decision on its own; a configured STATIC PROFILE does.
#
# Hence two refusals below rather than one, and neither claims more than
# that list. When OPT-04 puts resolve_tariff() on a background refresh, the
# first message's middle clause is what changes - not the rule.
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:
@@ -452,12 +469,65 @@ def _price_problems(opts: dict) -> list[str]:
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 "
"reads. The provider series IS consumed now - app/static_tariff.py "
"builds a tariff plan and an arbitrage verdict from it - but it is "
"not what main.py acts on, so the supplier you configured would "
"shape no energy decision while the status page named it. Clear "
"price_now_entity and price_avg_entity to use the "
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
"Home Assistant sensors.")
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
# from the Home Assistant pair when it is set and from the static profile
# otherwise - one flag, first writer wins, and the operator who filled in a
# day/night tariff has no way to see that it lost.
if ha_price and str(opts.get("static_profile", "") or "").strip():
problems.append(
f"static_profile is set AND {' and '.join(ha_price)} "
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two "
"tariff sources feeding one cheap-window flag. THE HOME ASSISTANT "
"ENTITIES WIN: main.py reads them first and never looks at the "
"static profile, so the day/night tariff you typed would decide "
"nothing and nothing would say so. Clear price_now_entity and "
"price_avg_entity to use static_profile, or clear static_profile "
"to keep the Home Assistant sensors.")
# ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile
# that does not tile the day has hours with no price, and the only ways to
# serve those are to invent a number or to fail at the moment the clock
# reaches them. §14's warning box - refuse to start on a mismatch rather
# than clamp quietly - is the same instruction one level down.
raw_profile = opts.get("static_profile", "")
if str(raw_profile or "").strip() or isinstance(raw_profile, (list, dict)):
try:
profile = parse_static_profile(raw_profile)
except ProfileInvalid as e:
profile = None
problems.append(f"static_profile cannot be used: {e}")
if profile is not None and profile.product is not None \
and len(set(profile.prices)) == 1:
_LOG.info(
"static_profile carries one distinct price (%.4f EUR/kWh "
"all-in), so this site is priced as FULLY FIXED. Arbitrage is "
"off by arithmetic - a flat series has no spread - and the "
"add-on runs self-consumption only, which is §9.2's handling "
"for this product. Peak shaving, reserve management and the "
"maintenance cycle are unaffected.", profile.prices[0])
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
# site with no price data falls back to the static profile; a dynamic site
# with none configured has nothing to fall back to when its feed dies. That
# is a real gap worth naming - but refusing to start over it would break
# every install TARIFF-02 shipped, none of which could have had one.
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
_LOG.warning(
"price_provider is %r but static_profile is empty. §11.4's "
"degradation row is 'no price data -> fall back to the static "
"profile'; with none configured, a feed outage leaves this site "
"with no tariff at all rather than with a coarse one. Not refused, "
"but filling it in is what makes that row work.", pid)
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 "
+36
View File
@@ -204,6 +204,37 @@ options:
price_horizon_h: 48
price_timeout_s: 10
# --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ----------
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
# of the installed base is on [a static profile]" - this is what makes those
# sites first-class rather than "price_provider: off, good luck".
#
# A JSON object, on one line, whose periods must tile every day type exactly
# once - no gap and no overlap. A gap is REFUSED at startup rather than
# defaulted, because an invented price is one an optimiser would trade on.
# A fully-fixed tariff is ONE period covering 00:00-24:00, all days; the
# add-on derives "fixed" from there being one distinct price, so there is no
# box to tick and none to tick wrongly.
#
# {"periods": [
# {"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
# {"name":"night","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
# {"name":"night","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
#
# ⚠️ A window written "22:00-07:00 mon-fri" does NOT cover Saturday morning -
# a period applies on the day the CLOCK reads, so the weekend row above is
# required, not decorative. See DOCS.md.
#
# eur_per_kwh is ALL-IN (energy + markup + taxes, §9.3), the same basis the
# dynamic providers are assembled onto. price_markup_eur_kwh and
# price_taxes_eur_kwh are NOT added to it.
#
# ⚠️ RECOMMENDED EVEN ON A DYNAMIC SITE. §11.4's degradation row is "no price
# data -> fall back to the static profile"; with this empty there is nothing
# to fall back to and a dead feed leaves the site with no tariff at all.
# validate.py warns about exactly that.
static_profile: ""
# §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.
@@ -296,6 +327,11 @@ schema:
price_feed_url: str?
price_horizon_h: int(1,72)
price_timeout_s: int(2,60)
# A JSON string rather than a nested schema, deliberately: it is ONE option
# key (§14 has one), it survives config.yaml's flat options block, and
# parse_static_profile() accepts an already-parsed dict too, so a future
# nested schema is a config.yaml change and no code change at all.
static_profile: str?
# ⚠️ 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.
+46 -15
View File
@@ -1220,23 +1220,42 @@ 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"))
# TARIFF-03 added a second source with the same silent-loser shape.
_ps = validate_startup_config(
vbase(static_profile='{"periods": [{"from":"00:00","to":"24:00",'
'"days":"all","eur_per_kwh":0.31}]}',
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
-5000, 5000)
check("a static_profile AND the Home Assistant price entities is refused too - "
"main.py reads one of them for `cheap` and the loser is silent",
len(_ps) == 1 and why(_ps, "HOME ASSISTANT"))
check("the Home Assistant entities alone still start, so nothing is taken away "
"from a live install",
validate_startup_config(vbase(price_now_entity="sensor.now",
price_avg_entity="sensor.avg"),
-5000, 5000) == [])
# ⚠️ A TRIPWIRE ON A REASON, NOT ON A BEHAVIOUR, AND IT IS AIMED AT WHOEVER
# LANDS TARIFF-03 OR OPT-04. Rule 7 refuses "provider + HA entities" and its
# stated reason is that the provider series decides NOTHING while the HA pair
# quietly keeps deciding. The refusal stays correct forever; its EXPLANATION
# expires the moment a consumer appears - at that point both really do decide,
# and it becomes a genuine two-writer conflict rather than a silently-ignored
# one. A future reader would then be trusting a reason that is no longer the
# reason, which is the same defect TEST-06's boundary comment names.
# ⚠️ THIS TRIPWIRE FIRED, AND THIS IS WHAT IT LOOKS LIKE AFTERWARDS.
#
# So: assert that nothing in app/ consumes the series yet. When you wire
# load_prices() into the optimiser this goes RED, and the fix is to update the
# refusal message in validate.py IN THE SAME COMMIT - not to delete this check.
# TARIFF-02 left a check here asserting that NOTHING in app/ consumed
# `load_prices()`, because rule 7's stated reason - "the supplier you configured
# would decide nothing" - was true only for as long as that held. The
# instruction attached to it was: when this goes red, update the message in
# validate.py in the same commit, do not delete the check.
#
# TARIFF-03 landed `app/static_tariff.py`, whose `resolve_tariff()` consumes
# `load_prices()` and turns a series into a tariff plan, a cheap-window flag and
# an arbitrage verdict. The check went red, naming that file, and validate.py's
# message was rewritten in the same commit to say what is true now instead. So
# the tripwire is KEPT and RE-AIMED rather than removed, at the next thing that
# can go quietly wrong:
#
# * the consumer set is PINNED. A second consumer appearing - OPT-04 wiring a
# strategy in, most likely - goes red again, and whoever does that has to
# re-read rule 7's message against what their change makes true.
# * the EXPIRED PHRASE is asserted GONE. If the old wording is ever restored
# by a bad merge, this says so.
# * and the message has to keep naming its consumer, so it cannot decay into
# a refusal with no stated reason at all.
_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app")
# ⚠️ AST, NOT A SUBSTRING, AND THE FIRST DRAFT PROVED WHY. A text scan for
# "load_prices" matched the COMMENT in validate.py that explains this very
@@ -1264,10 +1283,22 @@ _consumers = derived(lambda: sorted(
f for f in os.listdir(_APP)
if f.endswith(".py") and f != "price.py"
and _imports_or_calls(os.path.join(_APP, f), "load_prices")))
check(f"no module consumes load_prices() yet, so rule 7's stated reason - 'the "
f"provider decides nothing' - is still TRUE {_consumers}. When this goes "
f"red, update that message in validate.py in the same commit",
_consumers == [])
_EXPECTED_CONSUMERS = ["static_tariff.py"]
check(f"load_prices() is consumed by exactly the modules rule 7's message "
f"names {_consumers}. When a SECOND one appears this goes red, and the "
f"fix is to re-read that message against what the new consumer makes "
f"true - not to widen this list without doing so",
_consumers == _EXPECTED_CONSUMERS)
_RULE7 = derived(lambda: open(os.path.join(_APP, "validate.py"),
encoding="utf-8").read())
check("...and the phrase the tripwire expired - 'would decide nothing' about "
"the provider - is GONE from validate.py, so a bad merge that restores "
"the old reason is red rather than quiet",
isinstance(_RULE7, str)
and "supplier you configured would decide nothing" not in _RULE7)
check("...and the refusal still names the consumer that exists, so it cannot "
"decay into a rule with no stated reason",
isinstance(_RULE7, str) and "static_tariff.py" in _RULE7)
# ...and the other half: the registry IS on the startup path, which is what
# makes an unwired seam anchored rather than dead code.
check("...while validate.py does import the registry, so the seam is exercised "
+861
View File
@@ -0,0 +1,861 @@
"""Runnable check for static day/night and fully-fixed tariffs. `python3 test_static_tariff.py`
WHAT THIS FILE IS DEFENDING, in the order the ticket's acceptance criteria name
it:
AC1 `static_profile` (§14) drives TIME-WINDOW SHIFTING for a two-period
tariff - the right window at the right local instant, on both sides of
both 2026 DST transitions, and reaching the one shiftable load the add-on
actually has.
AC2 Fully-fixed pricing DISABLES ARBITRAGE and leaves self-consumption only.
AC3 The Belgian scenario suite carries a static-profile regression case.
THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
* EVERY VERDICT IS DERIVED. No `check("the split is right", True)`. Where a
check needs an expected number it is computed HERE, by different arithmetic
than app/static_tariff.py uses - the window split is re-derived by summing
over samples with an independent local-hour test, the profile mean by a
second pass, the saving by two routes that must agree.
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE. A
two-period tariff has this trap BUILT IN and it is the main hazard here:
- THE PERIOD BOUNDARY IS THE DEGENERACY. At 07:00 and at 22:00 the "which
window" question and the "is it cheap" question agree under almost any
bug, and a probe at 12:00 cannot tell a correct wrap-around from one
that treats 22:00-07:00 as 22:00-24:00. So probes sit at 06:59 / 07:00 /
07:01 and 21:59 / 22:00 / 22:01, and a Saturday 03:00 probe exists
specifically because the mon-fri night window MUST NOT cover it.
- MEAN-CHEAP AND MINIMUM-CHEAP AGREE ON A TWO-LEVEL TARIFF. Under two
prices "below the mean" and "is the cheapest price" are the same set, so
a two-period profile cannot tell `cheap_now` from `price == min`.
Section 3 therefore runs a THREE-level profile where those two answers
differ, and asserts they differ before grading anything with it.
- DAY AND NIGHT MUST HAVE DIFFERENT PRICES AND NEITHER MAY BE ZERO, or the
whole shifting question collapses. Asserted as checks in their own
right, first, rather than assumed.
- THE DST CHECKS RUN ON THE 23-HOUR AND 25-HOUR DAYS, not on a normal day
where a 24-slot implementation and a correct one agree exactly.
- THE REPLAY DAY MUST CARRY IMPORT IN BOTH WINDOWS. A day whose whole
import fell in one window grades no split at all; section 6 asserts that
before it reports a saving.
* A FAILED READ IS NOT AN OBSERVATION. A missing static_profile is not a zero
price, and zero is exactly the value that makes everything look free. So
`static_plan` with no profile is checked to produce a read with NO
`.points`, and `price_at` on it must RAISE rather than answer 0.0.
"""
import gzip
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from app.price import (
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
plan_is_provisional,
)
from app.static_tariff import (
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
parse_static_profile, resolve_tariff, static_plan, static_status,
)
from app.validate import validate_startup_config
HERE = Path(__file__).resolve().parent
UTC = timezone.utc
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def raises(fn, exc):
"""True only if fn() raises EXACTLY `exc`. A different exception is False -
'it was refused' and 'it broke on the way to being refused' must not grade
the same."""
try:
fn()
except exc:
return True
except Exception:
return False
return False
def reason(fn):
try:
fn()
except Exception as e: # noqa: BLE001
return str(e)
return ""
class _Raised:
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):
"""A value a check grades. An exception becomes `_Raised`, which equals
nothing and is falsy - NOT a default. See test_price.py for why."""
try:
return fn()
except Exception as e: # noqa: BLE001
return _Raised(e)
def near(a, b, tol=1e-9):
try:
return abs(float(a) - float(b)) <= tol
except (TypeError, ValueError):
return False
# --------------------------------------------------------------------------- #
# The fixtures. Belgian day/night numbers, and their degeneracies asserted.
# --------------------------------------------------------------------------- #
DAY_P = 0.3410 # all-in EUR/kWh, day
NIGHT_P = 0.2570 # all-in EUR/kWh, night
FIXED_P = 0.3070 # all-in EUR/kWh, single-tariff site
TWO_PERIOD = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
"eur_per_kwh": DAY_P},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
"eur_per_kwh": NIGHT_P},
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
"eur_per_kwh": NIGHT_P},
]}
FULLY_FIXED = {"periods": [
{"name": "fixed", "from": "00:00", "to": "24:00", "days": "all",
"eur_per_kwh": FIXED_P},
]}
# ⚠️ THREE LEVELS, AND SECTION 3 EXISTS BECAUSE OF IT. Under two prices "cheaper
# than the mean" and "is the minimum price" are the same set of hours, so a
# two-period profile cannot distinguish `cheap_now`'s rule from `price == min`.
# Here the middle level sits BELOW the mean without being the minimum (the four
# expensive evening hours pull the mean up past it), so those two rules give
# DIFFERENT answers at 12:00 - and section 3 proves they differ before grading
# anything with it.
THREE_LEVEL = {"periods": [
{"name": "peak", "from": "17:00", "to": "21:00", "days": "all",
"eur_per_kwh": 0.4800},
{"name": "day", "from": "07:00", "to": "17:00", "days": "all",
"eur_per_kwh": 0.2900},
{"name": "evening", "from": "21:00", "to": "24:00", "days": "all",
"eur_per_kwh": 0.2900},
{"name": "night", "from": "00:00", "to": "07:00", "days": "all",
"eur_per_kwh": 0.1900},
]}
def opts(profile=None, **over):
o = {"price_provider": "off",
"static_profile": "" if profile is None else json.dumps(profile)}
o.update(over)
return o
# --------------------------------------------------------------------------- #
print("0. the arithmetic this file grades with must not be degenerate")
# ⚠️ These ARE the guard against a section that passes for the wrong reason. If
# any goes red, every shifting assertion below is measuring nothing.
check("day and night prices differ, so 'used the wrong window' is visible",
DAY_P != NIGHT_P)
check("...and neither is zero, so 'dropped the price entirely' is visible",
DAY_P != 0 and NIGHT_P != 0)
check("...and the fixed price is neither of them, so a fixed-tariff result "
"cannot be a day/night result in disguise",
FIXED_P not in (DAY_P, NIGHT_P))
check("...and the day/night spread is not the fixed price, so 'reported the "
"spread where the price was wanted' is visible",
not near(DAY_P - NIGHT_P, FIXED_P))
_lv = sorted({p["eur_per_kwh"] for p in THREE_LEVEL["periods"]})
check(f"the three-level fixture really has three levels {_lv}", len(_lv) == 3)
# --------------------------------------------------------------------------- #
print()
print("1. AC1 - the two-period profile puts the right window on the right hour")
TWO = parse_static_profile(json.dumps(TWO_PERIOD))
check("a JSON string parses (what config.yaml's flat `str?` option carries)",
isinstance(TWO, StaticProfile))
check("...and so does an already-parsed dict (what /data/options.json would "
"carry under a nested schema, and what sim/inject.py hands over)",
isinstance(parse_static_profile(TWO_PERIOD), StaticProfile))
check("...and a bare list of periods, without the wrapper",
isinstance(parse_static_profile(TWO_PERIOD["periods"]), StaticProfile))
# ⚠️ THE PRODUCT IS DERIVED FROM THE DATA. Nobody ticks a box saying 'static'.
check("the product is derived from the number of distinct prices, not declared: "
"two levels -> STATIC", TWO.product is PriceProduct.STATIC)
check("...and one level -> FIXED, from the same code, with no flag anywhere",
parse_static_profile(FULLY_FIXED).product is PriceProduct.FIXED)
# ...and it is really the PRICES, not the row count: three rows, one price.
_three_rows_one_price = parse_static_profile({"periods": [
dict(p, eur_per_kwh=FIXED_P) for p in TWO_PERIOD["periods"]]})
check("...and THREE rows carrying ONE price is still FIXED, so the derivation "
"is on the prices and not on len(periods)",
_three_rows_one_price.product is PriceProduct.FIXED
and len(_three_rows_one_price.periods) == 3)
# ⚠️ PROBES AT THE BOUNDARY, NOT IN THE MIDDLE OF A WINDOW. 2026-01-15 is a
# Thursday and Belgium is on CET (+01:00) then, so local == UTC + 1 h.
THU = datetime(2026, 1, 15, tzinfo=UTC)
def at_local(day, hh, mm=0):
"""A UTC instant whose BRUSSELS wall clock reads hh:mm on `day`.
Built by search rather than by adding an offset, so this helper cannot
inherit the same offset bug it is meant to detect.
"""
for minutes in range(-180, 24 * 60 + 180):
u = day + timedelta(minutes=minutes)
loc = brussels(u)
if loc.hour == hh and loc.minute == mm and loc.date() == \
brussels(day + timedelta(hours=12)).date():
return u
raise AssertionError(f"no instant reads {hh:02d}:{mm:02d} local on {day}")
_probes = [(6, 59, "night"), (7, 0, "day"), (7, 1, "day"),
(21, 59, "day"), (22, 0, "night"), (22, 1, "night"),
(12, 0, "day"), (3, 0, "night")]
_got = [(hh, mm, TWO.at(at_local(THU, hh, mm)).name) for hh, mm, _ in _probes]
check(f"the half-open boundaries land exactly - 06:59 night, 07:00 day, "
f"21:59 day, 22:00 night {_got}",
all(name == want for (hh, mm, want), (_, __, name)
in zip(_probes, _got)))
check("...and the 22:00-07:00 window really wraps midnight: Thursday 03:00 is "
"night, which a [22:00,24:00) reading would call uncovered",
TWO.at(at_local(THU, 3)).name == "night")
# ⚠️ THE DAY-TYPE TRAP. A `mon-fri` night window must NOT bleed into Saturday
# morning - a period applies on the day the CLOCK reads. The weekend row is what
# covers Saturday 03:00, and it is a DIFFERENT Period object.
SAT = datetime(2026, 1, 17, tzinfo=UTC) # a Saturday
_sat3 = TWO.at(at_local(SAT, 3))
_thu3 = TWO.at(at_local(THU, 3))
check("Saturday 03:00 is served by the WEEKEND row, not by Friday's night "
"window bleeding over - same name and price, different period",
_sat3.name == _thu3.name and _sat3 is not _thu3
and 5 in _sat3.days and 5 not in _thu3.days)
check("...and Saturday 12:00 is night-priced, because a Belgian weekend tariff "
"runs all day - a weekday-shaped lookup would call it day",
near(TWO.price_at(at_local(SAT, 12)), NIGHT_P))
check("...while Thursday 12:00 is day-priced, so the two are really different "
"answers and not one answer twice",
near(TWO.price_at(at_local(THU, 12)), DAY_P))
# next_change: the shifting primitive - "how long until the cheap window".
_nc = TWO.next_change(at_local(THU, 12))
check(f"next_change from Thursday 12:00 is the 22:00 flip "
f"{None if _nc is None else brussels(_nc).isoformat()}",
_nc is not None and brussels(_nc).hour == 22 and brussels(_nc).minute == 0)
_nc2 = TWO.next_change(at_local(SAT, 12))
check("...and from Saturday noon it is Monday 07:00, because the whole weekend "
"is one window - a scan that stopped at midnight would say 24 h",
_nc2 is not None and brussels(_nc2).hour == 7
and brussels(_nc2).weekday() == 0)
# --------------------------------------------------------------------------- #
print()
print("2. AC2 - a fully-fixed tariff disables arbitrage, by arithmetic")
FIX = parse_static_profile(FULLY_FIXED)
# ⚠️ The window OPENS A DAY EARLY. `at_local(THU, 0)` is 23:00 UTC on the 14th -
# Belgium is an hour ahead - so a plan starting at THU would not cover midnight
# local, and `price_at` would correctly raise for it. That raise is the right
# behaviour (an hour outside the series is an hour with no price) but it is not
# what these sections are grading.
SPAN_A, SPAN_B = THU - timedelta(days=1), THU + timedelta(days=2)
p_fixed = static_plan(opts(FULLY_FIXED), SPAN_A, SPAN_B)
p_two = static_plan(opts(TWO_PERIOD), SPAN_A, SPAN_B)
check("the fixed plan really produced a series (so the refusal below is about "
"the tariff, not about a failed read)",
p_fixed.has_prices and len(p_fixed.read.points) == 72)
_spread_fixed = derived(p_fixed.spread_eur_kwh)
_spread_two = derived(p_two.spread_eur_kwh)
check(f"...and its spread is exactly zero {_spread_fixed}",
near(_spread_fixed, 0.0))
check(f"...while the two-period spread is the day/night difference "
f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}",
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
_v_fixed = p_fixed.arbitrage(peak_guard_active=False)
_v_two = p_two.arbitrage(peak_guard_active=False)
check("a fully-fixed tariff REFUSES arbitrage", _v_fixed.kind == "refused")
check("...for the true reason - the series is flat, so there is nothing to buy "
"low and sell high, not because a FIXED enum was matched on",
"flat" in _v_fixed.reason and "self-consumption only" in _v_fixed.reason)
check("...and a two-period tariff, on the same code path, ALLOWS it - so the "
"refusal is discriminating rather than universal",
_v_two.kind == "allowed")
# ⚠️ NOT KEYED ON THE PRODUCT ENUM. A DAY-AHEAD series that happens to print one
# price all day must be refused for the same reason, or the rule is a label
# check wearing arithmetic's clothes.
_flat_dayahead = TariffPlan(
PriceRead.series(
[PricePoint(start=brussels(THU + timedelta(hours=i)),
eur_per_kwh=0.2222, provisional=False) for i in range(24)],
PriceProduct.DAY_AHEAD, "generic_dayahead", THU),
None, "provider")
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
"is arithmetic and not a product label",
_flat_dayahead.arbitrage(peak_guard_active=False).kind == "refused")
# The numbers an optimiser needs exist ONLY on the permitting variant.
check("the allowed verdict carries the spread, the cheapest hour and the "
"dearest hour - the whole input set for a trade",
near(_v_two.spread_eur_kwh, DAY_P - NIGHT_P)
and near(_v_two.cheapest.eur_per_kwh, NIGHT_P)
and near(_v_two.dearest.eur_per_kwh, DAY_P))
check("...and a REFUSED verdict carries none of them: .spread_eur_kwh raises "
"Absence rather than answering 0.0, which is the number that would make "
"every hour look equally good",
raises(lambda: _v_fixed.spread_eur_kwh, Absence)
and raises(lambda: _v_fixed.cheapest, Absence))
check("...and it is not iterable and has no .get(), the other two routes from "
"a refusal to an empty, harmless-looking loop",
raises(lambda: list(_v_fixed), Absence)
and raises(lambda: _v_fixed.get("spread_eur_kwh", 0.0), Absence))
check("...and match() demands BOTH arms, so the refusal cannot be fallen "
"through", raises(lambda: _v_fixed.match(allowed=lambda v: 1), TypeError))
check("...and there is no falsy variant, so `if verdict:` cannot look like a "
"guard while being none", bool(_v_fixed) is True and bool(_v_two) is True)
# §10.1 priority 2 over priority 3, expressed so OPT-04 cannot skip it.
check("peak shaving outranks arbitrage: the SAME allowed plan is refused when "
"the peak guard is holding",
p_two.arbitrage(peak_guard_active=True).kind == "refused")
check("...and the reason names the ordering rather than the tariff",
"priority 2" in p_two.arbitrage(peak_guard_active=True).reason)
check("...and the question cannot be skipped: peak_guard_active is required "
"and keyword-only, so a spread is not obtainable without answering it",
raises(lambda: p_two.arbitrage(), TypeError)
and raises(lambda: p_two.arbitrage(False), TypeError))
check("...nor answered with a truthy 'unknown'",
raises(lambda: p_two.arbitrage(peak_guard_active=None), TypeError)
and raises(lambda: p_two.arbitrage(peak_guard_active="no"), TypeError))
# --------------------------------------------------------------------------- #
print()
print("3. the cheap-window rule, on a tariff where it is not degenerate")
# ⚠️ WHY THREE LEVELS. Under two prices, "below the mean" and "equal to the
# minimum" select the SAME hours, so a two-period profile cannot tell
# cheap_now()'s actual rule from `price == min(prices)`. This fixture's `day`
# level sits between the two, and the check below PROVES the two rules disagree
# before anything is graded with it.
THREE = parse_static_profile(THREE_LEVEL)
p3 = static_plan(opts(THREE_LEVEL), SPAN_A,
SPAN_A + timedelta(days=MEAN_WINDOW_DAYS))
_mean3 = p3.mean_eur_kwh()
_min3 = min(p.eur_per_kwh for p in p3.read.points)
_day3 = 0.2900
check(f"the three-level fixture is non-degenerate: the minimum {_min3:.4f} is "
f"strictly below the middle level {_day3:.4f}, which is strictly below "
f"the mean {_mean3:.4f} - so 'below the mean' and 'is the minimum' select "
f"DIFFERENT sets of hours and this fixture can tell them apart",
_min3 < _day3 < _mean3)
check("...so cheap_now() calls the 07:00-17:00 MIDDLE level cheap - it is under "
"the mean without being the cheapest price of the day, which a "
"`price == min(prices)` rule would get wrong and a `price < mean` rule "
"gets right", p3.cheap_now(at_local(THU, 12)) is True)
check("...and the middle level really is not the minimum, so that check graded "
"the difference rather than restating it",
not near(p3.price_at(at_local(THU, 12)), _min3))
check("...and calls the 00:00-07:00 minimum cheap",
p3.cheap_now(at_local(THU, 3)) is True)
check("...and the 17:00-21:00 peak not cheap",
p3.cheap_now(at_local(THU, 18)) is False)
# ⚠️ STRICTLY BELOW, AND THAT IS AC2 IN THE SHIFTING HALF. On a flat series
# `<=` would make EVERY hour a cheap window and a fixed-tariff site would top
# the battery up from the grid at any hour - the opposite of self-consumption
# only.
_fixed_hours = [at_local(THU, h) for h in range(0, 24, 3)]
check(f"a fully-fixed tariff has NO cheap window at any hour of the day "
f"{[h for h in range(0, 24, 3) if p_fixed.cheap_now(at_local(THU, h))]} - "
f"which is what `<` buys and `<=` would destroy",
not any(p_fixed.cheap_now(t) for t in _fixed_hours))
check("...and the price really is defined at every one of those hours, so the "
"'no cheap window' answer is a priced no and not a missing read",
all(near(p_fixed.price_at(t), FIXED_P) for t in _fixed_hours))
# ...and on the two-period tariff the cheap hours are exactly the night ones.
_cheap_h = {h for h in range(24) if p_two.cheap_now(at_local(THU, h))}
_night_h = {h for h in range(24) if TWO.at(at_local(THU, h)).name == "night"}
check(f"on the day/night tariff the cheap hours ARE the night hours, derived "
f"two ways {sorted(_cheap_h)}",
_cheap_h == _night_h and 0 < len(_cheap_h) < 24)
# ⚠️ THE MEAN IS OVER SEVEN DAYS, NOT OVER THE HOUR YOU ASKED AT. A 48 h mean on
# a Friday afternoon is ~2/3 weekend, so the same Tuesday 09:00 would come out
# cheap or dear depending on when the question was put.
FRI = datetime(2026, 1, 16, tzinfo=UTC)
_from_thu = static_status(opts(TWO_PERIOD), at_local(THU, 12))
_from_fri = static_status(opts(TWO_PERIOD), at_local(FRI, 15))
_mean_thu = static_plan(opts(TWO_PERIOD), at_local(THU, 12),
at_local(THU, 12) + timedelta(days=MEAN_WINDOW_DAYS)
).mean_eur_kwh()
_mean_fri = static_plan(opts(TWO_PERIOD), at_local(FRI, 15),
at_local(FRI, 15) + timedelta(days=MEAN_WINDOW_DAYS)
).mean_eur_kwh()
check(f"the 7-day mean is stable across the day it is asked on "
f"({_mean_thu:.4f} vs {_mean_fri:.4f}), so a window's cheapness is a "
f"property of the tariff and not of the clock",
near(_mean_thu, _mean_fri, 5e-4))
_mean48_thu = static_plan(opts(TWO_PERIOD), at_local(THU, 12),
at_local(THU, 12) + timedelta(days=2)).mean_eur_kwh()
_mean48_fri = static_plan(opts(TWO_PERIOD), at_local(FRI, 15),
at_local(FRI, 15) + timedelta(days=2)).mean_eur_kwh()
check(f"...and the 48 h mean this replaces is NOT stable ({_mean48_thu:.4f} vs "
f"{_mean48_fri:.4f}), so the seven days are buying something real",
not near(_mean48_thu, _mean48_fri, 5e-4))
# --------------------------------------------------------------------------- #
print()
print("4. DST - 23 and 25 hour days, where a 24-slot implementation is wrong")
# ⚠️ 2026: CEST from 01:00 UTC Sunday 29 March, CET from 01:00 UTC Sunday
# 25 October (price.py's brussels_offset, checked in PR #16 against zoneinfo
# over 149,040 instants). Both are SUNDAYS, so the weekend row prices them -
# which is itself worth knowing, because the wrap-around weekday row is not what
# is under test here.
SPRING = datetime(2026, 3, 29, tzinfo=UTC)
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC)
def local_day_points(profile, day_utc):
"""Hourly points whose BRUSSELS date is that of `day_utc` + 12 h."""
target = brussels(day_utc + timedelta(hours=12)).date()
pts = profile.series(day_utc - timedelta(hours=6),
day_utc + timedelta(hours=30), provisional=False)
return [p for p in pts if p.start.date() == target]
_spring = derived(lambda: local_day_points(TWO, SPRING))
_autumn = derived(lambda: local_day_points(TWO, AUTUMN))
check(f"the spring-forward local day emits 23 points, not 24 "
f"({len(_spring) if not isinstance(_spring, _Raised) else _spring})",
not isinstance(_spring, _Raised) and len(_spring) == 23)
check(f"the autumn-back local day emits 25 points, not 24 "
f"({len(_autumn) if not isinstance(_autumn, _Raised) else _autumn})",
not isinstance(_autumn, _Raised) and len(_autumn) == 25)
check("...and the two 02:00 points in October are DIFFERENT instants at "
"DIFFERENT offsets, which a naive local-hour key would collide",
len({p.start.utcoffset() for p in _autumn if p.start.hour == 2}) == 2)
check("...and every emitted start is strictly increasing in UTC, which is what "
"price.py's boundary check demands of a fetched feed too",
all(a.start.astimezone(UTC) < b.start.astimezone(UTC)
for a, b in zip(_autumn, _autumn[1:])))
# A weekday profile across a transition, so the wrap-around path sees one too.
_wrapdays = local_day_points(TWO, datetime(2026, 3, 30, tzinfo=UTC))
check("a normal Monday still emits 24, so the 23/25 above are the transitions "
"and not a systematic miscount", len(_wrapdays) == 24)
# --------------------------------------------------------------------------- #
print()
print("5. a failed read is not an observation, and a gap is not a price")
_none = static_plan(opts(None), THU, THU + timedelta(days=1))
check("no static_profile produces a NO-DATA read, not an empty series and not "
"a zero one", not _none.has_prices)
check("...and reading a price off it RAISES rather than answering 0.0 - the "
"value that would make every hour look free",
raises(lambda: _none.price_at(THU), Absence)
and raises(_none.mean_eur_kwh, Absence)
and raises(_none.spread_eur_kwh, Absence))
check("...and its underlying read has no .points at all, so `min(read.points)` "
"cannot run against it",
raises(lambda: _none.read.points, Absence))
check("...and arbitrage is refused, per §11.4",
_none.arbitrage(peak_guard_active=False).kind == "refused"
and arbitrage_allowed(_none.read) is False)
check("...and static_status() says so in words rather than reporting a price",
static_status(opts(None), THU) == (False, "no static profile"))
# ⚠️ THE TILING REFUSAL. An uncovered hour has no price and the only ways to
# serve it are to invent one or to fail when the clock reaches it.
_gap = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
"eur_per_kwh": DAY_P},
{"name": "night", "from": "23:00", "to": "07:00", "days": "all",
"eur_per_kwh": NIGHT_P}]}
check("a profile with a 22:00-23:00 gap is REFUSED, not defaulted",
raises(lambda: parse_static_profile(_gap), ProfileInvalid))
check("...and names the uncovered range so the operator can fix it",
"22:00-23:00" in reason(lambda: parse_static_profile(_gap)))
_overlap = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
"eur_per_kwh": DAY_P},
{"name": "night", "from": "21:00", "to": "07:00", "days": "all",
"eur_per_kwh": NIGHT_P}]}
check("...and an overlap is refused too - two prices for one hour means one "
"wins on list order",
raises(lambda: parse_static_profile(_overlap), ProfileInvalid)
and "2 prices" in reason(lambda: parse_static_profile(_overlap)))
# THE CLASSIC MISTAKE the docstring warns about: no weekend row.
_noweekend = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
"eur_per_kwh": DAY_P},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
"eur_per_kwh": NIGHT_P}]}
check("a weekday-only profile is refused because Saturday and Sunday have no "
"price - the mistake a wrap-around window invites",
raises(lambda: parse_static_profile(_noweekend), ProfileInvalid)
and "sat" in reason(lambda: parse_static_profile(_noweekend)))
# ...and the shipped shape is accepted, so the rule is not simply "refuse all".
check("...while the three-row Belgian shape IS accepted, so the tiling rule "
"discriminates rather than refusing everything",
isinstance(parse_static_profile(TWO_PERIOD), StaticProfile))
_bad = [
("not JSON at all", "day 07:00-22:00 0.34"),
("a price in cents", {"periods": [dict(FULLY_FIXED["periods"][0],
eur_per_kwh=30.7)]}),
("a non-numeric price", {"periods": [dict(FULLY_FIXED["periods"][0],
eur_per_kwh="cheap")]}),
("a missing days selector", {"periods": [
{"name": "x", "from": "00:00", "to": "24:00", "eur_per_kwh": 0.3}]}),
("an unknown day name", {"periods": [dict(FULLY_FIXED["periods"][0],
days="mon-frd")]}),
("a decimal hour", {"periods": [dict(FULLY_FIXED["periods"][0],
**{"from": "7.5"})]}),
("an unsupported timezone", dict(FULLY_FIXED, timezone="Europe/Madrid")),
("an empty periods list", {"periods": []}),
]
for label, value in _bad:
payload = value if isinstance(value, str) else json.dumps(value)
check(f"refused: {label}",
raises(lambda p=payload: parse_static_profile(p), ProfileInvalid))
check("...and every one of those refusals says something an operator can act "
"on, rather than just failing",
all(len(reason(lambda p=(v if isinstance(v, str) else json.dumps(v)):
parse_static_profile(p))) > 40 for _, v in _bad))
for label, value in (("empty", ""), ("null", "null"), ("absent", None),
("whitespace", " ")):
check(f"...while {label!r} means 'no profile' rather than an error",
parse_static_profile(value) is None)
# --------------------------------------------------------------------------- #
print()
print("6. AC3 - the static-profile regression case, on the Belgian data that exists")
# ⚠️ WHAT THE "BELGIAN SCENARIO SUITE" ACTUALLY IS IN THIS REPO. specs §17 names
# six scenarios; none of them is a static-profile case, which is why AC3 asks
# for a seventh. What exists as DATA is two days of this house's real P1 net
# power, captured from the live meter and committed as
# `scenarios/p1-net-2026-08-{20,23}.jsonl.gz` - the same fixtures test_tariff.py
# part 9 replays for the capacity tariff. Nothing longer was ever captured.
#
# So this case is measured against REAL BELGIAN HOUSEHOLD LOAD, and the two days
# happen to be one weekday and one weekend day, which is exactly the pair a
# day/night tariff needs to be graded on. It is NOT a 30-day replay and this
# section does not claim to be one; the numbers it prints are the numbers those
# two days actually produce.
REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz"))
print(f" replay fixtures found: {len(REPLAY)} "
f"({', '.join(p.name for p in REPLAY) or 'none'})")
check("the committed replay fixtures are present - a regression fixture CI "
"cannot see is not a regression fixture", len(REPLAY) >= 2)
MAX_GAP_S = 60.0 # a longer hole in the capture is not counted as energy
def replay_import(path):
"""(kWh imported per local tariff window, total kWh, span hours, weekday).
⚠️ THE WINDOW IS DECIDED HERE BY AN INDEPENDENT ROUTE - the Brussels-local
hour of the sample, tested against the fixture's own 07:00/22:00 boundaries
- rather than by asking the module under test. The two must agree; that
agreement is the check.
"""
per_window = {}
total = 0.0
first = last = None
prev = None
days = set()
with gzip.open(path, "rt", encoding="utf-8") as fh:
for line in fh:
row = json.loads(line)
t = datetime.fromisoformat(row["t"])
w = float(row["w"])
if first is None:
first = t
last = t
if prev is not None:
dt = (t - prev[0]).total_seconds()
if 0 < dt <= MAX_GAP_S and prev[1] > 0:
kwh = prev[1] * dt / 3_600_000.0
loc = brussels(prev[0])
days.add(loc.weekday())
window = ("day" if loc.weekday() < 5
and 7 <= loc.hour < 22 else "night")
per_window[window] = per_window.get(window, 0.0) + kwh
total += kwh
prev = (t, w)
span = (last - first).total_seconds() / 3600.0 if first else 0.0
return per_window, total, span, days
def cost_under(path, profile):
"""EUR the imported energy cost, priced sample by sample by the MODULE."""
total = 0.0
prev = None
with gzip.open(path, "rt", encoding="utf-8") as fh:
for line in fh:
row = json.loads(line)
t = datetime.fromisoformat(row["t"])
w = float(row["w"])
if prev is not None:
dt = (t - prev[0]).total_seconds()
if 0 < dt <= MAX_GAP_S and prev[1] > 0:
total += (prev[1] * dt / 3_600_000.0) * profile.price_at(prev[0])
prev = (t, w)
return total
replay_days = 0.0
graded = 0
for path in REPLAY:
windows, total_kwh, span_h, weekdays = replay_import(path)
replay_days += span_h / 24.0
day_kwh = windows.get("day", 0.0)
night_kwh = windows.get("night", 0.0)
kind = "weekday" if weekdays and min(weekdays) < 5 else "weekend"
print(f" {path.name}: {span_h:.1f} h, {kind} "
f"(weekday indices {sorted(weekdays)}), {total_kwh:.3f} kWh imported "
f"= {day_kwh:.3f} day + {night_kwh:.3f} night")
check(f"{path.name}: the two routes to the total agree",
near(day_kwh + night_kwh, total_kwh, 1e-9))
# The module's own pricing must reproduce the independently-split volumes.
c_two = cost_under(path, TWO)
c_expect = day_kwh * DAY_P + night_kwh * NIGHT_P
check(f"{path.name}: the module prices the real day at "
f"EUR {c_two:.4f}, which equals the independently-split "
f"{day_kwh:.3f} kWh x {DAY_P} + {night_kwh:.3f} kWh x {NIGHT_P} = "
f"EUR {c_expect:.4f}", near(c_two, c_expect, 1e-9))
c_fix = cost_under(path, FIX)
check(f"{path.name}: under a FULLY FIXED tariff the same energy costs "
f"EUR {c_fix:.4f} = {total_kwh:.3f} kWh x {FIXED_P}, one price for "
f"every hour", near(c_fix, total_kwh * FIXED_P, 1e-9))
# ⚠️ THE SHIFTING FIGURE, AND THE DEGENERACY GUARD IN FRONT OF IT. A day
# whose entire import landed in one window grades nothing: the saving would
# be 0 under a correct implementation AND under one that ignored the clock.
if day_kwh > 0 and night_kwh > 0:
graded += 1
saving = c_two - total_kwh * NIGHT_P
check(f"{path.name}: this day DOES import in both windows "
f"({day_kwh:.3f} / {night_kwh:.3f} kWh), so the split grades "
f"something", True)
check(f"{path.name}: shifting every day-window kWh into the night "
f"window saves EUR {saving:.4f}, which is exactly "
f"{day_kwh:.3f} kWh x the {DAY_P - NIGHT_P:.4f} spread - two "
f"routes to one number",
near(saving, day_kwh * (DAY_P - NIGHT_P), 1e-9) and saving > 0)
# AC2 on the same real data: under a fixed tariff there is nothing to
# shift, and that is arithmetic rather than a special case.
check(f"{path.name}: under the FIXED tariff the identical shift saves "
f"exactly nothing, so self-consumption-only is the right "
f"behaviour for that product",
near(c_fix - total_kwh * FIXED_P, 0.0, 1e-12))
else:
print(f" {path.name}: import fell in ONE window only "
f"({day_kwh:.3f} day / {night_kwh:.3f} night) - this day is a "
f"day-typing witness, not a shifting one, and no saving is "
f"claimed from it")
print()
print(f" AC3 MEASURED: {replay_days:.1f} DAYS of real Belgian household "
f"P1 data, {graded} of {len(REPLAY)} days carrying import in BOTH tariff "
f"windows and therefore grading the split.")
print(f" AC3 ASKS FOR A REGRESSION CASE IN THE BELGIAN SUITE, AND THIS IS "
f"ONE - but the suite holds two captured days, not a season, so nothing "
f"here is a claim about annual savings. §17's six named scenarios do not "
f"include a static-profile case; this is the seventh, and TEST-01 should "
f"say so.")
check("the replay produced a measurable split over a stated sample size, "
"rather than a criterion quietly redefined to fit the data",
graded >= 1 and replay_days < 30)
check("...and BOTH day types are present in the fixtures, which is what makes "
"the weekend row testable on real data at all",
len({("weekday" if min(replay_import(p)[3]) < 5 else "weekend")
for p in REPLAY}) == 2)
# --------------------------------------------------------------------------- #
print()
print("7. §11.4 - the profile is the fallback, and the fallback is provisional")
async def _dead(url, timeout_s):
raise OSError("the supplier's feed is down")
def run(coro):
import asyncio
return asyncio.run(coro)
_cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
price_feed_url="https://api.energy-charts.info/price?bzn=BE",
price_timeout_s=1, price_markup_eur_kwh=0.03,
price_taxes_eur_kwh=0.06)
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead))
check("a dead feed falls back to the static profile rather than to nothing",
_fb.origin == "fallback" and _fb.has_prices)
check("...with the SAME prices the configured-product path produces, because "
"it is one generator and not a second implementation",
[p.eur_per_kwh for p in _fb.read.points]
== [p.eur_per_kwh for p in static_plan(
opts(TWO_PERIOD), THU, THU + timedelta(days=1)).read.points])
check("...marked PROVISIONAL, which is the one flag that differs",
_fb.provisional and plan_is_provisional(_fb.read))
check("...and arbitrage is refused on it, per §11.4, even though a full series "
"exists and has a real spread",
_fb.arbitrage(peak_guard_active=False).kind == "refused"
and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P))
check("...while the SAME profile as the configured product allows it - so the "
"refusal is about provisionality and not about the profile",
static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1)
).arbitrage(peak_guard_active=False).kind == "allowed")
check("...and the provisional plan has no cheap window either: §8.3 forbids an "
"irreversible action on a provisional price, and a grid top-up is one",
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
_fb_none = run(resolve_tariff(opts(None, price_provider="generic_dayahead",
price_timeout_s=1),
THU, THU + timedelta(days=1), transport=_dead))
check("a dead feed with NO static_profile has nothing to fall back to, and "
"says that rather than inventing a tariff",
_fb_none.origin == "none" and not _fb_none.has_prices)
# A live feed still wins over the profile.
_body = json.dumps({"data": [
{"start": (THU + timedelta(hours=i)).isoformat(), "price_eur_mwh": 40 + i}
for i in range(24)]}).encode()
async def _live(url, timeout_s):
return _body
_live_plan = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1),
transport=_live))
check("a live feed is used in preference to the static profile",
_live_plan.origin == "provider"
and _live_plan.product is PriceProduct.DAY_AHEAD)
check("...and arbitrage is allowed on it, with the spread derived from the "
"feed rather than from the profile",
_live_plan.arbitrage(peak_guard_active=False).kind == "allowed"
and near(_live_plan.arbitrage(peak_guard_active=False).spread_eur_kwh,
0.023, 1e-9))
# --------------------------------------------------------------------------- #
print()
print("8. the startup gate, and CFG-03")
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("a good static_profile starts",
validate_startup_config(
vbase(static_profile=json.dumps(TWO_PERIOD)), -5000, 5000) == [])
check("a gapped one is REFUSED at startup, not at 22:00 on the day it bites",
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
-5000, 5000),
"static_profile cannot be used"))
check("...and the refusal names the uncovered range",
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
-5000, 5000), "22:00-23:00"))
check("static_profile alongside the Home Assistant price entities is refused - "
"main.py reads one of them and the loser is silent",
why(validate_startup_config(
vbase(static_profile=json.dumps(TWO_PERIOD),
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
-5000, 5000), "HOME ASSISTANT"))
check("...and the Home Assistant pair ALONE still starts, 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("...and an empty static_profile is never refused: §9.2 puts a large share "
"of the installed base on a fixed tariff, and §11.4 makes no-price-data a "
"supported state",
validate_startup_config(vbase(static_profile=""), -5000, 5000) == []
and validate_startup_config(vbase(), -5000, 5000) == [])
check("...and a fully-fixed profile starts, since a one-price site is a "
"supported product and not a misconfiguration",
validate_startup_config(vbase(static_profile=json.dumps(FULLY_FIXED)),
-5000, 5000) == [])
# ⚠️ CFG-03 IS NOT WIDENED BY THIS TICKET. `static_profile` was added to
# app/price.py's PRICE_OPTION_DEFAULTS - the ONE in-code mirror TARIFF-02
# created and pinned to config.yaml - rather than to a second defaults dict in
# the new module. That mirror is enforced by test_price.py section 1, which
# reads config.yaml; this check is the other half of the claim: the new module
# must carry NO defaults dict of its own.
_SRC = (HERE / "app" / "static_tariff.py").read_text(encoding="utf-8")
check("the new module holds no option-defaults dict of its own, so this ticket "
"added ZERO new places an option default lives (CFG-03)",
"OPTION_DEFAULTS" not in _SRC.replace("PRICE_OPTION_DEFAULTS", ""))
check("...and it reads the option through price.price_option(), the framework's "
"single read path, rather than through opts.get(key, <a number>)",
"price_option(opts" in _SRC and 'opts.get("static_profile"' not in _SRC)
_MAIN = (HERE / "app" / "main.py").read_text(encoding="utf-8")
check("...and main.py grew no `o.get(\"static_profile\", <a default>)` fallback "
"either - it calls static_status(o, now), and the option is read once, "
"inside the framework",
'get("static_profile"' not in _MAIN and "static_status(" in _MAIN)
# --------------------------------------------------------------------------- #
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")