diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 158514e..b51d995 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -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 diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 4a5842c..96f08c3 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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 diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index d7fd7ef..066c7b4 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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, diff --git a/goodwe_controller/app/price.py b/goodwe_controller/app/price.py index 7ac7978..5e1a382 100644 --- a/goodwe_controller/app/price.py +++ b/goodwe_controller/app/price.py @@ -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 diff --git a/goodwe_controller/app/static_tariff.py b/goodwe_controller/app/static_tariff.py new file mode 100644 index 0000000..9d1e01d --- /dev/null +++ b/goodwe_controller/app/static_tariff.py @@ -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") diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index c5833a6..cff9fe7 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -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 " diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index 0e08117..bae80ed 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -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. diff --git a/goodwe_controller/test_price.py b/goodwe_controller/test_price.py index 2ed3d51..2d4773b 100644 --- a/goodwe_controller/test_price.py +++ b/goodwe_controller/test_price.py @@ -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 " diff --git a/goodwe_controller/test_static_tariff.py b/goodwe_controller/test_static_tariff.py new file mode 100644 index 0000000..6be4afa --- /dev/null +++ b/goodwe_controller/test_static_tariff.py @@ -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"" + + +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, )", + "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\", )` 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")