Merge release/1.0 (FCST-03) into SAFETY-24 - CHANGELOG only
checks / unit (pull_request) Successful in 3m28s
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:
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user