"""FCST-03 - the hours the feed has not published yet, and what may not rest on them. specs.md 8.3: unknown hours use yesterday's profile and are flagged `provisional: true`, and no irreversible action may be taken on a provisional price. This module owns the fill and the gate; the widening arithmetic is in price.py. The flag is never passed through: a carried point is built only by `_carried()` (no flag parameter), a published point is passed by identity, and `_audit()` raises on a mismatch. The fill keys on the Brussels wall-clock hour, and a sub-hourly feed is never decimated. An hour with neither a published price nor a yesterday value is left out, never filled (`at()` raises `Absence`, 11.4 applies). `begin_phase()` is the price-justified entry point only; the calendar path is untouched. All of it: DECISIONS/FCST-03-the-hours-not-yet-published.md.""" import logging from dataclasses import dataclass from datetime import datetime, timedelta, timezone from enum import Enum from . import messages 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: the wire format carries only the boolean. It gives `_audit()` and the log names for the two cases.""" PUBLISHED = "published" # the provider printed this hour. Flag: the # feed's own - a real-time 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 _published_by_hour(read: PriceRead) -> dict: """UTC hour -> the provider's OWN points that start inside it, in order. A tuple per hour, not a point: a dict of `{hour: p}` silently keeps the last one and decimates a quarter-hourly feed.""" out: dict = {} read.match( series=lambda r: [out.setdefault(_utc_hour(p.start), []).append(p) for p in r.points], no_data=lambda _r: []) return {u: tuple(ps) for u, ps in out.items()} 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: the flag is a literal, nothing to pass or copy.""" 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 TUPLE of points the provider produced for it. PUBLISHED: the point must be one of `published[u]` by identity, emitted once, flag whatever the feed said (never forced to False). CARRIED: the hour must not be in `published`, the flag exactly True, no second point in that hour. Provenance, NOT completeness: it accepts a decimated or empty output, because `published` is unwindowed while the fill emits [since, until). The point count and identity checks in the tests guard that. Raises, never repairs. DECISIONS/FCST-03-the-hours-not-yet-published.md.""" carried_hours = set() emitted = set() for p in points: u = _utc_hour(p.start) src = published.get(u) if src is None: if u in carried_hours: raise ValueError( f"the filled series repeats the carried hour " f"{u.isoformat()}; a duplicated hour is how a DST day gets " f"mispriced") carried_hours.add(u) if p.provisional is not True: raise ValueError( f"{u.isoformat()} is {Provenance.CARRIED.value} and is " f"flagged provisional={p.provisional!r}. §8.3: an hour " f"filled from yesterday's profile is provisional whether " f"or not the number turns out to be right - the flag is a " f"claim about PROVENANCE, not about accuracy.") else: if not any(p is q for q in src): raise ValueError( f"{u.isoformat()} is {Provenance.PUBLISHED.value} but the " f"series carries a COPY of that point rather than the " f"point itself. A copy is where the provisional flag gets " f"lost; published points pass through by identity.") if id(p) in emitted: raise ValueError( f"the filled series emits the published point at " f"{p.start.isoformat()} TWICE. The provider's own points " f"are strictly increasing (`_validate_raw`), so a repeat " f"is never a second print - it is a duplicated hour, which " f"is how a DST day gets mispriced") emitted.add(id(p)) 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. `yesterday` has no default. Returns a series (every published point, by identity, plus one `_carried()` point per remaining whole hour) or `no_data`, never an empty series. The window is whole hours at both ends. Idempotent and re-runnable (5.4): published hours win and the series is rebuilt.""" 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 = _published_by_hour(read) # Yesterday's profile, keyed by BRUSSELS WALL-CLOCK HOUR. First point wins # when yesterday itself was a 25-hour day. 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 = _utc_hour(until) while u < end: got = published.get(u) if got: out.extend(got) # BY IDENTITY, and ALL of them. No copy, else: # and no decimation to one an hour. 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"all {messages.count(len(r.points), 'published price')} " f"fall outside {messages.when(brussels(since))} to " f"{messages.when(brussels(until))}"), no_data=lambda r: r.reason) return PriceRead.no_data( f"no hours could be filled from yesterday's prices: {reason}. So " f"the fixed tariff is used, the plan is estimated and there is no " f"trading") _audit(out, published) carried = sum(1 for p in out if _utc_hour(p.start) not in published) if carried: # TARIFF-11: carried hours, total hours and published points are three # different quantities. total_h = len({_utc_hour(p.start) for p in out}) _LOG.info("%d of %s use yesterday's prices, marked as estimated. " "The feed published %s over the other %s.", carried, messages.count(total_h, "hour"), messages.count(len(out) - carried, "price"), messages.count(total_h - carried, "hour")) 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. `commits_h` is the horizon it ties up: those hours must be firm before a price may justify it.""" 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, so the gate # cannot drift from it. 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 checked); `blocked` carries `.reason` only. No `__bool__`; both `match()` arms are required.""" 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"allowed on {messages.count(len(pts), 'published price')} " f"from {messages.when(brussels(pts[0].start))}") 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 (the gate and its caller must agree).""" 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 on no data (11.4), an hour with no price, or a provisional hour, and names the offending hour.""" hours = committed_hours(now, action) return read.match( series=lambda r: _walk(r, hours, action), no_data=lambda r: Gate.block( f"{action.name} needs a price and there is none: {r.reason}. A " f"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} needs prices for {action.commits_h:g} hours " f"from {messages.when(brussels(hours[0]))}, and no price " f"covers {messages.when(brussels(u))}. An hour with no price " f"is not a free one") p = series.at(u) if p.provisional: return Gate.block( f"{action.name} needs prices for {action.commits_h:g} hours " f"from {messages.when(brussels(hours[0]))}, and the price for " f"{messages.when(brussels(u))} is only estimated. It can start " f"once the day-ahead prices are published, around 14:00 to " f"16:00") 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 price-justified entry point, not the calendar one: a site with no provider must keep starting its monthly cycle as today (12). The blocking arm has no path to `maint.force_start(now)`.""" 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("An action that cannot be undone does not start: %s.", gate.reason) return gate