Merge release/1.0 (FCST-03) into SAFETY-24 - CHANGELOG only
checks / unit (pull_request) Successful in 3m28s

FCST-03 landed while this was in review, so both branches had prepended an
Unreleased stanza. Only CHANGELOG.md conflicted, exactly as predicted;
app/, config.yaml, validate.py and DOCS.md all auto-merged, and this card
touches neither price_forecast.py nor static_tariff.py.

Both stanzas kept verbatim, SAFETY-24's above FCST-03's - no text from
either author edited. Full suite green on the merged tree: 14 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
glenn schrooyen
2026-08-31 00:43:58 +02:00
co-authored by Claude Opus 5
6 changed files with 1700 additions and 48 deletions
+81
View File
@@ -282,6 +282,87 @@ def volatility_regime(product: PriceProduct) -> str:
return "wide" if product in _WIDE else "normal"
# --------------------------------------------------------------------------- #
# FCST-03: ONE widening path, driven by a SET of active uncertainty sources.
#
# Two spec rows widen the same number by two different triggers:
#
# §9.2 / §8.3 real-time / imbalance product -> "arbitrage thresholds widen"
# §11.4 no weather data -> "widen arbitrage thresholds"
#
# They are one mechanism reached twice, not two mechanisms, so there is one
# function and a set of reasons. ⚠️ THE COMBINATION RULE IS `max`, NOT A
# PRODUCT: multiplying 2.5 by 1.5 would make two independent doubts compound
# into a threshold nothing on a Belgian tariff can ever clear, and an arbitrage
# strategy that can never fire is indistinguishable from one that is switched
# off. The widest single reason wins.
#
# ⚠️ HONEST ABOUT WHERE THE NUMBERS COME FROM. specs.md says thresholds widen
# and does not say by how much; there is no capture to derive a factor from,
# because no arbitrage strategy has ever run here. These are POLICY DEFAULTS,
# chosen so that (a) a real-time product needs a materially larger spread than
# a settled day-ahead one before a cycle is worth its wear, and (b) the two
# factors DIFFER, so `max`, `sum` and `product` are three distinguishable
# answers rather than the same number - a check that grades a combination rule
# where all the candidates agree grades nothing. When OPT-04 lands
# `cycle_cost_threshold_eur` (§10.1/§14) these become the natural second option
# beside it, in PRICE_OPTION_DEFAULTS and nowhere else (CFG-03).
class Uncertainty(Enum):
"""Why an arbitrage threshold is currently wider than the tariff alone says."""
REALTIME_PRODUCT = "realtime_product" # §9.2 row 2, §8.3
NO_WEATHER = "no_weather" # §11.4 "no weather data"
WIDEN_FACTOR = {
Uncertainty.REALTIME_PRODUCT: 2.5,
Uncertainty.NO_WEATHER: 1.5,
}
def uncertainty_of(product: PriceProduct) -> frozenset:
"""The uncertainty a PRODUCT contributes on its own (§9.2 row 2).
Derived from the declared product, never from a plugin id - the same rule
`volatility_regime()` exists for. A sixth supplier declaring
`PriceProduct.REALTIME` is widened without anyone editing this file.
"""
if not isinstance(product, PriceProduct):
raise TypeError(f"uncertainty_of() takes a PriceProduct, not "
f"{product!r} - a plugin name is what §9.3 forbids.")
return (frozenset({Uncertainty.REALTIME_PRODUCT})
if product in _WIDE else frozenset())
def widen_factor(sources) -> float:
"""The multiplier the widest active source asks for. 1.0 when there are none.
⚠️ A STRING IS REFUSED. `widen_factor({"realtime"})` would otherwise be a
silent 1.0 - a widening that reports itself as applied and is not, which is
the shape of defect this repo keeps finding.
"""
srcs = frozenset(sources)
bad = [s for s in srcs if not isinstance(s, Uncertainty)]
if bad:
raise TypeError(f"widen_factor() takes Uncertainty members, not {bad!r}. "
f"A string here would widen by 1.0 and say it widened.")
return max((WIDEN_FACTOR[s] for s in srcs), default=1.0)
def required_spread(base_eur_kwh: float, sources) -> float:
"""The spread an arbitrage cycle must clear, given what is uncertain (§8.3).
`base_eur_kwh` is the caller's own round-trip cost threshold - OPT-04 owns
that number (§10.1's `cycle_cost_threshold_eur`), this owns only how much
wider it gets and why.
"""
if isinstance(base_eur_kwh, bool) or not isinstance(base_eur_kwh, (int, float)) or not math.isfinite(base_eur_kwh) or base_eur_kwh < 0:
raise ValueError(
f"required_spread() takes a non-negative, finite base threshold in "
f"EUR/kWh, not {base_eur_kwh!r}. A negative or NaN base makes every "
f"spread clear it, which is arbitrage with the brake removed.")
return round(float(base_eur_kwh) * widen_factor(sources), 6)
@dataclass(frozen=True)
class PricePoint:
"""One canonical point. THREE FIELDS. §9.3 lists three; this has three."""
+465
View File
@@ -0,0 +1,465 @@
"""FCST-03 - the hours the feed has not published yet, and what may not rest on them.
WHERE THIS LIVES. specs.md §2.2 puts price ingest under `ems/ingest/prices/`.
That 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/`. The
BEHAVIOUR §8.3 describes is unchanged and still authoritative - only the path
moved. Read `ems/...` as `app/...`.
§8.3, in full, because every line below is one clause of it:
"Day-ahead prices arrive once daily. Until they do, unknown hours use
yesterday's profile and are flagged `provisional: true` - and NO
IRREVERSIBLE ACTION MAY BE TAKEN ON A PROVISIONAL PRICE. Real-time /
imbalance products are treated as high-volatility: arbitrage thresholds
widen."
Three separate things, and this module owns the first two. The third - the
widening arithmetic - lives in price.py beside the product enum it is derived
from (`Uncertainty`, `widen_factor`, `required_spread`), and is spent by
`static_tariff.TariffPlan.arbitrage()`, because that is where the verdict this
repo already refuses to hand out casually is produced. FCST-03 added a refusal
to that existing ladder rather than a second mechanism beside it.
THE DEFECT THIS MODULE IS SHAPED BY: A COPY THAT KEEPS THE WRONG FLAG
=====================================================================
A point filled from yesterday and a point published this morning are the SAME
SHAPE. Three floats and a bool; only the bool tells them apart. And the natural
way to write a carry-forward fill is:
PricePoint(start=t, eur_per_kwh=y.eur_per_kwh, provisional=y.provisional)
^^^^^^^^^^^^^
which copies yesterday's `False` onto today's guess and produces exactly the
thing the ticket exists to prevent: STALE DATA PRESENTED AS CURRENT. It reads
fine, it type-checks, it round-trips through §9.3's JSON, and every price on
the status page looks like a price. This is the meter-side SAFETY-18 defect one
layer up: the reading arrives on time, looks fresh, and is wrong - and a
freshness check that only asks "did something arrive" cannot see it.
So the flag is not passed through anywhere in this file:
* A CARRIED POINT IS BUILT BY `_carried()`, WHICH HAS NO PARAMETER FOR THE
FLAG. `provisional=True` is a literal in one place. There is no argument a
caller can pass, no keyword to forget, and no variable that could hold
yesterday's value at the moment the point is constructed.
* A PUBLISHED POINT IS NEVER COPIED AT ALL. It is passed through BY IDENTITY -
the object the provider produced is the object in the output series - so
there is no copy that could lose or gain a flag. `filled.points[i] is
fetched.points[j]` is a checkable property, and test_price_forecast.py
checks it.
* `_audit()` RE-DERIVES the expected flag for every output point from the set
of hours the feed actually published, and RAISES if any point disagrees.
It is a post-condition, not a filter: it never fixes a flag, because a
module that quietly corrects its own provenance is one whose provenance
cannot be trusted.
* and test_price_forecast.py walks THIS FILE'S AST and fails if any
`PricePoint(...)` in it passes anything but a literal for `provisional`, so
the pass-through idiom cannot come back in a later edit and pass review by
looking ordinary.
⚠️ "PUBLISHED" DOES NOT MEAN "FIRM". `eneco_eddy` is an imbalance product whose
current-quarter print is genuinely provisional and says so per point (§9.2,
TARIFF-02). Such a point is published AND provisional, and the audit's rule for
a published point is "the flag is whatever the feed said", never "False". A
provenance check that forced published points to False would be a bug that
looks like a guarantee.
THE FILL, AND WHY IT KEYS ON THE WALL CLOCK
===========================================
For every whole hour in the requested horizon that the feed has not published,
take yesterday's value for THE SAME BRUSSELS WALL-CLOCK HOUR - not `t - 86400
s`. On the two days a year that matter those are different instants, and the
difference is not academic: a 23-hour day has no 02:00 at all and a 25-hour day
has two, so a fill that subtracts a fixed 24 h shifts every carried hour by one
across a transition and misprices the whole day in a way nothing reports.
On the 25-hour day the two 02:00 hours - `02:00+02:00` and `02:00+01:00` - both
key to local hour 2 and are BOTH filled from yesterday's single 02:00 value.
That is deliberate: they are both guesses, they are both flagged, and nothing
irreversible may rest on either. If yesterday itself was a 25-hour day, the
first of its two 02:00 points is the one used, deterministically.
An hour with no published price AND no yesterday value is LEFT OUT of the
series. It is not filled with a neighbour, a mean, or a zero. §11.4 applies to
that hour - `_Series.at()` raises `Absence` for it - and the caller that wanted
a tariff for the whole horizon falls back to the static profile (TARIFF-03),
which is the row the spec actually writes for "no price data". If NOTHING can
be produced, the result is `PriceRead.no_data(reason)` and not an empty series,
because an empty series is not constructible on purpose (price.py trap 3).
AC2: THE IRREVERSIBLE-ACTION GATE
=================================
"The distinction should be an explicit property of the action, not a judgement
call at each call site" - so an irreversible action is a value, `Irreversible`,
carrying the number of hours it COMMITS. `price_permits()` looks at exactly
those hours and answers with a `Gate` variant that carries the evidence on the
permitting arm and a reason on the blocking one, the `_Outcome` shape price.py
borrowed from `sim/riglock.py`.
The shipped example is the maintenance drain (§12): starting one commits hours
of forced discharge that cannot be undone if the price turns out to have been
yesterday's. Its commitment window is DERIVED from `maintenance.DRAIN_TIMEOUT_MIN`
rather than typed here, so the gate and the phase machine cannot drift apart.
⚠️ WHAT DOES NOT GO THROUGH THIS GATE, AND WHY THAT MATTERS MORE THAN WHAT
DOES. The maintenance cycle is a CALENDAR obligation (§12.2: a cycle that
silently never fires is indistinguishable from one that is not due yet), and a
site with `price_provider: off` - the shipped default - has no price series at
all. Routing every maintenance start through a gate that blocks on `no_data`
would stop the monthly cycle on every default install, permanently and
silently: the exact failure §12 opens by naming. So `begin_phase()` is the
entry point for a start that is being JUSTIFIED BY A PRICE, main.py's existing
calendar path is untouched by this ticket, and OPT-04/MAINT-05 own the decision
of which one a site runs. Nothing here disables anything that runs today.
Reversible actions - ordinary self-consumption modulation, the loop that
actually holds the grid at zero - are not gated at all and cannot be: §6 says
absent machinery must not disable the loop.
"""
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from .maintenance import CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN
from .price import PricePoint, PriceRead, _Outcome, brussels
_LOG = logging.getLogger("goodwe.price.forecast")
class Provenance(Enum):
"""Where a point in a filled series came from. INTERNAL - §9.3's wire format
carries the boolean and nothing else, so this never leaves the module.
It exists so `_audit()` has a vocabulary to state its post-condition in, and
so the two cases have names in the log rather than being "the ones with the
flag" and "the others".
"""
PUBLISHED = "published" # the provider printed this hour. Flag: the
# feed's own - an imbalance print may be
# published AND provisional (§9.2).
CARRIED = "carried" # yesterday's same-wall-clock hour (§8.3).
# Flag: ALWAYS True, by construction.
# --------------------------------------------------------------------------- #
# The fill
# --------------------------------------------------------------------------- #
def _utc_hour(instant: datetime) -> datetime:
u = instant.astimezone(timezone.utc)
return u.replace(minute=0, second=0, microsecond=0)
def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
"""A point filled from yesterday's profile. §8.3: ALWAYS provisional.
⚠️ THERE IS NO `provisional` PARAMETER, AND THAT IS THE MECHANISM. This is
the only constructor of a carried point, the flag is a literal, and no
caller - including a future one editing in a hurry - has anything to pass,
default, or copy from the point being carried.
"""
return PricePoint(start=brussels(start_utc),
eur_per_kwh=round(float(eur_per_kwh), 6),
provisional=True)
def _audit(points, published) -> None:
"""Post-condition: every flag matches the point's TRUE provenance (AC1).
`published` maps a UTC hour to the point the provider produced for it.
⚠️ RAISES, NEVER REPAIRS. A module that silently corrects its own
provenance is one whose provenance means nothing; the only safe response to
"this point's flag disagrees with where it came from" is to refuse to emit
the series at all.
"""
seen = set()
for p in points:
u = _utc_hour(p.start)
if u in seen:
raise ValueError(f"the filled series repeats {u.isoformat()}; a "
f"duplicated hour is how a DST day gets mispriced")
seen.add(u)
src = published.get(u)
prov = Provenance.CARRIED if src is None else Provenance.PUBLISHED
if src is None:
if p.provisional is not True:
raise ValueError(
f"{u.isoformat()} is {prov.value} and is flagged "
f"provisional={p.provisional!r}. §8.3: an hour filled from "
f"yesterday's profile is provisional whether or not the "
f"number turns out to be right - the flag is a claim about "
f"PROVENANCE, not about accuracy.")
else:
if p is not src:
raise ValueError(
f"{u.isoformat()} is {prov.value} but the series carries a "
f"COPY of that point rather than the point itself. A copy "
f"is where the provisional flag gets lost; published points "
f"pass through by identity.")
def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
since: datetime, until: datetime,
stamp: datetime) -> PriceRead:
"""§8.3: fill the hours the feed has not published with yesterday's profile.
Every argument is required and keyword-only past the first. In particular
`yesterday` has NO DEFAULT: a fill with no yesterday to fill from is a
different situation from a fill that found nothing useful in one, and a
default `None` would make the two arrive looking identical.
Returns a `PriceRead`:
* a series, whose points are the provider's own objects for the hours it
published and `_carried()` points for the rest, ordered by instant;
* `no_data` when neither source can produce a single hour - never an
empty series, which is not constructible (price.py trap 3).
⚠️ IDEMPOTENT AND RE-RUNNABLE, WHICH IS THE §5.4 LATE-PUBLICATION CONTRACT.
Day-ahead publication at 14-16 h CET can be late or fail, so the fetcher
retries and this runs again against the fresher read. Published hours always
win over carried ones, so a re-run REPLACES a carried point with the real
print and the hour becomes firm - the flag and the number move together
because the whole series is rebuilt rather than patched in place.
"""
for name, value in (("since", since), ("until", until), ("stamp", stamp)):
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{name} must be tz-aware; a naive instant is "
f"ambiguous for one hour every October")
published = read.match(
series=lambda r: {_utc_hour(p.start): p for p in r.points},
no_data=lambda _r: {})
# Yesterday's profile, keyed by BRUSSELS WALL-CLOCK HOUR. First point wins
# when yesterday itself was a 25-hour day and printed local hour 2 twice.
by_local_hour = {}
yesterday.match(
series=lambda r: [by_local_hour.setdefault(brussels(p.start).hour, p)
for p in r.points],
no_data=lambda _r: [])
out = []
u = _utc_hour(since)
end = until.astimezone(timezone.utc)
while u < end:
if u in published:
out.append(published[u]) # BY IDENTITY. No copy.
else:
src = by_local_hour.get(brussels(u).hour)
if src is not None:
out.append(_carried(u, src.eur_per_kwh))
u += timedelta(hours=1)
if not out:
reason = read.match(
series=lambda r: (f"the {len(r.points)} published hours all fall "
f"outside {since.isoformat()}..{until.isoformat()}"),
no_data=lambda r: r.reason)
return PriceRead.no_data(
f"§8.3 carry-forward produced no hours: {reason}, and yesterday's "
f"profile has nothing to fill them with either. §11.4 applies: "
f"static profile, plan provisional, no arbitrage")
_audit(out, published)
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
if carried:
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's "
"profile and flagged provisional; %d published",
carried, len(out), len(out) - carried)
product = read.match(series=lambda r: r.product,
no_data=lambda _r: yesterday.product)
provider = read.match(series=lambda r: r.provider,
no_data=lambda r: yesterday.provider)
return PriceRead.series(out, product, provider, stamp)
# --------------------------------------------------------------------------- #
# AC2: irreversibility as a property of the ACTION, and the gate that reads it
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Irreversible:
"""An action that cannot be undone once started, and how far it commits.
`commits_h` is the horizon the action ties up - the hours whose prices must
therefore be firm before it may be justified by a price at all.
"""
name: str # the §10.2 reason code
commits_h: float
def __post_init__(self):
if not self.name or not isinstance(self.name, str):
raise ValueError("an irreversible action needs a reason code (§10.2)")
if isinstance(self.commits_h, bool) or \
not isinstance(self.commits_h, (int, float)) or \
not self.commits_h > 0:
raise ValueError(
f"commits_h must be a positive number of hours, not "
f"{self.commits_h!r}. An action that commits nothing is a "
f"reversible one and does not belong here.")
# ⚠️ DERIVED FROM THE PHASE MACHINE'S OWN BOUNDS, NOT TYPED HERE. maintenance.py
# already states how long each phase may run before it is a fault; a second copy
# of those numbers here would be a place for them to drift apart, and the drift
# would show up as a gate that checked the wrong hours.
MAINTENANCE_DRAIN = Irreversible("MAINTENANCE_DRAIN", DRAIN_TIMEOUT_MIN / 60.0)
MAINTENANCE_CHARGE = Irreversible("MAINTENANCE_CHARGE", CHARGE_TIMEOUT_MIN / 60.0)
class Gate(_Outcome):
"""May an irreversible action proceed on the price evidence available?
* `permitted` - carries `.action` and `.hours`, the firm points that were
actually checked. The evidence exists only on this arm.
* `blocked` - carries `.reason`. Nothing else; `.hours` raises `Absence`.
No `__bool__`, both `match()` arms required and keyword-only, for the reason
price.py's `_Outcome` gives: `if gate:` must not look like a guard while
being none.
"""
def match(self, *, permitted, blocked):
return {"permitted": permitted, "blocked": blocked}[self.kind](self)
@staticmethod
def permit(action, hours):
return _Permitted(action, hours)
@staticmethod
def block(reason):
if not reason or not isinstance(reason, str):
raise ValueError(
"Gate.block() takes a reason a human can act on. §6 requires "
"every decision to explain itself, and 'the maintenance cycle "
"did not start' with no reason is indistinguishable from a bug.")
return _Blocked(reason)
class _Permitted(Gate):
kind = "permitted"
def __init__(self, action, hours):
if not isinstance(action, Irreversible):
raise TypeError(f"action must be an Irreversible, not {action!r}")
pts = tuple(hours)
if not pts:
raise ValueError(
"a permission granted over ZERO checked hours is a permission "
"granted on no evidence - the shape this whole file exists to "
"refuse. Block instead.")
if any(p.provisional for p in pts):
raise ValueError(
"a permitted gate cannot carry a provisional hour: §8.3 is the "
"one rule this type has.")
self.action = action
self.hours = pts
self.words = (f"{action.name} permitted on {len(pts)} firm hours "
f"({pts[0].start.isoformat()} ..)")
class _Blocked(Gate):
kind = "blocked"
def __init__(self, reason):
self.reason = reason
self.words = f"blocked - {reason}"
def committed_hours(now: datetime, action: Irreversible) -> tuple:
"""The whole hours `action` started at `now` would run through.
Split out because the gate and its caller must agree on what "the hours it
commits" means, and because a window that quietly excluded the last hour
would be a gate that permits an action on an hour it never looked at.
"""
if not isinstance(action, Irreversible):
raise TypeError(f"action must be an Irreversible, not {action!r}")
if now.tzinfo is None or now.utcoffset() is None:
raise ValueError("now must be tz-aware")
out = []
u = _utc_hour(now)
end = now.astimezone(timezone.utc) + timedelta(hours=action.commits_h)
while u < end:
out.append(u)
u += timedelta(hours=1)
return tuple(out)
def price_permits(read: PriceRead, now: datetime,
action: Irreversible) -> Gate:
"""§8.3's rule, as a value: may this irreversible action rest on this price?
Blocks when the read observed nothing (§11.4 - an action justified by a
price nobody has is an action justified by nothing), when any hour it
commits has no price at all, and when any of those hours is provisional.
Names the offending hour, so an operator reading the log knows which one.
"""
hours = committed_hours(now, action)
return read.match(
series=lambda r: _walk(r, hours, action),
no_data=lambda r: Gate.block(
f"{action.name} is gated on a price and there is no price data: "
f"{r.reason}. §11.4 - and a missing price is not a cheap one"))
def _walk(series, hours, action) -> Gate:
pts = []
for u in hours:
if not series.covers(u):
return Gate.block(
f"{action.name} commits {action.commits_h:g} h from "
f"{hours[0].isoformat()} and no price covers "
f"{u.isoformat()}. An hour outside the series is an hour with "
f"no price (§11.4 for that hour), not a free one")
p = series.at(u)
if p.provisional:
return Gate.block(
f"{action.name} commits {action.commits_h:g} h from "
f"{hours[0].isoformat()} and {u.isoformat()} is PROVISIONAL - "
f"carried from yesterday's profile or standing in for a dead "
f"feed. §8.3: no irreversible action may be taken on a "
f"provisional price. It becomes available when the day-ahead "
f"print lands (~14-16 h CET, §5.4)")
pts.append(p)
return Gate.permit(action, pts)
def begin_phase(maint, now: datetime, read: PriceRead,
action: Irreversible) -> Gate:
"""Start a maintenance phase BECAUSE OF A PRICE, or refuse and say why.
The AC2 example made real: `maint.force_start(now)` - the call that moves
the phase machine out of `idle` and commits the pack to hours of forced
discharge - happens on the permitting arm and cannot happen on the other
one, because the blocking arm has no code path to it.
⚠️ READ THE MODULE DOCSTRING'S LAST WARNING BEFORE WIRING THIS IN. This is
the price-justified entry point, not the calendar one; a site with no price
provider must keep starting its monthly cycle the way it does today, or
§12's opening sentence comes true - a cycle that silently never fires.
"""
gate = price_permits(read, now, action)
return gate.match(permitted=lambda g: _go(maint, now, g),
blocked=lambda g: _no(g))
def _go(maint, now, gate) -> Gate:
maint.force_start(now)
_LOG.info("%s: %s", gate.action.name, gate.words)
return gate
def _no(gate) -> Gate:
_LOG.warning("irreversible action refused - %s", gate.reason)
return gate
+192 -38
View File
@@ -77,25 +77,49 @@ 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
`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.
⚠️ FOUR REFUSALS, IN THIS ORDER, EACH DERIVED FROM SOMETHING MEASURED:
⚠️ 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. 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
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
@@ -182,7 +206,8 @@ from datetime import datetime, timedelta, timezone
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
_Outcome, brussels, load_prices, price_option,
Uncertainty, _Outcome, brussels, load_prices, price_option,
required_spread, uncertainty_of, widen_factor,
)
_LOG = logging.getLogger("goodwe.tariff.static")
@@ -619,8 +644,9 @@ class Arbitrage(_Outcome):
return {"allowed": allowed, "refused": refused}[self.kind](self)
@staticmethod
def allow(spread, cheapest, dearest, regime, product):
return _Allowed(spread, cheapest, dearest, regime, product)
def allow(spread, cheapest, dearest, regime, product, required, sources):
return _Allowed(spread, cheapest, dearest, regime, product, required,
sources)
@staticmethod
def refuse(reason):
@@ -632,17 +658,43 @@ class Arbitrage(_Outcome):
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):
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)")
f"{product.value} ({regime} thresholds, cleared "
f"{required:.4f} EUR/kWh)")
class _Refused(Arbitrage):
@@ -724,36 +776,100 @@ class TariffPlan:
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 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".
⚠️ 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".
"""
if not self.has_prices or self.provisional:
firm = self.firm_points()
if not firm:
return False
try:
return self.price_at(instant) < self.mean_eur_kwh()
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) -> Arbitrage:
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.
⚠️ `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.
⚠️ 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 "
@@ -765,26 +881,64 @@ class TariffPlan:
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:
# ⚠️ 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"§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()
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 "
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"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")
pts = self.read.points
# 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(pts, key=lambda p: p.eur_per_kwh),
dearest=max(pts, key=lambda p: p.eur_per_kwh),
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)
product=self.read.product,
required=need,
sources=sources)
@property
def words(self) -> str: