Comment, docstring and DECISIONS only (same-code script OK; python -OO bytecode now identical for all six files). - peakguard.charge_room_w: one-line docstring + SAFETY-125 pointer (cut whole). - peakguard.shaping_suspended: keep 'the one place this conjunction is written; do not inline it'. - price_forecast module note: an hour with no price and no yesterday is left out, never filled (test_price.py 14d cites the module note for it). - Refill comment blocks the cut left ragged; DECISIONS pointers on one line. - FCST-04 note: _yesterday_of(), not _previous_day(); FCST-03 note: the 49 stronger cases are why the rule is a real change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
329 lines
14 KiB
Python
329 lines
14 KiB
Python
"""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
|