From dd40c56ab431c83668479e2adc429f54c28f5154 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 30 Aug 2026 23:54:07 +0200 Subject: [PATCH] FCST-03: yesterday's prices, flagged as yesterday's, and unable to move anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §8.3 fills the hours a day-ahead feed has not published yet with yesterday's profile and flags them provisional, and forbids any irreversible action on such a price. The whole risk of this ticket is one line - a carried point and a published point are three fields and a bool, so `provisional=y.provisional` copies yesterday's False onto today's guess and produces stale data presented as current. That is SAFETY-18's frozen-but-fresh meter one layer up. So the flag is never passed through. Carried points come from a constructor with no provisional parameter; published points pass through BY IDENTITY, so no copy exists that could lose one; an audit re-derives the expected flag for every emitted point and raises rather than repairing; and the check walks the module's own AST so the pass-through idiom cannot come back looking ordinary. A published point may itself be provisional (eneco_eddy's imbalance print is), so the rule for a published hour is "whatever the feed said", never False. The fill keys on the Brussels wall clock rather than t-24h, so the 23- and 25-hour days are right; a late publication rebuilds the series, moving the number and the flag together. AC2 and AC3 landed on TARIFF-03's existing verdict surface rather than beside it. Refusal 3 became "fewer than two FIRM hours" - refusing on "any point is provisional" would have switched arbitrage off every morning on every dynamic site - and refusal 5 is new: the spread must clear a threshold widened by the widest active uncertainty source (max, never a product). The permitting variant now refuses to be constructed around a provisional point at all, and the cheap-window flag became per-hour for the same reason. The irreversible action is a value carrying the hours it commits, derived from maintenance.py's own DRAIN_TIMEOUT_MIN; the gate blocks a maintenance drain whose window reaches into a carried hour, and the phase machine stays idle. It is the price-justified entry point only - a gate that blocked on no_data would stop the monthly cycle on every default install. Zero new config options; TEST-14's signature scan ported to cover both Gate's and Arbitrage's constructors, closing _Refused.reason's open hole. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC --- goodwe_controller/CHANGELOG.md | 63 ++ goodwe_controller/app/price.py | 81 +++ goodwe_controller/app/price_forecast.py | 465 ++++++++++++ goodwe_controller/app/static_tariff.py | 230 +++++- goodwe_controller/test_price_forecast.py | 857 +++++++++++++++++++++++ goodwe_controller/test_static_tariff.py | 20 +- 6 files changed, 1668 insertions(+), 48 deletions(-) create mode 100644 goodwe_controller/app/price_forecast.py create mode 100644 goodwe_controller/test_price_forecast.py diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index b51d995..7ee66ba 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -2,6 +2,69 @@ ## Unreleased +**Provisional prices, and what may not rest on one (FCST-03).** +Day-ahead prices arrive once a day, around 14–16 h CET. Until they do, §8.3 says +the unknown hours use yesterday's profile and are flagged `provisional: true` — +and **no irreversible action may be taken on a provisional price.** +`app/price_forecast.py` does the fill, and the whole module is shaped by one +defect: a carried-forward point and a published one are the same three fields +and a bool, so the natural line `PricePoint(..., provisional=y.provisional)` +copies yesterday's `False` onto today's guess and produces stale data presented +as current. That is the meter-side SAFETY-18 failure one layer up — the reading +arrives on time, looks fresh, and is wrong. + +**So the flag is never passed through.** Carried points are built by a +constructor that has no `provisional` parameter at all (the literal `True` lives +in one place, and there is nothing for a caller to forget or copy); published +points are passed through **by identity**, so there is no copy that could lose a +flag; and an audit re-derives the expected flag for every emitted point from the +set of hours the feed actually published and *raises* rather than repairing one. +A published point may itself be provisional — `eneco_eddy`'s current-quarter +imbalance print is — so the audit's rule for a published hour is "whatever the +feed said", never "False". + +**The fill keys on the Brussels wall clock, not on `t − 24 h`.** A 23-hour day +has no 02:00 and a 25-hour day has two, so a fixed 24-hour subtraction shifts +every carried hour by one across a transition and misprices a whole day with +nothing to show for it. Re-running the fill after a late publication replaces +the carried hours with the real print: the number and the flag move together +because the series is rebuilt, never patched. + +**Arbitrage now drops provisional hours instead of refusing the whole day.** +The refusal that read "the plan is provisional" would have switched arbitrage off +every morning on every dynamic site once carry-forward filling landed — a +part-firm series is the *normal* state before the print. What §8.3 forbids is +acting **on** a provisional price, so the provisional hours are dropped and the +spread, the cheapest hour and the dearest hour are computed over the published +ones only; fewer than two firm hours is still a refusal, which is where the +§11.4 fallback (wholly provisional) lands. The permitting variant now refuses to +be *constructed* around a provisional point at all. The cheap-window flag became +per-hour for the same reason, so a site's maintenance charge floor does not go +dark every morning. + +**Real-time / imbalance products get measurably wider thresholds.** One widening +path, driven by a set of active uncertainty sources: the product's own (§9.2 row +2, derived from the declared `PriceProduct` and never from a plugin id) and +§11.4's "no weather data" row, which the spec widens by a different trigger and +which is the same mechanism. The combination rule is `max`, not a product — two +independent doubts must not compound into a threshold nothing can clear. The +caller supplies the base round-trip cost threshold, so OPT-04 still owns what +the number *is*; this owns only how much wider it gets and why, and the allowed +verdict reports the threshold it cleared. + +**An irreversible action is a value, not a judgement call at each call site.** +`Irreversible` carries the hours an action commits — the maintenance drain's +window is derived from `maintenance.DRAIN_TIMEOUT_MIN` rather than typed twice — +and the gate looks at exactly those hours, blocking on a provisional one, on an +hour the series does not cover, and on no price data at all: three different +reasons, none of them an answer. ⚠️ It is the entry point for a start being +*justified by a price*; the calendar path main.py runs today is untouched, +because a gate that blocked on `no_data` would stop the monthly cycle on every +default install — §12's opening sentence. + +**Zero new options.** No `config.yaml` key, no new defaults dict, no `o.get(k, +X)` in main.py: the module reads no options at all, so CFG-03 did not widen. + **Static day/night and fully-fixed tariffs, first-class (TARIFF-03).** §9.2 puts a large share of the Belgian installed base on a static profile, and an EMS that only works under dynamic pricing is an EMS for a minority. A new diff --git a/goodwe_controller/app/price.py b/goodwe_controller/app/price.py index 5e1a382..437dcef 100644 --- a/goodwe_controller/app/price.py +++ b/goodwe_controller/app/price.py @@ -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.""" diff --git a/goodwe_controller/app/price_forecast.py b/goodwe_controller/app/price_forecast.py new file mode 100644 index 0000000..fdb7e7a --- /dev/null +++ b/goodwe_controller/app/price_forecast.py @@ -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 diff --git a/goodwe_controller/app/static_tariff.py b/goodwe_controller/app/static_tariff.py index 9d1e01d..102da58 100644 --- a/goodwe_controller/app/static_tariff.py +++ b/goodwe_controller/app/static_tariff.py @@ -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: diff --git a/goodwe_controller/test_price_forecast.py b/goodwe_controller/test_price_forecast.py new file mode 100644 index 0000000..b892121 --- /dev/null +++ b/goodwe_controller/test_price_forecast.py @@ -0,0 +1,857 @@ +"""Runnable check for FCST-03 - provisional prices. `python3 test_price_forecast.py` + +WHAT THIS FILE IS DEFENDING, in the order the ticket's acceptance criteria name +it: + + AC1 Every price point carries a `provisional` flag MATCHING ITS TRUE + PROVENANCE - a point filled from yesterday's profile is provisional even + when the number happens to be right, a point the feed published keeps + whatever flag the feed gave it, and an unmarked carry-forward is not + constructible rather than merely discouraged. + AC2 An irreversible action gated on a provisional price is REJECTED, with the + spec's own example - a maintenance-phase transition - actually refused: + the phase machine is still in `idle` afterwards. + AC3 Real-time / imbalance prices get MEASURABLY WIDER thresholds. Measured, + by finding the smallest spread each regime will accept and comparing the + two numbers - not by reading a constant back out of the module that + defines it. + +THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will: + + * EVERY VERDICT IS DERIVED. The carried/published split is re-derived here + from the set of hours the fixture published, by different arithmetic than + app/price_forecast.py uses; the DST day lengths come from the offset + difference rather than from a 23 or a 25 typed into a check; the widening + factor is MEASURED by bisecting the accept/refuse boundary and then + compared with the declared one. + + * NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE, and + this ticket is full of that trap: + - YESTERDAY AND TODAY MUST PRICE EVERY HOUR DIFFERENTLY, or a fill that + carried the wrong day forward is invisible. Asserted first, as a check. + - THE PROVISIONAL HOURS MUST CONTAIN THE CHEAPEST HOUR, or "arbitrage + ignores provisional hours" grades nothing: the firm-only spread and the + whole-series spread would be the same number. Asserted before it is + used, and it is the realistic shape of the danger - yesterday's cheap + hour luring a charge that today's prices do not justify. + - THE TWO WIDENING FACTORS MUST DIFFER, or `max`, `sum` and `product` are + indistinguishable combination rules. Asserted, then all three are + computed and only one matches what the code does. + - THE LOCAL-HOUR FILL AND A NAIVE `t - 24 h` FILL MUST DISAGREE on the + transition days, or the DST check passes for both implementations. + Asserted, then the correct one is graded. + - PRICES ARE WRITTEN TO SIX DECIMALS with distinct 3-, 4- and 6-dp + roundings, so an implementation that rounds differently is visible. + (TARIFF-03's fixtures were 3-dp, where those three are one number.) + + * A FAILED READ IS NOT AN OBSERVATION. A missing price is not a zero price + and a stale one is not a fresh one: the gate blocks on `no_data`, blocks on + an hour the series does not cover, and blocks on a provisional hour - three + different reasons, none of them an answer. +""" + +import ast +import inspect +import os +import sys +from datetime import datetime, timedelta, timezone + +from app import price as _price +from app import price_forecast as _fc +from app import static_tariff as _st +from app.maintenance import ( + CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN, DRAIN, IDLE, MaintConfig, Maintenance, +) +from app.price import ( + Absence, PricePoint, PriceProduct, PriceRead, PROVIDERS, Uncertainty, + WIDEN_FACTOR, brussels, brussels_offset, plan_is_provisional, + required_spread, uncertainty_of, widen_factor, +) +from app.price_forecast import ( + MAINTENANCE_CHARGE, MAINTENANCE_DRAIN, Gate, Irreversible, committed_hours, + fill_from_yesterday, price_permits, begin_phase, +) +from app.static_tariff import Arbitrage, TariffPlan + +UTC = timezone.utc +HERE = os.path.dirname(os.path.abspath(__file__)) +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +def raises(fn, exc): + """True only if fn() raises EXACTLY `exc`. A different exception is False - + 'it was refused' and 'it broke on the way to being refused' must not grade + the same.""" + try: + fn() + except exc: + return True + except Exception: + return False + return False + + +class _Raised: + def __init__(self, e): + self.e = e + + def __eq__(self, other): + return False + + def __hash__(self): + return id(self) + + def __bool__(self): + return False + + def __repr__(self): + return f"" + + +def derived(fn): + """A value a check grades. An exception becomes `_Raised`, which equals + nothing and is falsy - NOT a default.""" + try: + return fn() + except Exception as e: # noqa: BLE001 + return _Raised(e) + + +def near(a, b, tol=1e-9): + try: + return abs(float(a) - float(b)) <= tol + except (TypeError, ValueError): + return False + + +# --------------------------------------------------------------------------- # +# Fixtures. +# +# ⚠️ SIX-DECIMAL PRICES, DELIBERATELY. TARIFF-03's fixtures were 0.3410 / +# 0.2570, where rounding to 3, 4 and 6 decimals gives the same number - so an +# implementation that rounded differently from the one being graded survived +# every check. These are chosen so those three roundings differ. +# --------------------------------------------------------------------------- # + +def y_price(h): + """Yesterday's all-in EUR/kWh for local hour h.""" + return round(0.123437 + 0.010700 * h, 6) + + +def t_price(h): + """Today's PUBLISHED all-in EUR/kWh for local hour h. Different from + yesterday's at every hour, on purpose - see the degeneracy note above.""" + return round(0.206562 + 0.009300 * h, 6) + + +def local_midnight(y, m, d) -> datetime: + """The UTC instant of Brussels local midnight on that date, derived from + `brussels_offset` rather than from a table of dates.""" + naive = datetime(y, m, d, 0, 0) + for hours in (1, 2): + guess = naive.replace(tzinfo=UTC) - timedelta(hours=hours) + if brussels_offset(guess).utcoffset(None) == timedelta(hours=hours): + return guess + raise AssertionError(f"no consistent Brussels offset for {naive}") + + +def hours_between(a, b): + out, u = [], a + while u < b: + out.append(u) + u += timedelta(hours=1) + return out + + +def series_of(instants, price_of, product, provider, *, provisional): + return PriceRead.series( + [PricePoint(start=brussels(u), eur_per_kwh=price_of(u), + provisional=provisional) for u in instants], + product, provider, instants[0]) + + +# A plain winter day: local 2026-01-15, 24 hours, no transition in sight. +D0 = local_midnight(2026, 1, 15) +D1 = local_midnight(2026, 1, 16) +YEST0 = local_midnight(2026, 1, 14) +DAY = hours_between(D0, D1) +YDAY = hours_between(YEST0, D0) + +YESTERDAY = series_of(YDAY, lambda u: y_price(brussels(u).hour), + PriceProduct.DAY_AHEAD, "generic_dayahead", + provisional=False) +# The feed has published the first 12 hours of today and nothing after - the +# ordinary state of a dynamic site before the 14-16 h CET print (§5.4). +PUBLISHED_N = 12 +TODAY_PART = series_of(DAY[:PUBLISHED_N], lambda u: t_price(brussels(u).hour), + PriceProduct.DAY_AHEAD, "generic_dayahead", + provisional=False) + + +print() +print("0. the fixtures are not degenerate (checked before anything is graded)") + +check("yesterday and today price EVERY shared hour differently, so a fill that " + "carried the wrong day forward would be visible in the value and not " + "only in the flag", + all(y_price(h) != t_price(h) for h in range(24))) +_roundings = derived(lambda: {round(y_price(3), n) for n in (3, 4, 6)}) +check(f"...and a fixture price rounds to three DIFFERENT numbers at 3, 4 and 6 " + f"decimals {sorted(_roundings)}, so a divergent rounding cannot hide", + len(_roundings) == 3) +check("...and both widening factors differ, so max / sum / product are three " + "distinguishable answers", + len(set(WIDEN_FACTOR.values())) == len(WIDEN_FACTOR) >= 2) + + +# --------------------------------------------------------------------------- # +print() +print("1. AC1 - the flag matches PROVENANCE, and an unmarked fill is not " + "constructible") + +FILLED = fill_from_yesterday(TODAY_PART, yesterday=YESTERDAY, + since=D0, until=D1, stamp=D0) + +_pub_starts = {u for u in DAY[:PUBLISHED_N]} +_expected_prov = {u: (u not in _pub_starts) for u in DAY} +_got_prov = derived(lambda: {p.start.astimezone(UTC): p.provisional + for p in FILLED.points}) +check(f"the filled series covers every hour of the day " + f"{len(FILLED.points) if FILLED.kind == 'series' else FILLED}", + FILLED.kind == "series" and len(FILLED.points) == len(DAY)) +check("...and EVERY point's flag equals what its provenance says it should be, " + "re-derived here from the set of hours the fixture published", + _got_prov == _expected_prov) +_carried = derived(lambda: [p for p in FILLED.points if p.provisional]) +check(f"...which is {len(DAY) - PUBLISHED_N} carried hours and {PUBLISHED_N} " + f"published ones, counted rather than asserted", + len(_carried) == len(DAY) - PUBLISHED_N) +check("...and the carried hours carry YESTERDAY's number for the same wall " + "clock hour, not today's and not a neighbour's", + all(near(p.eur_per_kwh, y_price(brussels(p.start).hour)) + for p in _carried)) +check("...while the published hours carry TODAY's, so the two halves are not " + "the same data wearing two flags", + all(near(p.eur_per_kwh, t_price(brussels(p.start).hour)) + for p in FILLED.points if not p.provisional)) + +# ⚠️ THE COPY IS THE DEFECT. A published point that survives as a COPY is one +# whose flag could have been rewritten on the way through; identity is the only +# property that rules that out for every field at once. +check("a published point is passed through BY IDENTITY - the object the " + "provider produced is the object in the filled series", + all(any(p is q for q in TODAY_PART.points) + for p in FILLED.points if not p.provisional)) +check("...and the canonical §9.3 dict carries the flag out to the wire for " + "both kinds", + FILLED.canonical()["prices"][0]["provisional"] is False + and FILLED.canonical()["prices"][-1]["provisional"] is True) +check("...and plan_is_provisional() sees the series as provisional overall, " + "because at least one hour is", + plan_is_provisional(FILLED) is True) + +# The audit is a post-condition that RAISES. Feed it the exact defect. +_stale_copy = PricePoint(start=FILLED.points[-1].start, + eur_per_kwh=FILLED.points[-1].eur_per_kwh, + provisional=False) # the copy-the-flag bug +check("the provenance audit REFUSES a carried hour flagged non-provisional - " + "the 'stale but plausible' point, which is SAFETY-18's frozen-but-fresh " + "meter one layer up", + raises(lambda: _fc._audit([_stale_copy], {}), ValueError)) +check("...and refuses a published hour that arrived as a COPY rather than as " + "the published object, which is where a flag gets lost", + raises(lambda: _fc._audit( + [PricePoint(start=TODAY_PART.points[0].start, + eur_per_kwh=TODAY_PART.points[0].eur_per_kwh, + provisional=TODAY_PART.points[0].provisional)], + {D0: TODAY_PART.points[0]}), ValueError)) +check("...and never repairs a flag: the audit's only outcomes are silence and " + "an exception, so a series it passed was already correct", + _fc._audit(list(FILLED.points), {u: TODAY_PART.points[i] + for i, u in enumerate(DAY[:PUBLISHED_N])}) + is None) + +# ⚠️ A PUBLISHED POINT MAY BE PROVISIONAL. eneco_eddy's current-quarter print +# is settled after the fact and says so (§9.2). "Published" is not "firm". +_eddy = series_of(DAY[:2], lambda u: t_price(brussels(u).hour), + PriceProduct.REALTIME, "eneco_eddy", provisional=True) +_eddy_filled = derived(lambda: fill_from_yesterday( + _eddy, yesterday=YESTERDAY, since=D0, until=D1, stamp=D0)) +check("a PUBLISHED point that the feed itself flagged provisional keeps that " + "flag - the audit checks provenance, and does not force published hours " + "to False", + _eddy_filled.kind == "series" + and all(p.provisional for p in _eddy_filled.points[:2]) + and _eddy_filled.points[0] is _eddy.points[0]) + +# The structural half: the constructor has nothing to pass, and the file is +# scanned so the pass-through idiom cannot come back looking ordinary. +check("_carried() has NO `provisional` parameter, so a caller has nothing to " + "forget, default, or copy from the point being carried", + "provisional" not in inspect.signature(_fc._carried).parameters) +_SRC = derived(lambda: open(os.path.join(HERE, "app", "price_forecast.py"), + encoding="utf-8").read()) + + +def _nonliteral_provisional(src): + """Every `PricePoint(...)` in the module whose `provisional` is not a + literal. AST, not a substring: the module docstring quotes the defect it + exists to prevent, and a text scan would match its own documentation.""" + out = [] + for node in ast.walk(ast.parse(src)): + if isinstance(node, ast.Call) and \ + getattr(node.func, "id", None) == "PricePoint": + kw = {k.arg: k.value for k in node.keywords} + v = kw.get("provisional") + if not isinstance(v, ast.Constant): + out.append(ast.dump(node)[:80]) + return out + + +_leaks = derived(lambda: _nonliteral_provisional(_SRC)) +check(f"no PricePoint built in app/price_forecast.py takes its `provisional` " + f"from an expression - it is a literal at every construction site, so " + f"`provisional=y.provisional` cannot reappear in a later edit {_leaks}", + _leaks == []) +# The scan must be able to fail, or it grades nothing. +check("...and that scan FAILS on the defect it names, so it is a check and not " + "a decoration", + _nonliteral_provisional( + "PricePoint(start=t, eur_per_kwh=v, provisional=y.provisional)") != []) + + +# --------------------------------------------------------------------------- # +print() +print("2. the fill keys on the WALL CLOCK, so the 23- and 25-hour days are right") + +for label, (y, m, d) in (("spring forward (23 h)", (2026, 3, 29)), + ("fall back (25 h)", (2026, 10, 25))): + a = local_midnight(y, m, d) + b = local_midnight(y, m, d + 1) + yb = local_midnight(y, m, d - 1) + yesterday = series_of(hours_between(yb, a), + lambda u: y_price(brussels(u).hour), + PriceProduct.DAY_AHEAD, "generic_dayahead", + provisional=False) + # Nothing published at all yet: the whole day is carried forward. + nothing = PriceRead.no_data("the day-ahead print has not landed yet") + got = derived(lambda: fill_from_yesterday(nothing, yesterday=yesterday, + since=a, until=b, stamp=a)) + # ⚠️ DERIVED FROM THE OFFSETS, not from a 23 or a 25 typed here. + off_a = brussels_offset(a).utcoffset(None).total_seconds() / 3600 + off_b = brussels_offset(b - timedelta(seconds=1)).utcoffset(None) \ + .total_seconds() / 3600 + expect = int(24 - (off_b - off_a)) + check(f"{label}: the day is {expect} hours long, derived from the offset " + f"change {off_a:+.0f} -> {off_b:+.0f}, and the fill emits exactly " + f"that many", + got.kind == "series" and len(got.points) == expect) + check(f"{label}: every hour of it is flagged provisional - nothing was " + f"published", + got.kind == "series" and all(p.provisional for p in got.points)) + # The discriminating part: a naive `t - 24 h` fill would give a DIFFERENT + # answer here, so this check separates the two implementations. + y_by_utc = {u: y_price(brussels(u).hour) for u in hours_between(yb, a)} + naive = [y_by_utc.get(u - timedelta(hours=24)) for u in hours_between(a, b)] + wall = [y_price(brussels(u).hour) for u in hours_between(a, b)] + check(f"{label}: the wall-clock fill and a naive t-24h fill DISAGREE, so " + f"the check below can tell them apart", + naive != wall) + check(f"{label}: ...and the emitted values are the WALL-CLOCK ones", + got.kind == "series" + and [round(p.eur_per_kwh, 6) for p in got.points] == wall) + +# The 25-hour day's two 02:00 hours are two distinct instants, both filled. +_oct = local_midnight(2026, 10, 25) +_oct_end = local_midnight(2026, 10, 26) +_oct_fill = fill_from_yesterday( + PriceRead.no_data("nothing yet"), + yesterday=series_of(hours_between(local_midnight(2026, 10, 24), _oct), + lambda u: y_price(brussels(u).hour), + PriceProduct.DAY_AHEAD, "generic_dayahead", + provisional=False), + since=_oct, until=_oct_end, stamp=_oct) +_twos = [p for p in _oct_fill.points if brussels(p.start).hour == 2] +check("the 25-hour day has TWO local 02:00 points, at different offsets, and " + "both are filled and both are flagged - a 24-slot implementation would " + "collide them into one", + len(_twos) == 2 + and _twos[0].start.utcoffset() != _twos[1].start.utcoffset() + and all(p.provisional for p in _twos)) + + +# --------------------------------------------------------------------------- # +print() +print("3. §5.4 - a late publication REPLACES a carried hour and re-flags it") + +_late = fill_from_yesterday( + series_of(DAY, lambda u: t_price(brussels(u).hour), + PriceProduct.DAY_AHEAD, "generic_dayahead", provisional=False), + yesterday=YESTERDAY, since=D0, until=D1, stamp=D1) +_h20 = DAY[20] +_before = derived(lambda: FILLED.at(_h20)) +_after = derived(lambda: _late.at(_h20)) +check("before the print, hour 20 is provisional and carries YESTERDAY's number", + _before.provisional is True and near(_before.eur_per_kwh, y_price(20))) +check("...after it, the same hour is FIRM and carries today's - the flag and " + "the number move together, because the series is rebuilt and not patched", + _after.provisional is False and near(_after.eur_per_kwh, t_price(20))) +check("...and the two numbers actually differ, so 'it was replaced' is a " + "measurement and not a restatement", + not near(_before.eur_per_kwh, _after.eur_per_kwh)) +check("...and re-running the fill over an already-complete series changes " + "nothing: it is idempotent", + [p.canonical() for p in fill_from_yesterday( + _late, yesterday=YESTERDAY, since=D0, until=D1, stamp=D1).points] + == [p.canonical() for p in _late.points]) + +# Nothing to fill from, and nothing published: no_data, never an empty series. +_nothing = derived(lambda: fill_from_yesterday( + PriceRead.no_data("the feed timed out"), + yesterday=PriceRead.no_data("and there is no yesterday either"), + since=D0, until=D1, stamp=D0)) +check("with neither a published hour nor a yesterday, the result is no_data - " + "not an empty series, and not a series of zeros", + _nothing.kind == "no_data") +check("...so it has no .points to iterate and no .get() to default out of", + raises(lambda: _nothing.points, Absence) + and raises(lambda: _nothing.get("points", []), Absence)) +check("...and its reason names §11.4 for an operator", + "§11.4" in _nothing.reason) +check("a naive `since` is refused rather than silently interpreted as UTC", + raises(lambda: fill_from_yesterday(TODAY_PART, yesterday=YESTERDAY, + since=datetime(2026, 1, 15), + until=D1, stamp=D0), ValueError)) +check("...and `yesterday` has no default, so a fill with nothing to fill from " + "cannot be written by accident", + raises(lambda: fill_from_yesterday(TODAY_PART, since=D0, until=D1, + stamp=D0), TypeError)) + + +# --------------------------------------------------------------------------- # +print() +print("4. AC2 - an irreversible action is REJECTED on a provisional price") + + +class FakeStore: + """Same surface as app/store.Store, no disk. Copied from test_maintenance.""" + + def __init__(self): + self.data = {"phase": IDLE, "phase_started": None, + "last_completed": None, "last_start_attempt": None} + + def set(self, k, v): + self.data[k] = v + + def set_time(self, k, when): + self.data[k] = when + + def get_time(self, k): + return self.data.get(k) + + def save(self): + pass + + +def maint(): + return Maintenance(MaintConfig(enabled=True), FakeStore()) + + +check("the drain's commitment window is DERIVED from the phase machine's own " + "timeout, so the gate and maintenance.py cannot drift apart", + near(MAINTENANCE_DRAIN.commits_h, DRAIN_TIMEOUT_MIN / 60.0) + and near(MAINTENANCE_CHARGE.commits_h, CHARGE_TIMEOUT_MIN / 60.0)) +# The shipped `maintenance_start_hour` is 10:00 local, so a drain started on +# schedule runs into the evening - hours a morning-only day-ahead print has not +# covered. That is the ticket's scenario and it is where the window is placed. +START = D0 + timedelta(hours=MaintConfig().start_hour) +_hours = derived(lambda: committed_hours(START, MAINTENANCE_DRAIN)) +check(f"...and it commits {len(_hours) if not isinstance(_hours, _Raised) else _hours} " + f"whole hours, one per hour of the timeout", + len(_hours) == int(DRAIN_TIMEOUT_MIN / 60)) +check("an action that commits nothing is not an irreversible one and cannot be " + "declared", raises(lambda: Irreversible("X", 0), ValueError) + and raises(lambda: Irreversible("", 4), ValueError)) + +# The stimulus: the first hours are firm, the drain's window reaches into the +# carried ones. THIS is the ticket's scenario - a cycle started at 10:00 on +# prices that were published for the morning and guessed for the evening. +_m = maint() +_gate_bad = derived(lambda: begin_phase(_m, START, FILLED, MAINTENANCE_DRAIN)) +_first_prov = derived(lambda: next(u for u in _hours if FILLED.at(u).provisional)) +check("the drain's window really does reach past the published hours while " + "staying INSIDE the series, so the refusal below is about provenance and " + "not about a short series", + FILLED.covers(_hours[-1]) + and any(not FILLED.at(u).provisional for u in _hours) + and any(FILLED.at(u).provisional for u in _hours)) +check("a maintenance DRAIN whose window includes a carried-forward hour is " + "BLOCKED", _gate_bad.kind == "blocked") +check("...and the phase machine did not move: it is still idle, so the " + "rejection is of the ACTION and not merely of a verdict object", + _m.phase == IDLE) +check("...and the reason names §8.3, the offending hour, and when the real " + "price is expected", + "§8.3" in _gate_bad.reason + and _first_prov.isoformat() in _gate_bad.reason + and "14-16" in _gate_bad.reason) + +# The positive arm, so the block above is discriminating rather than universal. +_firm_long = series_of(hours_between(START, + START + timedelta(hours=len(_hours) + 2)), + lambda u: t_price(brussels(u).hour % 24), + PriceProduct.DAY_AHEAD, "generic_dayahead", + provisional=False) +_m2 = maint() +_gate_ok = derived(lambda: begin_phase(_m2, START, _firm_long, + MAINTENANCE_DRAIN)) +check("...while the SAME transition on a fully published series is PERMITTED, " + "and the phase machine actually moves to drain", + _gate_ok.kind == "permitted" and _m2.phase == DRAIN) +check("...and the permitting arm carries the evidence it checked - one firm " + "point per committed hour", + _gate_ok.kind == "permitted" and len(_gate_ok.hours) == len(_hours) + and not any(p.provisional for p in _gate_ok.hours)) + +# The other two ways a price can fail to be an observation. +_m3 = maint() +_gate_none = begin_phase(_m3, START, PriceRead.no_data("the feed timed out"), + MAINTENANCE_DRAIN) +check("no price data at all BLOCKS an irreversible action too - a missing " + "price is not a cheap one (§11.4)", + _gate_none.kind == "blocked" and "§11.4" in _gate_none.reason + and _m3.phase == IDLE) +_short = series_of(DAY[:4], lambda u: t_price(brussels(u).hour), + PriceProduct.DAY_AHEAD, "generic_dayahead", + provisional=False) +_gate_short = price_permits(_short, D0, MAINTENANCE_DRAIN) +check("...and so does an hour the series does not cover, which is §11.4 for " + "that hour rather than a reason to reach for a neighbour", + _gate_short.kind == "blocked" and "no price covers" in _gate_short.reason) + +# The variant type itself, held to the same shape as PriceRead and Arbitrage. +check("a BLOCKED gate carries a reason and nothing else: .hours and .action " + "raise Absence rather than answering with an empty tuple", + raises(lambda: _gate_bad.hours, Absence) + and raises(lambda: _gate_bad.action, Absence)) +check("...it is not iterable and has no .get(), the other two routes to an " + "empty, harmless-looking loop", + raises(lambda: list(_gate_bad), Absence) + and raises(lambda: _gate_bad.get("hours", []), Absence)) +check("...match() demands BOTH arms, so the block cannot be fallen through", + raises(lambda: _gate_bad.match(permitted=lambda g: 1), TypeError)) +check("...and there is no falsy variant, so `if gate:` cannot look like a " + "guard while being none", + bool(_gate_bad) is True and bool(_gate_ok) is True) +# A genuinely carried point - the flagged one, not the mis-flagged copy above. +_prov_pt = FILLED.points[-1] +check("the point the two constructor checks below are handed really is " + "provisional, or they would be grading nothing", + _prov_pt.provisional is True) +check("a permitted gate cannot be built over a provisional hour or over no " + "hours at all - §8.3 is the one rule this type has", + raises(lambda: Gate.permit(MAINTENANCE_DRAIN, [_prov_pt]), ValueError) + and raises(lambda: Gate.permit(MAINTENANCE_DRAIN, []), ValueError)) + +# TEST-14, PORTED. A default on any of these constructors reopens the exact +# hole `_NoData.reason` had - and static_tariff's `_Refused.reason` still has, +# because nothing scanned it. Scanned by SIGNATURE, so a default added later is +# red without anyone remembering to write a new case. +def _defaulted(fn): + try: + ps = list(inspect.signature(fn).parameters.values()) + except (TypeError, ValueError) as e: # pragma: no cover + return _Raised(e) + return [q.name for q in ps + if q.name != "self" and q.default is not inspect.Parameter.empty] + + +_ctors = {"Gate.permit": Gate.permit, "Gate.block": Gate.block, + "Gate.match": Gate.match, + "_Permitted.__init__": _fc._Permitted.__init__, + "_Blocked.__init__": _fc._Blocked.__init__, + # ⚠️ static_tariff's outcome type is scanned HERE because nothing + # scanned it before: TEST-14 records `_Refused.reason` gaining a + # default as a mutation that survived every check in that file. + "Arbitrage.allow": Arbitrage.allow, "Arbitrage.refuse": Arbitrage.refuse, + "Arbitrage.match": Arbitrage.match, + "_Allowed.__init__": _st._Allowed.__init__, + "_Refused.__init__": _st._Refused.__init__} +_with_defaults = derived(lambda: {n: d for n, d in + ((n, _defaulted(f)) for n, f in _ctors.items()) + if d}) +check(f"no constructor or handler of an outcome type - Gate's OR Arbitrage's - " + f"carries a DEFAULT, so a verdict can never be built out of nothing " + f"{_with_defaults}", _with_defaults == {}) +check("...so a block with no reason is a TypeError at the call site, not a " + "blank status line", + raises(lambda: Gate.block(), TypeError) + and raises(lambda: Gate.block(""), ValueError)) +check("...and the same holds for the refusal FCST-03 added to Arbitrage", + raises(lambda: Arbitrage.refuse(), TypeError)) +check("an ALLOWED arbitrage verdict cannot be constructed around a provisional " + "hour even directly - §8.3 is enforced at the constructor, not only in " + "the ladder above it", + raises(lambda: Arbitrage.allow(0.1, _prov_pt, _prov_pt, "normal", + PriceProduct.DAY_AHEAD, 0.0, frozenset()), + ValueError)) + + +# --------------------------------------------------------------------------- # +print() +print("5. AC3 - real-time thresholds are MEASURABLY wider (measured, not read)") + + +def plan_with_spread(spread, product, provider): + """A two-hour series with exactly `spread` between its two prices.""" + lo, hi = 0.150000, round(0.150000 + spread, 6) + pts = [PricePoint(start=brussels(D0), eur_per_kwh=lo, provisional=False), + PricePoint(start=brussels(D0 + timedelta(hours=1)), + eur_per_kwh=hi, provisional=False)] + return TariffPlan(PriceRead.series(pts, product, provider, D0), None, + "provider") + + +def min_accepted_spread(product, sources, base, provider="generic_dayahead"): + """The SMALLEST spread this configuration will trade on, found by bisecting + the accept/refuse boundary of the real verdict. Nothing here reads a + factor out of the module it is grading.""" + lo, hi = 0.0, 4.0 + for _ in range(60): + mid = (lo + hi) / 2 + v = plan_with_spread(mid, product, provider).arbitrage( + peak_guard_active=False, min_spread_eur_kwh=base, + uncertainty=sources) + if v.kind == "allowed": + hi = mid + else: + lo = mid + return hi + + +BASE = 0.104000 # a caller's own round-trip cost threshold, EUR/kWh +_normal = derived(lambda: min_accepted_spread(PriceProduct.DAY_AHEAD, + frozenset(), BASE)) +_wide = derived(lambda: min_accepted_spread(PriceProduct.REALTIME, + frozenset(), BASE)) +_weather = derived(lambda: min_accepted_spread( + PriceProduct.DAY_AHEAD, frozenset({Uncertainty.NO_WEATHER}), BASE)) +_both = derived(lambda: min_accepted_spread( + PriceProduct.REALTIME, frozenset({Uncertainty.NO_WEATHER}), BASE)) + +check(f"a day-ahead product trades on any spread above its own threshold " + f"{_normal:.6f}, which is the base {BASE} it was given", + near(_normal, BASE, 1e-6)) +check(f"a REAL-TIME / imbalance product needs {_wide:.6f} - MEASURABLY wider, " + f"by a factor of {(_wide / _normal) if _normal else 0:.3f} measured from " + f"two verdict boundaries and not read from any constant", + _wide > _normal + 1e-6) +check(f"...and that measured factor equals the declared one for " + f"REALTIME_PRODUCT ({WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT]}), so " + f"the declaration and the behaviour are the same thing", + near(_wide / _normal, WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT], 1e-4)) +check(f"§11.4's no-weather row reaches the SAME widening path by a different " + f"trigger, and lands at {_weather:.6f}", + near(_weather / _normal, WIDEN_FACTOR[Uncertainty.NO_WEATHER], 1e-4)) + +# ⚠️ THE COMBINATION RULE, ON A STIMULUS WHERE THE THREE CANDIDATES DIFFER. +_f1 = WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT] +_f2 = WIDEN_FACTOR[Uncertainty.NO_WEATHER] +check(f"with BOTH sources active the threshold is max(f1,f2)={max(_f1, _f2)} x " + f"base and NOT the product ({_f1 * _f2}) or the sum ({_f1 + _f2}) - two " + f"independent doubts must not compound into a threshold nothing can " + f"clear", + near(_both / _normal, max(_f1, _f2), 1e-4) + and not near(_both / _normal, _f1 * _f2, 1e-3) + and not near(_both / _normal, _f1 + _f2, 1e-3)) + +# The regime must follow the DECLARED product, never the plugin id. +_wide_named_dayahead = derived(lambda: min_accepted_spread( + PriceProduct.REALTIME, frozenset(), BASE, provider="generic_dayahead")) +_normal_named_eddy = derived(lambda: min_accepted_spread( + PriceProduct.DAY_AHEAD, frozenset(), BASE, provider="eneco_eddy")) +check("the widening follows the DECLARED PriceProduct even when the provider " + "is called 'generic_dayahead', and does NOT follow the name " + "'eneco_eddy' when the declared product is day-ahead - so a sixth " + "supplier is widened by its declaration and not by an id in a list", + near(_wide_named_dayahead, _wide, 1e-9) + and near(_normal_named_eddy, _normal, 1e-9)) +check("...and the shipped plugin §9.2 names IS the one that declares REALTIME, " + "so the mechanism is pointed at the real product", + PROVIDERS["eneco_eddy"].capabilities().product is PriceProduct.REALTIME + and uncertainty_of(PriceProduct.REALTIME) + == frozenset({Uncertainty.REALTIME_PRODUCT}) + and uncertainty_of(PriceProduct.DAY_AHEAD) == frozenset()) +check("a string in the uncertainty set is REFUSED rather than widening by 1.0 " + "and reporting that it widened", + raises(lambda: widen_factor({"realtime"}), TypeError) + and raises(lambda: required_spread(0.1, {"realtime"}), TypeError)) +check("...and a negative or non-finite base threshold is refused, since it is " + "one every spread clears", + raises(lambda: required_spread(-0.01, frozenset()), ValueError) + and raises(lambda: required_spread(float("nan"), frozenset()), ValueError)) +check("required_spread() with nothing uncertain is the base itself, so the " + "widening is additive machinery and not a permanent surcharge", + near(required_spread(BASE, frozenset()), BASE)) + + +# --------------------------------------------------------------------------- # +print() +print("6. the refusal ladder - provisional hours are DROPPED, not traded on") + +# ⚠️ THE PROVISIONAL HOURS CARRY THE CHEAPEST PRICE. That is the realistic +# danger (yesterday's cheap night luring a charge) AND the non-degenerate +# stimulus: the firm-only spread and the whole-series spread differ, so a check +# on the spread can tell which set was used. +_cheap_stale = [PricePoint(start=brussels(DAY[i]), + eur_per_kwh=0.041250, provisional=True) + for i in range(3)] +_firm_rest = [PricePoint(start=brussels(DAY[i]), + eur_per_kwh=round(0.212500 + 0.013750 * i, 6), + provisional=False) for i in range(3, 12)] +_mixed = TariffPlan(PriceRead.series(_cheap_stale + _firm_rest, + PriceProduct.DAY_AHEAD, + "generic_dayahead", D0), None, "provider") +_all_spread = derived(lambda: _mixed.spread_eur_kwh()) +_firm_spread = derived(lambda: round( + max(p.eur_per_kwh for p in _firm_rest) + - min(p.eur_per_kwh for p in _firm_rest), 6)) +check(f"the fixture is non-degenerate: the whole-series spread " + f"{_all_spread:.6f} and the firm-only spread {_firm_spread:.6f} are " + f"different numbers, so a verdict can be traced to one of them", + not near(_all_spread, _firm_spread, 1e-6)) +_v = derived(lambda: _mixed.arbitrage(peak_guard_active=False, + min_spread_eur_kwh=0.0, + uncertainty=frozenset())) +check("a PART-published series still allows arbitrage - §8.3 forbids acting on " + "a provisional price, not trading at all until 16:00", + _v.kind == "allowed") +check(f"...on the FIRM spread {_firm_spread:.6f}, not the one yesterday's " + f"carried-forward cheap hour would have invented", + near(_v.spread_eur_kwh, _firm_spread, 1e-9)) +check("...and the cheapest and dearest hours it hands an optimiser are both " + "PUBLISHED hours - the two points ARE the trade", + _v.cheapest.provisional is False and _v.dearest.provisional is False) +check("...and it reports the threshold it cleared and why, rather than leaving " + "OPT-04 to re-derive it from a product enum", + near(_v.required_spread_eur_kwh, 0.0) and _v.uncertainty == frozenset()) + +_all_prov = TariffPlan(PriceRead.series(_cheap_stale, PriceProduct.DAY_AHEAD, + "generic_dayahead", D0), None, + "fallback") +_vp = derived(lambda: _all_prov.arbitrage(peak_guard_active=False, + min_spread_eur_kwh=0.0, + uncertainty=frozenset())) +check("a WHOLLY provisional series - the §11.4 fallback, or a day before any " + "print - is still refused, and the reason counts the firm hours", + _vp.kind == "refused" and "0 of 3" in _vp.reason and "§8.3" in _vp.reason) +check("...and the refusal carries no numbers to trade on", + raises(lambda: _vp.spread_eur_kwh, Absence) + and raises(lambda: _vp.cheapest, Absence)) +_one_firm = TariffPlan( + PriceRead.series(_cheap_stale + _firm_rest[:1], PriceProduct.DAY_AHEAD, + "generic_dayahead", D0), None, "provider") +check("one firm hour is not enough either: a buy hour and a sell hour must " + "BOTH be published before a cycle can be committed", + _one_firm.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, + uncertainty=frozenset()).kind == "refused") +check("§10.1 still holds above all of it: the peak guard refuses the same " + "series that is otherwise allowed", + _mixed.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, + uncertainty=frozenset()).kind == "refused") +check("the three questions cannot be skipped: all of peak_guard_active, " + "min_spread_eur_kwh and uncertainty are required and keyword-only", + raises(lambda: _mixed.arbitrage(peak_guard_active=False), TypeError) + and raises(lambda: _mixed.arbitrage(peak_guard_active=False, + min_spread_eur_kwh=0.0), TypeError) + and raises(lambda: _mixed.arbitrage(False, 0.0, frozenset()), TypeError)) +check("...nor answered with a truthy 'unknown' or a negative threshold", + raises(lambda: _mixed.arbitrage(peak_guard_active=None, + min_spread_eur_kwh=0.0, + uncertainty=frozenset()), TypeError) + and raises(lambda: _mixed.arbitrage(peak_guard_active=False, + min_spread_eur_kwh=-0.1, + uncertainty=frozenset()), TypeError)) + +# The cheap-window flag is the other consumer of provisionality, and it is the +# one a live install's maintenance charge floor already reads. +_firm_hour = _firm_rest[0].start +_prov_hour = _cheap_stale[0].start +check("the cheap-window flag is decided per HOUR: a provisional hour is never " + "cheap, whatever its (stale) number says", + _mixed.cheap_now(_prov_hour) is False) +check("...while a firm hour below the firm mean still is, so carry-forward " + "filling does not switch the maintenance charge floor off every morning", + _mixed.cheap_now(_firm_hour) is True) +check("...and the firm mean it compares against is derived from published " + "hours only", + near(sum(p.eur_per_kwh for p in _mixed.firm_points()) + / len(_mixed.firm_points()), + sum(p.eur_per_kwh for p in _firm_rest) / len(_firm_rest))) + + +# --------------------------------------------------------------------------- # +print() +print("7. CFG-03 and validate.py rule 7 - what this ticket did NOT widen") + +_defaults = derived(lambda: sorted(_price.PRICE_OPTION_DEFAULTS)) +_cfg = derived(lambda: open(os.path.join(HERE, "config.yaml"), + encoding="utf-8").read()) +check(f"FCST-03 added ZERO option keys: the in-code mirror still holds exactly " + f"TARIFF-02's and TARIFF-03's {_defaults}", + _defaults == sorted(["price_provider", "price_markup_eur_kwh", + "price_taxes_eur_kwh", "export_price_eur_kwh", + "price_csv_path", "price_feed_url", + "price_horizon_h", "price_timeout_s", + "static_profile"])) +check("...and every one of them is still in config.yaml, which stays the " + "source of truth", + isinstance(_cfg, str) + and all(f"\n {k}:" in _cfg for k in _price.PRICE_OPTION_DEFAULTS)) + + +def _names(path): + try: + tree = ast.parse(open(path, encoding="utf-8").read()) + except (OSError, SyntaxError): # pragma: no cover + return set() + out = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + out |= {a.name for a in node.names} + if isinstance(node, ast.Name): + out.add(node.id) + if isinstance(node, ast.Attribute): + out.add(node.attr) + return out + + +_FC = os.path.join(HERE, "app", "price_forecast.py") +_used = derived(lambda: _names(_FC)) +check("app/price_forecast.py reads NO options at all - it takes a PriceRead " + "and instants, so there is no fourth place for an option default to live", + isinstance(_used, set) + and not ({"price_option", "PRICE_OPTION_DEFAULTS", "PluginOptions"} + & _used)) +# ⚠️ RULE 7'S TRIPWIRE, RESPECTED RATHER THAN TRIPPED. test_price.py section 12 +# pins the set of modules that consume `load_prices()` to exactly +# ["static_tariff.py"], and goes red when a second one appears. This module +# deliberately does NOT fetch: it transforms a read somebody else obtained, so +# rule 7's message about what a configured provider does or does not drive is +# unchanged by this ticket and needed no rewording. +check("...and it does not consume load_prices(), so validate.py rule 7's " + "pinned consumer list is still true and its wording still accurate", + "load_prices" not in _used) + + +print() +print("FAILED" if fails else "OK") +for f in fails: + print(" -", f) +sys.exit(1 if fails else 0) diff --git a/goodwe_controller/test_static_tariff.py b/goodwe_controller/test_static_tariff.py index 6be4afa..145b81e 100644 --- a/goodwe_controller/test_static_tariff.py +++ b/goodwe_controller/test_static_tariff.py @@ -306,8 +306,8 @@ check(f"...while the two-period spread is the day/night difference " f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}", near(_spread_two, DAY_P - NIGHT_P, 1e-9)) -_v_fixed = p_fixed.arbitrage(peak_guard_active=False) -_v_two = p_two.arbitrage(peak_guard_active=False) +_v_fixed = p_fixed.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()) +_v_two = p_two.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()) check("a fully-fixed tariff REFUSES arbitrage", _v_fixed.kind == "refused") check("...for the true reason - the series is flat, so there is nothing to buy " "low and sell high, not because a FIXED enum was matched on", @@ -327,7 +327,7 @@ _flat_dayahead = TariffPlan( None, "provider") check("a DAY_AHEAD series that happens to be flat is refused too, so the rule " "is arithmetic and not a product label", - _flat_dayahead.arbitrage(peak_guard_active=False).kind == "refused") + _flat_dayahead.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused") # The numbers an optimiser needs exist ONLY on the permitting variant. check("the allowed verdict carries the spread, the cheapest hour and the " @@ -352,9 +352,9 @@ check("...and there is no falsy variant, so `if verdict:` cannot look like a " # §10.1 priority 2 over priority 3, expressed so OPT-04 cannot skip it. check("peak shaving outranks arbitrage: the SAME allowed plan is refused when " "the peak guard is holding", - p_two.arbitrage(peak_guard_active=True).kind == "refused") + p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused") check("...and the reason names the ordering rather than the tariff", - "priority 2" in p_two.arbitrage(peak_guard_active=True).reason) + "priority 2" in p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).reason) check("...and the question cannot be skipped: peak_guard_active is required " "and keyword-only, so a spread is not obtainable without answering it", raises(lambda: p_two.arbitrage(), TypeError) @@ -497,7 +497,7 @@ check("...and its underlying read has no .points at all, so `min(read.points)` " "cannot run against it", raises(lambda: _none.read.points, Absence)) check("...and arbitrage is refused, per §11.4", - _none.arbitrage(peak_guard_active=False).kind == "refused" + _none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused" and arbitrage_allowed(_none.read) is False) check("...and static_status() says so in words rather than reporting a price", static_status(opts(None), THU) == (False, "no static profile")) @@ -746,12 +746,12 @@ check("...marked PROVISIONAL, which is the one flag that differs", _fb.provisional and plan_is_provisional(_fb.read)) check("...and arbitrage is refused on it, per §11.4, even though a full series " "exists and has a real spread", - _fb.arbitrage(peak_guard_active=False).kind == "refused" + _fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused" and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P)) check("...while the SAME profile as the configured product allows it - so the " "refusal is about provisionality and not about the profile", static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1) - ).arbitrage(peak_guard_active=False).kind == "allowed") + ).arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed") check("...and the provisional plan has no cheap window either: §8.3 forbids an " "irreversible action on a provisional price, and a grid top-up is one", not any(_fb.cheap_now(at_local(THU, h)) for h in range(24))) @@ -779,8 +779,8 @@ check("a live feed is used in preference to the static profile", and _live_plan.product is PriceProduct.DAY_AHEAD) check("...and arbitrage is allowed on it, with the spread derived from the " "feed rather than from the profile", - _live_plan.arbitrage(peak_guard_active=False).kind == "allowed" - and near(_live_plan.arbitrage(peak_guard_active=False).spread_eur_kwh, + _live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed" + and near(_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh, 0.023, 1e-9)) -- 2.54.0