checks / unit (pull_request) Successful in 7m16s
CHANGELOG.md was the only conflict: both entries belong under the same
`## Unreleased` heading, TARIFF-10's first because it merged first. No
version number picked.
app/main.py auto-merged, and was re-read because of it. TARIFF-10 changed
WHICH installs run `run_tariff_refresh()` (`arbitrage.enabled(opts)` ->
`tariff_refresh_wanted(opts)`); this card changed what that task does on
a failed fetch. Nothing in the keep/replace reasoning changed, and the
two newly reachable configurations are now DRIVEN in test_arbitrage.py
8b rather than reasoned about:
* a provider configured with trading DISARMED is the case the guard
was designed for, and needs no second fixture because nothing on
this path reads `arbitrage_enabled` at all - asserted over the AST's
symbols and non-docstring constants, after two drafts of that check
went red on a CORRECT tree for matching the word "arbitrage" in a
docstring and in a log line;
* `price_provider: off` + a `static_profile` resolves to the
CONFIGURED product (`origin: "static"`), so `standing.from_feed` is
false on every refresh and the guard never latches - driven twice in
a row, because a guard that latched there would freeze a static
site's plan at its first window for the life of the install.
Also: the kept-plan log line said "arbitrage stops with it", which after
TARIFF-10 is false on a site that fetches without trading; it now says
"arbitrage, where it is armed, stops with it". The keep decision is
strictly downstream of `resolve_tariff()`, so the §8.3 carry into
price_history - TARIFF-10's whole point - is unaffected either way.
All 29 suites green on the merged tree, including TARIFF-10's new
test_price.py section 13. All eleven mutations RE-DRIVEN on the merged
tree rather than carried over: eleven red, no survivors, every revert
fingerprinted. M11 is new - making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B) is red on five named checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
1152 lines
56 KiB
Python
1152 lines
56 KiB
Python
"""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`, `min_spread_eur_kwh` and `uncertainty` as REQUIRED KEYWORD
|
|
ARGUMENTS, none of them defaulted. §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.
|
|
|
|
⚠️ FIVE 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. fewer than TWO FIRM HOURS §8.3. FCST-03 rewrote this one: it used
|
|
to be "any point in the series is
|
|
provisional", which would have switched
|
|
arbitrage off every morning on every
|
|
dynamic site once carry-forward filling
|
|
landed (a series is part-firm and
|
|
part-carried until the 14-16 h CET
|
|
publication). What §8.3 forbids is
|
|
acting ON a provisional price, so the
|
|
provisional hours are DROPPED and
|
|
everything below is computed over the
|
|
firm ones. A wholly provisional series -
|
|
the §11.4 fallback - still has no firm
|
|
pair and is still refused.
|
|
4. the FIRM series is FLAT max - min == 0, so there is nothing to
|
|
arbitrage. THIS is TARIFF-03's 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.
|
|
5. the spread does not clear the §8.3/§9.2/§11.4, new in FCST-03. The
|
|
WIDENED threshold caller states its round-trip cost
|
|
threshold; this widens it by the widest
|
|
active uncertainty source - a real-time
|
|
/ imbalance PRODUCT (derived from the
|
|
declared `PriceProduct`, never from a
|
|
plugin id) or the caller's own
|
|
`NO_WEATHER`. See price.py's
|
|
`required_spread()`; the combination
|
|
rule is `max`, not a product.
|
|
|
|
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,
|
|
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
|
provider_configured, required_spread, uncertainty_of, widen_factor,
|
|
)
|
|
|
|
_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, required, sources):
|
|
return _Allowed(spread, cheapest, dearest, regime, product, required,
|
|
sources)
|
|
|
|
@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)
|
|
|
|
|
|
# ⚠️ FIRM BY CONSTRUCTION, ASSERTED RATHER THAN TRUSTED (FCST-03). §8.3 forbids
|
|
# an irreversible action on a provisional price, and the two points on the
|
|
# permitting variant ARE the action: they are the hours a strategy buys and
|
|
# sells in. A provisional point reaching this far is a defect in the refusal
|
|
# ladder above, and it fails loudly at the moment of construction rather than
|
|
# quietly at the meter a day later.
|
|
def _firm_or_die(cheapest, dearest):
|
|
for role, pt in (("cheapest", cheapest), ("dearest", dearest)):
|
|
if getattr(pt, "provisional", True):
|
|
raise ValueError(
|
|
f"the {role} hour handed to an arbitrage strategy is "
|
|
f"PROVISIONAL ({getattr(pt, 'start', '?')}). §8.3: no "
|
|
f"irreversible action may be taken on a provisional price - "
|
|
f"an ALLOWED verdict cannot be built out of one.")
|
|
|
|
|
|
class _Allowed(Arbitrage):
|
|
kind = "allowed"
|
|
|
|
def __init__(self, spread, cheapest, dearest, regime, product, required,
|
|
sources):
|
|
_firm_or_die(cheapest, dearest)
|
|
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
|
|
# FCST-03. The threshold this spread actually cleared, and why it was
|
|
# the size it was. An optimiser that wants to know how much margin it
|
|
# has does not get to re-derive it from a product enum.
|
|
self.required_spread_eur_kwh = required
|
|
self.uncertainty = frozenset(sources)
|
|
self.words = (f"spread {spread:.4f} EUR/kWh over "
|
|
f"{product.value} ({regime} thresholds, cleared "
|
|
f"{required:.4f} EUR/kWh)")
|
|
|
|
|
|
|
|
|
|
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 from_feed(self) -> bool:
|
|
"""Is this series an OBSERVATION of the configured provider's feed?
|
|
|
|
TARIFF-12. `origin` names four ways a plan is obtained; this collapses
|
|
them to the one question a caller holding a PREVIOUS plan has to ask
|
|
about a new one - did this resolution see the feed, or is it standing
|
|
in for one that did not? Only `"provider"` saw it. `"fallback"` and
|
|
`"none"` are the two §11.4 rows, and `"static"` is a site with no
|
|
feed to observe at all, where the question does not arise.
|
|
|
|
⚠️ IT IS NOT `has_prices`, AND THE DIFFERENCE IS THE WHOLE POINT. A
|
|
§11.4 fallback HAS prices - the operator's own static profile - but
|
|
every point `_degrade()` builds carries `provisional=True`, so
|
|
`firm_points()` is empty, `arbitrage()` refuses and `cheap_now()` is
|
|
False. It is a real tariff and it is not an observation, and a caller
|
|
deciding whether to REPLACE an observation with it needs the second
|
|
fact, not the first.
|
|
|
|
⚠️ AND IT IS A PROPERTY HERE RATHER THAN AN `origin == "provider"` AT
|
|
THE CALL SITE, for `main.fresh_tariff_plan()`'s stated reason: a check
|
|
has to be able to assert on the shipped expression rather than on a
|
|
transcription that drifts, and `origin`'s vocabulary belongs to this
|
|
module.
|
|
"""
|
|
return self.origin == "provider"
|
|
|
|
@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 firm_points(self) -> tuple:
|
|
"""The points that carry a PUBLISHED price for their own hour (§8.3).
|
|
|
|
The complement of the carried-forward fill FCST-03 adds. Empty on a
|
|
§11.4 fallback plan and on a `no_data` read - and empty is honest here,
|
|
because it is a count of observations, not a value invented in place of
|
|
one. Never used to answer "what does this hour cost"; only to answer
|
|
"may something irreversible rest on it".
|
|
"""
|
|
return self.read.match(
|
|
series=lambda r: tuple(p for p in r.points if not p.provisional),
|
|
no_data=lambda _r: ())
|
|
|
|
def cheap_now(self, instant: datetime) -> bool:
|
|
"""Is now a cheap window? STRICTLY below the mean - see the docstring.
|
|
|
|
⚠️ FALSE ON A PROVISIONAL HOUR, AND IT IS THIS HOUR THAT DECIDES
|
|
(FCST-03). §8.3 forbids an irreversible action on a provisional price
|
|
and a paid grid top-up of a battery is about as irreversible as this
|
|
add-on gets - but the price that would be paid is THIS hour's, so a
|
|
firm hour does not stop being cheap because tomorrow's hours have not
|
|
been published yet. Before FCST-03 the whole plan had to be firm, which
|
|
was the same answer for the only two series that existed then (all firm,
|
|
or all provisional) and would have switched the cheap window off every
|
|
morning once carry-forward filling landed.
|
|
|
|
The mean is taken over the FIRM hours for the same reason: yesterday's
|
|
carried values are not observations of what today costs, and a
|
|
comparison basis half-built out of them grades this hour against a
|
|
number nobody published. On an all-firm series - every series that
|
|
existed before FCST-03 - it is the same mean to the last digit.
|
|
|
|
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".
|
|
"""
|
|
firm = self.firm_points()
|
|
if not firm:
|
|
return False
|
|
try:
|
|
here = self.read.match(series=lambda r: r.at(instant),
|
|
no_data=lambda r: _absent(r.reason))
|
|
except Absence:
|
|
# An instant outside the series is an hour with no price (§11.4 for
|
|
# that hour), not a cheap one.
|
|
return False
|
|
if here.provisional:
|
|
return False
|
|
return here.eur_per_kwh < (sum(p.eur_per_kwh for p in firm) / len(firm))
|
|
|
|
def arbitrage(self, *, peak_guard_active: bool, min_spread_eur_kwh,
|
|
uncertainty) -> Arbitrage:
|
|
"""May OPT-04 trade, and on what numbers. See the module docstring.
|
|
|
|
⚠️ ALL THREE ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
|
|
each, and none of them has a default:
|
|
|
|
`peak_guard_active` §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.
|
|
`min_spread_eur_kwh` the caller's own round-trip cost threshold
|
|
(§10.1's `cycle_cost_threshold_eur`). A verdict
|
|
that says "allowed" without being told what
|
|
would make a cycle worth doing is a verdict
|
|
about arithmetic, not about money. Pass 0.0 to
|
|
mean "any positive spread", explicitly.
|
|
`uncertainty` the §11.4 doubts the CALLER can see and this
|
|
object cannot - today that is
|
|
`Uncertainty.NO_WEATHER`. Defaulting it to the
|
|
empty set would be this module asserting the
|
|
weather forecast is healthy, which is a fact it
|
|
has no way to observe. The product's own
|
|
real-time uncertainty (§9.2 row 2) is derived
|
|
here and unioned in, so AC3 cannot be forgotten
|
|
by a caller who passes an empty set.
|
|
"""
|
|
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 isinstance(min_spread_eur_kwh, bool) or not isinstance(min_spread_eur_kwh, (int, float)) or not math.isfinite(min_spread_eur_kwh) or min_spread_eur_kwh < 0:
|
|
raise TypeError(
|
|
f"min_spread_eur_kwh must be a finite, non-negative number of "
|
|
f"EUR/kWh, not {min_spread_eur_kwh!r}. A negative threshold is "
|
|
f"one every spread clears.")
|
|
caller_sources = frozenset(uncertainty)
|
|
if any(not isinstance(u, Uncertainty) for u in caller_sources):
|
|
raise TypeError(
|
|
f"uncertainty must be Uncertainty members, not "
|
|
f"{sorted(map(repr, caller_sources))} - a string widens by "
|
|
f"nothing while reporting that it widened.")
|
|
|
|
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")
|
|
|
|
# ⚠️ REFUSAL 3, REWRITTEN BY FCST-03 FROM "ANY POINT IS PROVISIONAL" TO
|
|
# "THE FIRM POINTS ARE NOT ENOUGH TO TRADE ON", and the difference is
|
|
# the whole ticket. §8.3 fills the hours a day-ahead feed has not
|
|
# published yet with yesterday's profile and flags them - so from
|
|
# midnight until the 14-16 h CET publication, a normal healthy site has
|
|
# a series that is PART firm and PART carried. Refusing on "any point
|
|
# provisional" would switch arbitrage off every morning of every day on
|
|
# every dynamic site, which is not what §8.3 says and is a behaviour
|
|
# nothing would ever report. What §8.3 forbids is acting ON a
|
|
# provisional price - so the provisional hours are DROPPED from the
|
|
# trade set and everything below is computed over the firm ones only.
|
|
# A fully provisional series (the §11.4 fallback, or a pre-publication
|
|
# day with no fetch at all) has no firm pair left and is refused, which
|
|
# is the previous behaviour arrived at by a truer route.
|
|
pts = self.read.points
|
|
firm = tuple(p for p in pts if not p.provisional)
|
|
if len(firm) < 2:
|
|
return Arbitrage.refuse(
|
|
f"§8.3: only {len(firm)} of {len(pts)} hours carry a published "
|
|
f"price - the rest are provisional (origin {self.origin!r}), "
|
|
f"and no irreversible action may be taken on a provisional "
|
|
f"price. A buy hour and a sell hour must BOTH be firm before a "
|
|
f"cycle can be committed. Load shifting, self-consumption and "
|
|
f"peak shaving continue")
|
|
|
|
spread = round(max(p.eur_per_kwh for p in firm)
|
|
- min(p.eur_per_kwh for p in firm), 6)
|
|
if spread == 0.0:
|
|
return Arbitrage.refuse(
|
|
f"the tariff is flat - every one of the {len(firm)} firm hours "
|
|
f"costs {firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
|
|
f"selling high is arithmetically impossible. §9.2: a fully "
|
|
f"fixed product is self-consumption only")
|
|
|
|
# REFUSAL 5, NEW IN FCST-03 (AC3). One widening path, driven by the set
|
|
# of active uncertainty sources: the product's own (§9.2 row 2, derived
|
|
# from the DECLARED product and never from a plugin id) unioned with
|
|
# whatever the caller can see (§11.4's no-weather row).
|
|
sources = uncertainty_of(self.read.product) | caller_sources
|
|
need = required_spread(min_spread_eur_kwh, sources)
|
|
if spread < need:
|
|
return Arbitrage.refuse(
|
|
f"the firm spread is {spread:.4f} EUR/kWh and this cycle needs "
|
|
f"{need:.4f} - {min_spread_eur_kwh:.4f} widened by "
|
|
f"{widen_factor(sources):.2f}x because "
|
|
f"{', '.join(sorted(u.value for u in sources)) or 'nothing'} "
|
|
f"is uncertain (§8.3, §9.2, §11.4). A cycle whose spread does "
|
|
f"not cover its own round-trip cost costs money and pack life")
|
|
|
|
return Arbitrage.allow(
|
|
spread=spread,
|
|
cheapest=min(firm, key=lambda p: p.eur_per_kwh),
|
|
dearest=max(firm, key=lambda p: p.eur_per_kwh),
|
|
regime=self.read.regime,
|
|
product=self.read.product,
|
|
required=need,
|
|
sources=sources)
|
|
|
|
@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, *, history) -> 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, §8.3-FILLED (§9.2 rows 1-2)
|
|
provider no_data + profile -> §11.4 FALLBACK, provisional, no arbitrage
|
|
provider no_data + none -> no prices at all
|
|
|
|
⚠️ `history` IS REQUIRED, KEYWORD-ONLY, AND HAS NO DEFAULT (FCST-04). It is
|
|
the §8.3 carry-forward store - `price_history.PriceHistory`, or the explicit
|
|
`price_history.NoHistory()` for a caller that keeps none. A default of
|
|
`None` meaning "skip the fill" would give a caller who forgot the argument
|
|
the TRUNCATED HORIZON this ticket exists to remove, silently, while the code
|
|
read as though the fill were happening. There is nothing to forget now.
|
|
|
|
⚠️ AND THE FILL RUNS ONLY ON A `series` READ. A feed that published nothing
|
|
still falls to `_degrade()` and §11.4's own row - the static profile the
|
|
operator configured FOR that case - rather than to yesterday's feed. The two
|
|
are indistinguishable to the optimiser (both are 100 % provisional, so
|
|
`arbitrage()` finds fewer than two firm hours and refuses, and `cheap_now()`
|
|
is False), so preferring yesterday would change a documented degradation for
|
|
a difference only the status page can see. See price_history.py's closing
|
|
note and DECISIONS/FCST-04-the-day-that-is-carried.md.
|
|
|
|
⚠️ THE FILL IS PER WHOLE HOUR AND ITS COVERAGE IS CONDITIONAL. Every point
|
|
the provider published inside the window passes through BY IDENTITY - four
|
|
of them in one hour on a quarter-hourly product like `eneco_eddy` - and a
|
|
carried point is added only for a whole hour the feed did not print at all
|
|
AND yesterday did. An hour neither source can price is left out, which is
|
|
§11.4 for that hour: `plan.price_at()` raises rather than answering, and an
|
|
hour with no price is not a free one.
|
|
|
|
⚠️ THE FILLED SERIES IS WINDOWED TO [since, until), AND THAT IS A CHANGE.
|
|
`load_prices()` returns whatever the feed sent, past hours included, and
|
|
`TariffPlan.arbitrage()` took its spread over all of it - so an hour that
|
|
had already gone could be the cheapest hour of a cycle nothing can now buy
|
|
in. `fill_from_yesterday()` walks the requested horizon and emits exactly
|
|
that, so the spread is over hours that are still ahead. Both directions of
|
|
the difference are real: the window gains the unpublished hours it was
|
|
missing and loses the elapsed ones it should never have had.
|
|
"""
|
|
stamp = now or datetime.now(timezone.utc)
|
|
if not provider_configured(opts):
|
|
return static_plan(opts, since, until, provisional=False, now=stamp)
|
|
|
|
read = await load_prices(opts, since, until, transport)
|
|
return read.match(
|
|
series=lambda r: _provider_plan(opts, r, since, until, stamp, history),
|
|
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
|
|
|
|
|
def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
|
|
"""§8.3: record what the feed published, carry yesterday into the gaps.
|
|
|
|
The fill can itself return `no_data` - a feed whose points all fall outside
|
|
the horizon, with nothing in the history to fill it from - and that is the
|
|
§11.4 row arrived at one step later, so it degrades exactly as a failed
|
|
fetch does rather than being special-cased here.
|
|
"""
|
|
filled = history.carry(read, since=since, until=until, stamp=stamp)
|
|
return filled.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")
|