TARIFF-03: static day/night and fully-fixed tariffs, first-class #19
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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 "
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
"""Runnable check for static day/night and fully-fixed tariffs. `python3 test_static_tariff.py`
|
||||
|
||||
WHAT THIS FILE IS DEFENDING, in the order the ticket's acceptance criteria name
|
||||
it:
|
||||
|
||||
AC1 `static_profile` (§14) drives TIME-WINDOW SHIFTING for a two-period
|
||||
tariff - the right window at the right local instant, on both sides of
|
||||
both 2026 DST transitions, and reaching the one shiftable load the add-on
|
||||
actually has.
|
||||
AC2 Fully-fixed pricing DISABLES ARBITRAGE and leaves self-consumption only.
|
||||
AC3 The Belgian scenario suite carries a static-profile regression case.
|
||||
|
||||
THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||||
|
||||
* EVERY VERDICT IS DERIVED. No `check("the split is right", True)`. Where a
|
||||
check needs an expected number it is computed HERE, by different arithmetic
|
||||
than app/static_tariff.py uses - the window split is re-derived by summing
|
||||
over samples with an independent local-hour test, the profile mean by a
|
||||
second pass, the saving by two routes that must agree.
|
||||
|
||||
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE. A
|
||||
two-period tariff has this trap BUILT IN and it is the main hazard here:
|
||||
|
||||
- THE PERIOD BOUNDARY IS THE DEGENERACY. At 07:00 and at 22:00 the "which
|
||||
window" question and the "is it cheap" question agree under almost any
|
||||
bug, and a probe at 12:00 cannot tell a correct wrap-around from one
|
||||
that treats 22:00-07:00 as 22:00-24:00. So probes sit at 06:59 / 07:00 /
|
||||
07:01 and 21:59 / 22:00 / 22:01, and a Saturday 03:00 probe exists
|
||||
specifically because the mon-fri night window MUST NOT cover it.
|
||||
- MEAN-CHEAP AND MINIMUM-CHEAP AGREE ON A TWO-LEVEL TARIFF. Under two
|
||||
prices "below the mean" and "is the cheapest price" are the same set, so
|
||||
a two-period profile cannot tell `cheap_now` from `price == min`.
|
||||
Section 3 therefore runs a THREE-level profile where those two answers
|
||||
differ, and asserts they differ before grading anything with it.
|
||||
- DAY AND NIGHT MUST HAVE DIFFERENT PRICES AND NEITHER MAY BE ZERO, or the
|
||||
whole shifting question collapses. Asserted as checks in their own
|
||||
right, first, rather than assumed.
|
||||
- THE DST CHECKS RUN ON THE 23-HOUR AND 25-HOUR DAYS, not on a normal day
|
||||
where a 24-slot implementation and a correct one agree exactly.
|
||||
- THE REPLAY DAY MUST CARRY IMPORT IN BOTH WINDOWS. A day whose whole
|
||||
import fell in one window grades no split at all; section 6 asserts that
|
||||
before it reports a saving.
|
||||
|
||||
* A FAILED READ IS NOT AN OBSERVATION. A missing static_profile is not a zero
|
||||
price, and zero is exactly the value that makes everything look free. So
|
||||
`static_plan` with no profile is checked to produce a read with NO
|
||||
`.points`, and `price_at` on it must RAISE rather than answer 0.0.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.price import (
|
||||
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
|
||||
plan_is_provisional,
|
||||
)
|
||||
from app.static_tariff import (
|
||||
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
|
||||
parse_static_profile, resolve_tariff, static_plan, static_status,
|
||||
)
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def raises(fn, exc):
|
||||
"""True only if fn() raises EXACTLY `exc`. A different exception is False -
|
||||
'it was refused' and 'it broke on the way to being refused' must not grade
|
||||
the same."""
|
||||
try:
|
||||
fn()
|
||||
except exc:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def reason(fn):
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return str(e)
|
||||
return ""
|
||||
|
||||
|
||||
class _Raised:
|
||||
def __init__(self, e):
|
||||
self.e = e
|
||||
|
||||
def __eq__(self, other):
|
||||
return False
|
||||
|
||||
def __hash__(self):
|
||||
return id(self)
|
||||
|
||||
def __bool__(self):
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return f"<raised {type(self.e).__name__}: {self.e}>"
|
||||
|
||||
|
||||
def derived(fn):
|
||||
"""A value a check grades. An exception becomes `_Raised`, which equals
|
||||
nothing and is falsy - NOT a default. See test_price.py for why."""
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _Raised(e)
|
||||
|
||||
|
||||
def near(a, b, tol=1e-9):
|
||||
try:
|
||||
return abs(float(a) - float(b)) <= tol
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The fixtures. Belgian day/night numbers, and their degeneracies asserted.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
DAY_P = 0.3410 # all-in EUR/kWh, day
|
||||
NIGHT_P = 0.2570 # all-in EUR/kWh, night
|
||||
FIXED_P = 0.3070 # all-in EUR/kWh, single-tariff site
|
||||
|
||||
TWO_PERIOD = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
|
||||
"eur_per_kwh": DAY_P},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
|
||||
"eur_per_kwh": NIGHT_P},
|
||||
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
|
||||
"eur_per_kwh": NIGHT_P},
|
||||
]}
|
||||
|
||||
FULLY_FIXED = {"periods": [
|
||||
{"name": "fixed", "from": "00:00", "to": "24:00", "days": "all",
|
||||
"eur_per_kwh": FIXED_P},
|
||||
]}
|
||||
|
||||
# ⚠️ THREE LEVELS, AND SECTION 3 EXISTS BECAUSE OF IT. Under two prices "cheaper
|
||||
# than the mean" and "is the minimum price" are the same set of hours, so a
|
||||
# two-period profile cannot distinguish `cheap_now`'s rule from `price == min`.
|
||||
# Here the middle level sits BELOW the mean without being the minimum (the four
|
||||
# expensive evening hours pull the mean up past it), so those two rules give
|
||||
# DIFFERENT answers at 12:00 - and section 3 proves they differ before grading
|
||||
# anything with it.
|
||||
THREE_LEVEL = {"periods": [
|
||||
{"name": "peak", "from": "17:00", "to": "21:00", "days": "all",
|
||||
"eur_per_kwh": 0.4800},
|
||||
{"name": "day", "from": "07:00", "to": "17:00", "days": "all",
|
||||
"eur_per_kwh": 0.2900},
|
||||
{"name": "evening", "from": "21:00", "to": "24:00", "days": "all",
|
||||
"eur_per_kwh": 0.2900},
|
||||
{"name": "night", "from": "00:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": 0.1900},
|
||||
]}
|
||||
|
||||
|
||||
def opts(profile=None, **over):
|
||||
o = {"price_provider": "off",
|
||||
"static_profile": "" if profile is None else json.dumps(profile)}
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("0. the arithmetic this file grades with must not be degenerate")
|
||||
# ⚠️ These ARE the guard against a section that passes for the wrong reason. If
|
||||
# any goes red, every shifting assertion below is measuring nothing.
|
||||
check("day and night prices differ, so 'used the wrong window' is visible",
|
||||
DAY_P != NIGHT_P)
|
||||
check("...and neither is zero, so 'dropped the price entirely' is visible",
|
||||
DAY_P != 0 and NIGHT_P != 0)
|
||||
check("...and the fixed price is neither of them, so a fixed-tariff result "
|
||||
"cannot be a day/night result in disguise",
|
||||
FIXED_P not in (DAY_P, NIGHT_P))
|
||||
check("...and the day/night spread is not the fixed price, so 'reported the "
|
||||
"spread where the price was wanted' is visible",
|
||||
not near(DAY_P - NIGHT_P, FIXED_P))
|
||||
_lv = sorted({p["eur_per_kwh"] for p in THREE_LEVEL["periods"]})
|
||||
check(f"the three-level fixture really has three levels {_lv}", len(_lv) == 3)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("1. AC1 - the two-period profile puts the right window on the right hour")
|
||||
|
||||
TWO = parse_static_profile(json.dumps(TWO_PERIOD))
|
||||
check("a JSON string parses (what config.yaml's flat `str?` option carries)",
|
||||
isinstance(TWO, StaticProfile))
|
||||
check("...and so does an already-parsed dict (what /data/options.json would "
|
||||
"carry under a nested schema, and what sim/inject.py hands over)",
|
||||
isinstance(parse_static_profile(TWO_PERIOD), StaticProfile))
|
||||
check("...and a bare list of periods, without the wrapper",
|
||||
isinstance(parse_static_profile(TWO_PERIOD["periods"]), StaticProfile))
|
||||
|
||||
# ⚠️ THE PRODUCT IS DERIVED FROM THE DATA. Nobody ticks a box saying 'static'.
|
||||
check("the product is derived from the number of distinct prices, not declared: "
|
||||
"two levels -> STATIC", TWO.product is PriceProduct.STATIC)
|
||||
check("...and one level -> FIXED, from the same code, with no flag anywhere",
|
||||
parse_static_profile(FULLY_FIXED).product is PriceProduct.FIXED)
|
||||
# ...and it is really the PRICES, not the row count: three rows, one price.
|
||||
_three_rows_one_price = parse_static_profile({"periods": [
|
||||
dict(p, eur_per_kwh=FIXED_P) for p in TWO_PERIOD["periods"]]})
|
||||
check("...and THREE rows carrying ONE price is still FIXED, so the derivation "
|
||||
"is on the prices and not on len(periods)",
|
||||
_three_rows_one_price.product is PriceProduct.FIXED
|
||||
and len(_three_rows_one_price.periods) == 3)
|
||||
|
||||
# ⚠️ PROBES AT THE BOUNDARY, NOT IN THE MIDDLE OF A WINDOW. 2026-01-15 is a
|
||||
# Thursday and Belgium is on CET (+01:00) then, so local == UTC + 1 h.
|
||||
THU = datetime(2026, 1, 15, tzinfo=UTC)
|
||||
|
||||
|
||||
def at_local(day, hh, mm=0):
|
||||
"""A UTC instant whose BRUSSELS wall clock reads hh:mm on `day`.
|
||||
|
||||
Built by search rather than by adding an offset, so this helper cannot
|
||||
inherit the same offset bug it is meant to detect.
|
||||
"""
|
||||
for minutes in range(-180, 24 * 60 + 180):
|
||||
u = day + timedelta(minutes=minutes)
|
||||
loc = brussels(u)
|
||||
if loc.hour == hh and loc.minute == mm and loc.date() == \
|
||||
brussels(day + timedelta(hours=12)).date():
|
||||
return u
|
||||
raise AssertionError(f"no instant reads {hh:02d}:{mm:02d} local on {day}")
|
||||
|
||||
|
||||
_probes = [(6, 59, "night"), (7, 0, "day"), (7, 1, "day"),
|
||||
(21, 59, "day"), (22, 0, "night"), (22, 1, "night"),
|
||||
(12, 0, "day"), (3, 0, "night")]
|
||||
_got = [(hh, mm, TWO.at(at_local(THU, hh, mm)).name) for hh, mm, _ in _probes]
|
||||
check(f"the half-open boundaries land exactly - 06:59 night, 07:00 day, "
|
||||
f"21:59 day, 22:00 night {_got}",
|
||||
all(name == want for (hh, mm, want), (_, __, name)
|
||||
in zip(_probes, _got)))
|
||||
check("...and the 22:00-07:00 window really wraps midnight: Thursday 03:00 is "
|
||||
"night, which a [22:00,24:00) reading would call uncovered",
|
||||
TWO.at(at_local(THU, 3)).name == "night")
|
||||
|
||||
# ⚠️ THE DAY-TYPE TRAP. A `mon-fri` night window must NOT bleed into Saturday
|
||||
# morning - a period applies on the day the CLOCK reads. The weekend row is what
|
||||
# covers Saturday 03:00, and it is a DIFFERENT Period object.
|
||||
SAT = datetime(2026, 1, 17, tzinfo=UTC) # a Saturday
|
||||
_sat3 = TWO.at(at_local(SAT, 3))
|
||||
_thu3 = TWO.at(at_local(THU, 3))
|
||||
check("Saturday 03:00 is served by the WEEKEND row, not by Friday's night "
|
||||
"window bleeding over - same name and price, different period",
|
||||
_sat3.name == _thu3.name and _sat3 is not _thu3
|
||||
and 5 in _sat3.days and 5 not in _thu3.days)
|
||||
check("...and Saturday 12:00 is night-priced, because a Belgian weekend tariff "
|
||||
"runs all day - a weekday-shaped lookup would call it day",
|
||||
near(TWO.price_at(at_local(SAT, 12)), NIGHT_P))
|
||||
check("...while Thursday 12:00 is day-priced, so the two are really different "
|
||||
"answers and not one answer twice",
|
||||
near(TWO.price_at(at_local(THU, 12)), DAY_P))
|
||||
|
||||
# next_change: the shifting primitive - "how long until the cheap window".
|
||||
_nc = TWO.next_change(at_local(THU, 12))
|
||||
check(f"next_change from Thursday 12:00 is the 22:00 flip "
|
||||
f"{None if _nc is None else brussels(_nc).isoformat()}",
|
||||
_nc is not None and brussels(_nc).hour == 22 and brussels(_nc).minute == 0)
|
||||
_nc2 = TWO.next_change(at_local(SAT, 12))
|
||||
check("...and from Saturday noon it is Monday 07:00, because the whole weekend "
|
||||
"is one window - a scan that stopped at midnight would say 24 h",
|
||||
_nc2 is not None and brussels(_nc2).hour == 7
|
||||
and brussels(_nc2).weekday() == 0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2. AC2 - a fully-fixed tariff disables arbitrage, by arithmetic")
|
||||
|
||||
FIX = parse_static_profile(FULLY_FIXED)
|
||||
# ⚠️ The window OPENS A DAY EARLY. `at_local(THU, 0)` is 23:00 UTC on the 14th -
|
||||
# Belgium is an hour ahead - so a plan starting at THU would not cover midnight
|
||||
# local, and `price_at` would correctly raise for it. That raise is the right
|
||||
# behaviour (an hour outside the series is an hour with no price) but it is not
|
||||
# what these sections are grading.
|
||||
SPAN_A, SPAN_B = THU - timedelta(days=1), THU + timedelta(days=2)
|
||||
p_fixed = static_plan(opts(FULLY_FIXED), SPAN_A, SPAN_B)
|
||||
p_two = static_plan(opts(TWO_PERIOD), SPAN_A, SPAN_B)
|
||||
|
||||
check("the fixed plan really produced a series (so the refusal below is about "
|
||||
"the tariff, not about a failed read)",
|
||||
p_fixed.has_prices and len(p_fixed.read.points) == 72)
|
||||
_spread_fixed = derived(p_fixed.spread_eur_kwh)
|
||||
_spread_two = derived(p_two.spread_eur_kwh)
|
||||
check(f"...and its spread is exactly zero {_spread_fixed}",
|
||||
near(_spread_fixed, 0.0))
|
||||
check(f"...while the two-period spread is the day/night difference "
|
||||
f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}",
|
||||
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
|
||||
|
||||
_v_fixed = p_fixed.arbitrage(peak_guard_active=False)
|
||||
_v_two = p_two.arbitrage(peak_guard_active=False)
|
||||
check("a fully-fixed tariff REFUSES arbitrage", _v_fixed.kind == "refused")
|
||||
check("...for the true reason - the series is flat, so there is nothing to buy "
|
||||
"low and sell high, not because a FIXED enum was matched on",
|
||||
"flat" in _v_fixed.reason and "self-consumption only" in _v_fixed.reason)
|
||||
check("...and a two-period tariff, on the same code path, ALLOWS it - so the "
|
||||
"refusal is discriminating rather than universal",
|
||||
_v_two.kind == "allowed")
|
||||
|
||||
# ⚠️ NOT KEYED ON THE PRODUCT ENUM. A DAY-AHEAD series that happens to print one
|
||||
# price all day must be refused for the same reason, or the rule is a label
|
||||
# check wearing arithmetic's clothes.
|
||||
_flat_dayahead = TariffPlan(
|
||||
PriceRead.series(
|
||||
[PricePoint(start=brussels(THU + timedelta(hours=i)),
|
||||
eur_per_kwh=0.2222, provisional=False) for i in range(24)],
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", THU),
|
||||
None, "provider")
|
||||
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
|
||||
"is arithmetic and not a product label",
|
||||
_flat_dayahead.arbitrage(peak_guard_active=False).kind == "refused")
|
||||
|
||||
# The numbers an optimiser needs exist ONLY on the permitting variant.
|
||||
check("the allowed verdict carries the spread, the cheapest hour and the "
|
||||
"dearest hour - the whole input set for a trade",
|
||||
near(_v_two.spread_eur_kwh, DAY_P - NIGHT_P)
|
||||
and near(_v_two.cheapest.eur_per_kwh, NIGHT_P)
|
||||
and near(_v_two.dearest.eur_per_kwh, DAY_P))
|
||||
check("...and a REFUSED verdict carries none of them: .spread_eur_kwh raises "
|
||||
"Absence rather than answering 0.0, which is the number that would make "
|
||||
"every hour look equally good",
|
||||
raises(lambda: _v_fixed.spread_eur_kwh, Absence)
|
||||
and raises(lambda: _v_fixed.cheapest, Absence))
|
||||
check("...and it is not iterable and has no .get(), the other two routes from "
|
||||
"a refusal to an empty, harmless-looking loop",
|
||||
raises(lambda: list(_v_fixed), Absence)
|
||||
and raises(lambda: _v_fixed.get("spread_eur_kwh", 0.0), Absence))
|
||||
check("...and match() demands BOTH arms, so the refusal cannot be fallen "
|
||||
"through", raises(lambda: _v_fixed.match(allowed=lambda v: 1), TypeError))
|
||||
check("...and there is no falsy variant, so `if verdict:` cannot look like a "
|
||||
"guard while being none", bool(_v_fixed) is True and bool(_v_two) is True)
|
||||
|
||||
# §10.1 priority 2 over priority 3, expressed so OPT-04 cannot skip it.
|
||||
check("peak shaving outranks arbitrage: the SAME allowed plan is refused when "
|
||||
"the peak guard is holding",
|
||||
p_two.arbitrage(peak_guard_active=True).kind == "refused")
|
||||
check("...and the reason names the ordering rather than the tariff",
|
||||
"priority 2" in p_two.arbitrage(peak_guard_active=True).reason)
|
||||
check("...and the question cannot be skipped: peak_guard_active is required "
|
||||
"and keyword-only, so a spread is not obtainable without answering it",
|
||||
raises(lambda: p_two.arbitrage(), TypeError)
|
||||
and raises(lambda: p_two.arbitrage(False), TypeError))
|
||||
check("...nor answered with a truthy 'unknown'",
|
||||
raises(lambda: p_two.arbitrage(peak_guard_active=None), TypeError)
|
||||
and raises(lambda: p_two.arbitrage(peak_guard_active="no"), TypeError))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. the cheap-window rule, on a tariff where it is not degenerate")
|
||||
# ⚠️ WHY THREE LEVELS. Under two prices, "below the mean" and "equal to the
|
||||
# minimum" select the SAME hours, so a two-period profile cannot tell
|
||||
# cheap_now()'s actual rule from `price == min(prices)`. This fixture's `day`
|
||||
# level sits between the two, and the check below PROVES the two rules disagree
|
||||
# before anything is graded with it.
|
||||
|
||||
THREE = parse_static_profile(THREE_LEVEL)
|
||||
p3 = static_plan(opts(THREE_LEVEL), SPAN_A,
|
||||
SPAN_A + timedelta(days=MEAN_WINDOW_DAYS))
|
||||
_mean3 = p3.mean_eur_kwh()
|
||||
_min3 = min(p.eur_per_kwh for p in p3.read.points)
|
||||
_day3 = 0.2900
|
||||
check(f"the three-level fixture is non-degenerate: the minimum {_min3:.4f} is "
|
||||
f"strictly below the middle level {_day3:.4f}, which is strictly below "
|
||||
f"the mean {_mean3:.4f} - so 'below the mean' and 'is the minimum' select "
|
||||
f"DIFFERENT sets of hours and this fixture can tell them apart",
|
||||
_min3 < _day3 < _mean3)
|
||||
check("...so cheap_now() calls the 07:00-17:00 MIDDLE level cheap - it is under "
|
||||
"the mean without being the cheapest price of the day, which a "
|
||||
"`price == min(prices)` rule would get wrong and a `price < mean` rule "
|
||||
"gets right", p3.cheap_now(at_local(THU, 12)) is True)
|
||||
check("...and the middle level really is not the minimum, so that check graded "
|
||||
"the difference rather than restating it",
|
||||
not near(p3.price_at(at_local(THU, 12)), _min3))
|
||||
check("...and calls the 00:00-07:00 minimum cheap",
|
||||
p3.cheap_now(at_local(THU, 3)) is True)
|
||||
check("...and the 17:00-21:00 peak not cheap",
|
||||
p3.cheap_now(at_local(THU, 18)) is False)
|
||||
|
||||
# ⚠️ STRICTLY BELOW, AND THAT IS AC2 IN THE SHIFTING HALF. On a flat series
|
||||
# `<=` would make EVERY hour a cheap window and a fixed-tariff site would top
|
||||
# the battery up from the grid at any hour - the opposite of self-consumption
|
||||
# only.
|
||||
_fixed_hours = [at_local(THU, h) for h in range(0, 24, 3)]
|
||||
check(f"a fully-fixed tariff has NO cheap window at any hour of the day "
|
||||
f"{[h for h in range(0, 24, 3) if p_fixed.cheap_now(at_local(THU, h))]} - "
|
||||
f"which is what `<` buys and `<=` would destroy",
|
||||
not any(p_fixed.cheap_now(t) for t in _fixed_hours))
|
||||
check("...and the price really is defined at every one of those hours, so the "
|
||||
"'no cheap window' answer is a priced no and not a missing read",
|
||||
all(near(p_fixed.price_at(t), FIXED_P) for t in _fixed_hours))
|
||||
# ...and on the two-period tariff the cheap hours are exactly the night ones.
|
||||
_cheap_h = {h for h in range(24) if p_two.cheap_now(at_local(THU, h))}
|
||||
_night_h = {h for h in range(24) if TWO.at(at_local(THU, h)).name == "night"}
|
||||
check(f"on the day/night tariff the cheap hours ARE the night hours, derived "
|
||||
f"two ways {sorted(_cheap_h)}",
|
||||
_cheap_h == _night_h and 0 < len(_cheap_h) < 24)
|
||||
|
||||
# ⚠️ THE MEAN IS OVER SEVEN DAYS, NOT OVER THE HOUR YOU ASKED AT. A 48 h mean on
|
||||
# a Friday afternoon is ~2/3 weekend, so the same Tuesday 09:00 would come out
|
||||
# cheap or dear depending on when the question was put.
|
||||
FRI = datetime(2026, 1, 16, tzinfo=UTC)
|
||||
_from_thu = static_status(opts(TWO_PERIOD), at_local(THU, 12))
|
||||
_from_fri = static_status(opts(TWO_PERIOD), at_local(FRI, 15))
|
||||
_mean_thu = static_plan(opts(TWO_PERIOD), at_local(THU, 12),
|
||||
at_local(THU, 12) + timedelta(days=MEAN_WINDOW_DAYS)
|
||||
).mean_eur_kwh()
|
||||
_mean_fri = static_plan(opts(TWO_PERIOD), at_local(FRI, 15),
|
||||
at_local(FRI, 15) + timedelta(days=MEAN_WINDOW_DAYS)
|
||||
).mean_eur_kwh()
|
||||
check(f"the 7-day mean is stable across the day it is asked on "
|
||||
f"({_mean_thu:.4f} vs {_mean_fri:.4f}), so a window's cheapness is a "
|
||||
f"property of the tariff and not of the clock",
|
||||
near(_mean_thu, _mean_fri, 5e-4))
|
||||
_mean48_thu = static_plan(opts(TWO_PERIOD), at_local(THU, 12),
|
||||
at_local(THU, 12) + timedelta(days=2)).mean_eur_kwh()
|
||||
_mean48_fri = static_plan(opts(TWO_PERIOD), at_local(FRI, 15),
|
||||
at_local(FRI, 15) + timedelta(days=2)).mean_eur_kwh()
|
||||
check(f"...and the 48 h mean this replaces is NOT stable ({_mean48_thu:.4f} vs "
|
||||
f"{_mean48_fri:.4f}), so the seven days are buying something real",
|
||||
not near(_mean48_thu, _mean48_fri, 5e-4))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("4. DST - 23 and 25 hour days, where a 24-slot implementation is wrong")
|
||||
# ⚠️ 2026: CEST from 01:00 UTC Sunday 29 March, CET from 01:00 UTC Sunday
|
||||
# 25 October (price.py's brussels_offset, checked in PR #16 against zoneinfo
|
||||
# over 149,040 instants). Both are SUNDAYS, so the weekend row prices them -
|
||||
# which is itself worth knowing, because the wrap-around weekday row is not what
|
||||
# is under test here.
|
||||
SPRING = datetime(2026, 3, 29, tzinfo=UTC)
|
||||
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC)
|
||||
|
||||
|
||||
def local_day_points(profile, day_utc):
|
||||
"""Hourly points whose BRUSSELS date is that of `day_utc` + 12 h."""
|
||||
target = brussels(day_utc + timedelta(hours=12)).date()
|
||||
pts = profile.series(day_utc - timedelta(hours=6),
|
||||
day_utc + timedelta(hours=30), provisional=False)
|
||||
return [p for p in pts if p.start.date() == target]
|
||||
|
||||
|
||||
_spring = derived(lambda: local_day_points(TWO, SPRING))
|
||||
_autumn = derived(lambda: local_day_points(TWO, AUTUMN))
|
||||
check(f"the spring-forward local day emits 23 points, not 24 "
|
||||
f"({len(_spring) if not isinstance(_spring, _Raised) else _spring})",
|
||||
not isinstance(_spring, _Raised) and len(_spring) == 23)
|
||||
check(f"the autumn-back local day emits 25 points, not 24 "
|
||||
f"({len(_autumn) if not isinstance(_autumn, _Raised) else _autumn})",
|
||||
not isinstance(_autumn, _Raised) and len(_autumn) == 25)
|
||||
check("...and the two 02:00 points in October are DIFFERENT instants at "
|
||||
"DIFFERENT offsets, which a naive local-hour key would collide",
|
||||
len({p.start.utcoffset() for p in _autumn if p.start.hour == 2}) == 2)
|
||||
check("...and every emitted start is strictly increasing in UTC, which is what "
|
||||
"price.py's boundary check demands of a fetched feed too",
|
||||
all(a.start.astimezone(UTC) < b.start.astimezone(UTC)
|
||||
for a, b in zip(_autumn, _autumn[1:])))
|
||||
# A weekday profile across a transition, so the wrap-around path sees one too.
|
||||
_wrapdays = local_day_points(TWO, datetime(2026, 3, 30, tzinfo=UTC))
|
||||
check("a normal Monday still emits 24, so the 23/25 above are the transitions "
|
||||
"and not a systematic miscount", len(_wrapdays) == 24)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. a failed read is not an observation, and a gap is not a price")
|
||||
|
||||
_none = static_plan(opts(None), THU, THU + timedelta(days=1))
|
||||
check("no static_profile produces a NO-DATA read, not an empty series and not "
|
||||
"a zero one", not _none.has_prices)
|
||||
check("...and reading a price off it RAISES rather than answering 0.0 - the "
|
||||
"value that would make every hour look free",
|
||||
raises(lambda: _none.price_at(THU), Absence)
|
||||
and raises(_none.mean_eur_kwh, Absence)
|
||||
and raises(_none.spread_eur_kwh, Absence))
|
||||
check("...and its underlying read has no .points at all, so `min(read.points)` "
|
||||
"cannot run against it",
|
||||
raises(lambda: _none.read.points, Absence))
|
||||
check("...and arbitrage is refused, per §11.4",
|
||||
_none.arbitrage(peak_guard_active=False).kind == "refused"
|
||||
and arbitrage_allowed(_none.read) is False)
|
||||
check("...and static_status() says so in words rather than reporting a price",
|
||||
static_status(opts(None), THU) == (False, "no static profile"))
|
||||
|
||||
# ⚠️ THE TILING REFUSAL. An uncovered hour has no price and the only ways to
|
||||
# serve it are to invent one or to fail when the clock reaches it.
|
||||
_gap = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
|
||||
"eur_per_kwh": DAY_P},
|
||||
{"name": "night", "from": "23:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": NIGHT_P}]}
|
||||
check("a profile with a 22:00-23:00 gap is REFUSED, not defaulted",
|
||||
raises(lambda: parse_static_profile(_gap), ProfileInvalid))
|
||||
check("...and names the uncovered range so the operator can fix it",
|
||||
"22:00-23:00" in reason(lambda: parse_static_profile(_gap)))
|
||||
_overlap = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
|
||||
"eur_per_kwh": DAY_P},
|
||||
{"name": "night", "from": "21:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": NIGHT_P}]}
|
||||
check("...and an overlap is refused too - two prices for one hour means one "
|
||||
"wins on list order",
|
||||
raises(lambda: parse_static_profile(_overlap), ProfileInvalid)
|
||||
and "2 prices" in reason(lambda: parse_static_profile(_overlap)))
|
||||
# THE CLASSIC MISTAKE the docstring warns about: no weekend row.
|
||||
_noweekend = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
|
||||
"eur_per_kwh": DAY_P},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
|
||||
"eur_per_kwh": NIGHT_P}]}
|
||||
check("a weekday-only profile is refused because Saturday and Sunday have no "
|
||||
"price - the mistake a wrap-around window invites",
|
||||
raises(lambda: parse_static_profile(_noweekend), ProfileInvalid)
|
||||
and "sat" in reason(lambda: parse_static_profile(_noweekend)))
|
||||
# ...and the shipped shape is accepted, so the rule is not simply "refuse all".
|
||||
check("...while the three-row Belgian shape IS accepted, so the tiling rule "
|
||||
"discriminates rather than refusing everything",
|
||||
isinstance(parse_static_profile(TWO_PERIOD), StaticProfile))
|
||||
|
||||
_bad = [
|
||||
("not JSON at all", "day 07:00-22:00 0.34"),
|
||||
("a price in cents", {"periods": [dict(FULLY_FIXED["periods"][0],
|
||||
eur_per_kwh=30.7)]}),
|
||||
("a non-numeric price", {"periods": [dict(FULLY_FIXED["periods"][0],
|
||||
eur_per_kwh="cheap")]}),
|
||||
("a missing days selector", {"periods": [
|
||||
{"name": "x", "from": "00:00", "to": "24:00", "eur_per_kwh": 0.3}]}),
|
||||
("an unknown day name", {"periods": [dict(FULLY_FIXED["periods"][0],
|
||||
days="mon-frd")]}),
|
||||
("a decimal hour", {"periods": [dict(FULLY_FIXED["periods"][0],
|
||||
**{"from": "7.5"})]}),
|
||||
("an unsupported timezone", dict(FULLY_FIXED, timezone="Europe/Madrid")),
|
||||
("an empty periods list", {"periods": []}),
|
||||
]
|
||||
for label, value in _bad:
|
||||
payload = value if isinstance(value, str) else json.dumps(value)
|
||||
check(f"refused: {label}",
|
||||
raises(lambda p=payload: parse_static_profile(p), ProfileInvalid))
|
||||
check("...and every one of those refusals says something an operator can act "
|
||||
"on, rather than just failing",
|
||||
all(len(reason(lambda p=(v if isinstance(v, str) else json.dumps(v)):
|
||||
parse_static_profile(p))) > 40 for _, v in _bad))
|
||||
for label, value in (("empty", ""), ("null", "null"), ("absent", None),
|
||||
("whitespace", " ")):
|
||||
check(f"...while {label!r} means 'no profile' rather than an error",
|
||||
parse_static_profile(value) is None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("6. AC3 - the static-profile regression case, on the Belgian data that exists")
|
||||
# ⚠️ WHAT THE "BELGIAN SCENARIO SUITE" ACTUALLY IS IN THIS REPO. specs §17 names
|
||||
# six scenarios; none of them is a static-profile case, which is why AC3 asks
|
||||
# for a seventh. What exists as DATA is two days of this house's real P1 net
|
||||
# power, captured from the live meter and committed as
|
||||
# `scenarios/p1-net-2026-08-{20,23}.jsonl.gz` - the same fixtures test_tariff.py
|
||||
# part 9 replays for the capacity tariff. Nothing longer was ever captured.
|
||||
#
|
||||
# So this case is measured against REAL BELGIAN HOUSEHOLD LOAD, and the two days
|
||||
# happen to be one weekday and one weekend day, which is exactly the pair a
|
||||
# day/night tariff needs to be graded on. It is NOT a 30-day replay and this
|
||||
# section does not claim to be one; the numbers it prints are the numbers those
|
||||
# two days actually produce.
|
||||
|
||||
REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz"))
|
||||
print(f" replay fixtures found: {len(REPLAY)} "
|
||||
f"({', '.join(p.name for p in REPLAY) or 'none'})")
|
||||
check("the committed replay fixtures are present - a regression fixture CI "
|
||||
"cannot see is not a regression fixture", len(REPLAY) >= 2)
|
||||
|
||||
MAX_GAP_S = 60.0 # a longer hole in the capture is not counted as energy
|
||||
|
||||
|
||||
def replay_import(path):
|
||||
"""(kWh imported per local tariff window, total kWh, span hours, weekday).
|
||||
|
||||
⚠️ THE WINDOW IS DECIDED HERE BY AN INDEPENDENT ROUTE - the Brussels-local
|
||||
hour of the sample, tested against the fixture's own 07:00/22:00 boundaries
|
||||
- rather than by asking the module under test. The two must agree; that
|
||||
agreement is the check.
|
||||
"""
|
||||
per_window = {}
|
||||
total = 0.0
|
||||
first = last = None
|
||||
prev = None
|
||||
days = set()
|
||||
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
row = json.loads(line)
|
||||
t = datetime.fromisoformat(row["t"])
|
||||
w = float(row["w"])
|
||||
if first is None:
|
||||
first = t
|
||||
last = t
|
||||
if prev is not None:
|
||||
dt = (t - prev[0]).total_seconds()
|
||||
if 0 < dt <= MAX_GAP_S and prev[1] > 0:
|
||||
kwh = prev[1] * dt / 3_600_000.0
|
||||
loc = brussels(prev[0])
|
||||
days.add(loc.weekday())
|
||||
window = ("day" if loc.weekday() < 5
|
||||
and 7 <= loc.hour < 22 else "night")
|
||||
per_window[window] = per_window.get(window, 0.0) + kwh
|
||||
total += kwh
|
||||
prev = (t, w)
|
||||
span = (last - first).total_seconds() / 3600.0 if first else 0.0
|
||||
return per_window, total, span, days
|
||||
|
||||
|
||||
def cost_under(path, profile):
|
||||
"""EUR the imported energy cost, priced sample by sample by the MODULE."""
|
||||
total = 0.0
|
||||
prev = None
|
||||
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
row = json.loads(line)
|
||||
t = datetime.fromisoformat(row["t"])
|
||||
w = float(row["w"])
|
||||
if prev is not None:
|
||||
dt = (t - prev[0]).total_seconds()
|
||||
if 0 < dt <= MAX_GAP_S and prev[1] > 0:
|
||||
total += (prev[1] * dt / 3_600_000.0) * profile.price_at(prev[0])
|
||||
prev = (t, w)
|
||||
return total
|
||||
|
||||
|
||||
replay_days = 0.0
|
||||
graded = 0
|
||||
for path in REPLAY:
|
||||
windows, total_kwh, span_h, weekdays = replay_import(path)
|
||||
replay_days += span_h / 24.0
|
||||
day_kwh = windows.get("day", 0.0)
|
||||
night_kwh = windows.get("night", 0.0)
|
||||
kind = "weekday" if weekdays and min(weekdays) < 5 else "weekend"
|
||||
print(f" {path.name}: {span_h:.1f} h, {kind} "
|
||||
f"(weekday indices {sorted(weekdays)}), {total_kwh:.3f} kWh imported "
|
||||
f"= {day_kwh:.3f} day + {night_kwh:.3f} night")
|
||||
|
||||
check(f"{path.name}: the two routes to the total agree",
|
||||
near(day_kwh + night_kwh, total_kwh, 1e-9))
|
||||
|
||||
# The module's own pricing must reproduce the independently-split volumes.
|
||||
c_two = cost_under(path, TWO)
|
||||
c_expect = day_kwh * DAY_P + night_kwh * NIGHT_P
|
||||
check(f"{path.name}: the module prices the real day at "
|
||||
f"EUR {c_two:.4f}, which equals the independently-split "
|
||||
f"{day_kwh:.3f} kWh x {DAY_P} + {night_kwh:.3f} kWh x {NIGHT_P} = "
|
||||
f"EUR {c_expect:.4f}", near(c_two, c_expect, 1e-9))
|
||||
|
||||
c_fix = cost_under(path, FIX)
|
||||
check(f"{path.name}: under a FULLY FIXED tariff the same energy costs "
|
||||
f"EUR {c_fix:.4f} = {total_kwh:.3f} kWh x {FIXED_P}, one price for "
|
||||
f"every hour", near(c_fix, total_kwh * FIXED_P, 1e-9))
|
||||
|
||||
# ⚠️ THE SHIFTING FIGURE, AND THE DEGENERACY GUARD IN FRONT OF IT. A day
|
||||
# whose entire import landed in one window grades nothing: the saving would
|
||||
# be 0 under a correct implementation AND under one that ignored the clock.
|
||||
if day_kwh > 0 and night_kwh > 0:
|
||||
graded += 1
|
||||
saving = c_two - total_kwh * NIGHT_P
|
||||
check(f"{path.name}: this day DOES import in both windows "
|
||||
f"({day_kwh:.3f} / {night_kwh:.3f} kWh), so the split grades "
|
||||
f"something", True)
|
||||
check(f"{path.name}: shifting every day-window kWh into the night "
|
||||
f"window saves EUR {saving:.4f}, which is exactly "
|
||||
f"{day_kwh:.3f} kWh x the {DAY_P - NIGHT_P:.4f} spread - two "
|
||||
f"routes to one number",
|
||||
near(saving, day_kwh * (DAY_P - NIGHT_P), 1e-9) and saving > 0)
|
||||
# AC2 on the same real data: under a fixed tariff there is nothing to
|
||||
# shift, and that is arithmetic rather than a special case.
|
||||
check(f"{path.name}: under the FIXED tariff the identical shift saves "
|
||||
f"exactly nothing, so self-consumption-only is the right "
|
||||
f"behaviour for that product",
|
||||
near(c_fix - total_kwh * FIXED_P, 0.0, 1e-12))
|
||||
else:
|
||||
print(f" {path.name}: import fell in ONE window only "
|
||||
f"({day_kwh:.3f} day / {night_kwh:.3f} night) - this day is a "
|
||||
f"day-typing witness, not a shifting one, and no saving is "
|
||||
f"claimed from it")
|
||||
|
||||
print()
|
||||
print(f" AC3 MEASURED: {replay_days:.1f} DAYS of real Belgian household "
|
||||
f"P1 data, {graded} of {len(REPLAY)} days carrying import in BOTH tariff "
|
||||
f"windows and therefore grading the split.")
|
||||
print(f" AC3 ASKS FOR A REGRESSION CASE IN THE BELGIAN SUITE, AND THIS IS "
|
||||
f"ONE - but the suite holds two captured days, not a season, so nothing "
|
||||
f"here is a claim about annual savings. §17's six named scenarios do not "
|
||||
f"include a static-profile case; this is the seventh, and TEST-01 should "
|
||||
f"say so.")
|
||||
check("the replay produced a measurable split over a stated sample size, "
|
||||
"rather than a criterion quietly redefined to fit the data",
|
||||
graded >= 1 and replay_days < 30)
|
||||
check("...and BOTH day types are present in the fixtures, which is what makes "
|
||||
"the weekend row testable on real data at all",
|
||||
len({("weekday" if min(replay_import(p)[3]) < 5 else "weekend")
|
||||
for p in REPLAY}) == 2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("7. §11.4 - the profile is the fallback, and the fallback is provisional")
|
||||
|
||||
|
||||
async def _dead(url, timeout_s):
|
||||
raise OSError("the supplier's feed is down")
|
||||
|
||||
|
||||
def run(coro):
|
||||
import asyncio
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
_cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
|
||||
price_feed_url="https://api.energy-charts.info/price?bzn=BE",
|
||||
price_timeout_s=1, price_markup_eur_kwh=0.03,
|
||||
price_taxes_eur_kwh=0.06)
|
||||
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead))
|
||||
check("a dead feed falls back to the static profile rather than to nothing",
|
||||
_fb.origin == "fallback" and _fb.has_prices)
|
||||
check("...with the SAME prices the configured-product path produces, because "
|
||||
"it is one generator and not a second implementation",
|
||||
[p.eur_per_kwh for p in _fb.read.points]
|
||||
== [p.eur_per_kwh for p in static_plan(
|
||||
opts(TWO_PERIOD), THU, THU + timedelta(days=1)).read.points])
|
||||
check("...marked PROVISIONAL, which is the one flag that differs",
|
||||
_fb.provisional and plan_is_provisional(_fb.read))
|
||||
check("...and arbitrage is refused on it, per §11.4, even though a full series "
|
||||
"exists and has a real spread",
|
||||
_fb.arbitrage(peak_guard_active=False).kind == "refused"
|
||||
and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P))
|
||||
check("...while the SAME profile as the configured product allows it - so the "
|
||||
"refusal is about provisionality and not about the profile",
|
||||
static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1)
|
||||
).arbitrage(peak_guard_active=False).kind == "allowed")
|
||||
check("...and the provisional plan has no cheap window either: §8.3 forbids an "
|
||||
"irreversible action on a provisional price, and a grid top-up is one",
|
||||
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
|
||||
_fb_none = run(resolve_tariff(opts(None, price_provider="generic_dayahead",
|
||||
price_timeout_s=1),
|
||||
THU, THU + timedelta(days=1), transport=_dead))
|
||||
check("a dead feed with NO static_profile has nothing to fall back to, and "
|
||||
"says that rather than inventing a tariff",
|
||||
_fb_none.origin == "none" and not _fb_none.has_prices)
|
||||
|
||||
# A live feed still wins over the profile.
|
||||
_body = json.dumps({"data": [
|
||||
{"start": (THU + timedelta(hours=i)).isoformat(), "price_eur_mwh": 40 + i}
|
||||
for i in range(24)]}).encode()
|
||||
|
||||
|
||||
async def _live(url, timeout_s):
|
||||
return _body
|
||||
|
||||
|
||||
_live_plan = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1),
|
||||
transport=_live))
|
||||
check("a live feed is used in preference to the static profile",
|
||||
_live_plan.origin == "provider"
|
||||
and _live_plan.product is PriceProduct.DAY_AHEAD)
|
||||
check("...and arbitrage is allowed on it, with the spread derived from the "
|
||||
"feed rather than from the profile",
|
||||
_live_plan.arbitrage(peak_guard_active=False).kind == "allowed"
|
||||
and near(_live_plan.arbitrage(peak_guard_active=False).spread_eur_kwh,
|
||||
0.023, 1e-9))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("8. the startup gate, and CFG-03")
|
||||
|
||||
|
||||
def vbase(**over):
|
||||
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off",
|
||||
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
def why(problems, needle):
|
||||
return any(needle in p for p in problems)
|
||||
|
||||
|
||||
check("a good static_profile starts",
|
||||
validate_startup_config(
|
||||
vbase(static_profile=json.dumps(TWO_PERIOD)), -5000, 5000) == [])
|
||||
check("a gapped one is REFUSED at startup, not at 22:00 on the day it bites",
|
||||
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
|
||||
-5000, 5000),
|
||||
"static_profile cannot be used"))
|
||||
check("...and the refusal names the uncovered range",
|
||||
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
|
||||
-5000, 5000), "22:00-23:00"))
|
||||
check("static_profile alongside the Home Assistant price entities is refused - "
|
||||
"main.py reads one of them and the loser is silent",
|
||||
why(validate_startup_config(
|
||||
vbase(static_profile=json.dumps(TWO_PERIOD),
|
||||
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
|
||||
-5000, 5000), "HOME ASSISTANT"))
|
||||
check("...and the Home Assistant pair ALONE still starts, so nothing is taken "
|
||||
"away from a live install",
|
||||
validate_startup_config(vbase(price_now_entity="sensor.now",
|
||||
price_avg_entity="sensor.avg"),
|
||||
-5000, 5000) == [])
|
||||
check("...and an empty static_profile is never refused: §9.2 puts a large share "
|
||||
"of the installed base on a fixed tariff, and §11.4 makes no-price-data a "
|
||||
"supported state",
|
||||
validate_startup_config(vbase(static_profile=""), -5000, 5000) == []
|
||||
and validate_startup_config(vbase(), -5000, 5000) == [])
|
||||
check("...and a fully-fixed profile starts, since a one-price site is a "
|
||||
"supported product and not a misconfiguration",
|
||||
validate_startup_config(vbase(static_profile=json.dumps(FULLY_FIXED)),
|
||||
-5000, 5000) == [])
|
||||
|
||||
# ⚠️ CFG-03 IS NOT WIDENED BY THIS TICKET. `static_profile` was added to
|
||||
# app/price.py's PRICE_OPTION_DEFAULTS - the ONE in-code mirror TARIFF-02
|
||||
# created and pinned to config.yaml - rather than to a second defaults dict in
|
||||
# the new module. That mirror is enforced by test_price.py section 1, which
|
||||
# reads config.yaml; this check is the other half of the claim: the new module
|
||||
# must carry NO defaults dict of its own.
|
||||
_SRC = (HERE / "app" / "static_tariff.py").read_text(encoding="utf-8")
|
||||
check("the new module holds no option-defaults dict of its own, so this ticket "
|
||||
"added ZERO new places an option default lives (CFG-03)",
|
||||
"OPTION_DEFAULTS" not in _SRC.replace("PRICE_OPTION_DEFAULTS", ""))
|
||||
check("...and it reads the option through price.price_option(), the framework's "
|
||||
"single read path, rather than through opts.get(key, <a number>)",
|
||||
"price_option(opts" in _SRC and 'opts.get("static_profile"' not in _SRC)
|
||||
_MAIN = (HERE / "app" / "main.py").read_text(encoding="utf-8")
|
||||
check("...and main.py grew no `o.get(\"static_profile\", <a default>)` fallback "
|
||||
"either - it calls static_status(o, now), and the option is read once, "
|
||||
"inside the framework",
|
||||
'get("static_profile"' not in _MAIN and "static_status(" in _MAIN)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
Reference in New Issue
Block a user