diff --git a/goodwe_controller/app/price_forecast.py b/goodwe_controller/app/price_forecast.py index 916a8fc..f71a4a4 100644 --- a/goodwe_controller/app/price_forecast.py +++ b/goodwe_controller/app/price_forecast.py @@ -1,163 +1,14 @@ """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. EVERY published point in the window is passed through, not one - per hour: see the sub-hourly note below. - * `_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. - - ⚠️ IT GRADES THE POINTS THAT ARE EMITTED AND NOTHING ABOUT THE ONES THAT - ARE NOT (FCST-05). `_audit()` is a provenance check, NOT a completeness - check: fed FCST-04's own decimation defect - four published points in an - hour, only the last emitted - it ACCEPTS, and it accepts an empty output - too. Measured, and the FCST-03 audit it replaced was equally blind, so - this is a limit rather than a regression. What actually guards against a - decimated series is the point COUNT and the pass-through IDENTITY checks - in `test_price_forecast.py` (section on the quarter-hourly feed) and - `test_price_history.py`; see `_audit()`'s own docstring for why the - completeness rule is not expressible from its two arguments. - * 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". §9.3's canonical format carries -`provisional` PER POINT, not per series, precisely because a real-time / -imbalance product's current-interval print is settled after the fact and is -genuinely provisional while it is published. 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. - -⚠️ NO SHIPPED PLUGIN EMITS ONE TODAY (TARIFF-07 withdrew the only declarer of -`PriceProduct.REALTIME`), and that is exactly why this paragraph stays. The -rule is a property of the FORMAT, not of any supplier; weakening it to -"published implies False" while nothing exercises it is how the first real -imbalance feed would arrive mispriced and flagged firm. - - -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). - -⚠️ A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR, and the hourly walk -below is about WHICH HOURS NEED FILLING - never about what a published hour is -allowed to contain. BELGIAN DAY-AHEAD ITSELF SETTLES PER QUARTER since the -15-minute MTU took effect on 2025-10-01 - `generic_dayahead` reads 96 points a -day, not 24 - `_validate_raw()` accepts any strictly-increasing spacing, and a -published hour may therefore hold four points. So EVERY published point inside -the window is passed through, in order, and a carried point is added only for a -whole hour with NO published coverage at all. - -The alternative was measured rather than reasoned about, because FCST-04's -first caller shipped it: keying the published set as `{_utc_hour(p.start): p -for p in r.points}` reads perfectly ordinary and keeps the LAST point of each -hour, dropping the other three. On a 96-point quarter-hourly feed - all firm, -fully published, nothing to carry - the series came out 15 points long, the -arbitrage spread collapsed from 0.143 to 0.014 EUR/kWh, and the cheapest -"hour" became a `:45` point whose `at()` window runs into the next hour. A feed -losing 84 % of its own prints is not a windowing effect and is not something a -provisional flag would ever have reported. - - -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. -""" +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. `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 @@ -172,13 +23,8 @@ _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". - """ + """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). @@ -186,9 +32,7 @@ class Provenance(Enum): # Flag: ALWAYS True, by construction. -# --------------------------------------------------------------------------- # # The fill -# --------------------------------------------------------------------------- # def _utc_hour(instant: datetime) -> datetime: u = instant.astimezone(timezone.utc) @@ -196,16 +40,9 @@ def _utc_hour(instant: datetime) -> datetime: 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, AND THAT IS THE WHOLE OF THE SUB-HOURLY - RULE. `{_utc_hour(p.start): p for p in r.points}` reads identically, is one - character shorter, and silently keeps one point per hour - the last one - written. The mapping answers two DIFFERENT questions here: "does this hour - need filling at all?" is `u in published`, and "what does the output carry - for it?" is all of `published[u]`. Collapsing them is how a quarter-hourly - feed loses three quarters of its prints. - """ + """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) @@ -215,13 +52,8 @@ def _published_by_hour(read: PriceRead) -> dict: 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. - """ + """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) @@ -230,88 +62,15 @@ def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint: 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_by_hour()`). An hour ABSENT from that mapping is an hour - the feed did not print, and a carried point is the only thing that may - stand in it. + `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. - The rule, in the two forms an output point can take: - - * PUBLISHED - the point must BE one of `published[u]`, by identity, and - may be emitted only once. Its flag is whatever the feed said, never - forced to False: an imbalance print is published AND provisional (§9.2, - TARIFF-02), and a check that "corrected" it would be a bug wearing a - guarantee. - * CARRIED - the hour must not appear in `published` AT ALL, the flag must - be exactly True, and that hour may hold no second point. - - ⚠️ WHAT REPLACED "AT MOST ONE POINT PER HOUR", AND WHAT THAT DID AND DID - NOT CHANGE. This audit began as FCST-03's `seen` set: any repeated - `_utc_hour` raised, beside an identity rule `p is not src`. A bare hour - count cannot express a quarter-hourly feed, where four distinct published - points share one hour legitimately - so the count was replaced by the two - rules above, which subsume it. - - ⚠️ AND IT IS NOT STRICTER. FCST-04's version of this paragraph claimed two - strengthenings and BOTH WERE FALSE; FCST-05 measured it. A differential - over the whole domain this audit can express - every published mask over 3 - hours crossed with every output series of length 0-3 drawn from - {published, published-provisional, carried, carried-unflagged, - copy-of-published} - found ZERO divergence in EITHER direction: - - OLD raised, NEW accepted (WEAKER): 0 - OLD accepted, NEW raised (STRONGER): 0 - - The two specific claims, one at a time: - - * "so does the same published point emitted twice" - the OLD `seen` - count already raised on that, because emitting one point twice is one - HOUR twice. Preserved by the identity/`emitted` rules, not added. - * "a carried point may no longer share an hour with a published one, - which the count never checked at all" - true of the COUNT, and the old - IDENTITY rule raised on it anyway: a carried point in a published hour - took the `else` branch and failed `p is not src`. The message was about - a copy rather than about a carry, which is the only thing that moved. - - The one real behavioural change is the intended RELAXATION - several - distinct published points in one hour, which the old audit could not - express at all and refused. That relaxation is why this was rewritten, and - it stays. A right decision with a fabricated reason is still a defect here. - - ⚠️ AND THE REAL LIMIT, WHICH IS NOT A REGRESSION AND IS NOT SMALL: THIS - AUDIT DOES NOT CHECK COMPLETENESS. It grades every point that IS emitted - and says nothing about the ones that are not. Fed the H1 decimation - directly - four published points in an hour, output emitting only the last - - it ACCEPTS; so does two of four, and so does an EMPTY output. So `_audit` - is NOT what stops H1 recurring. What stops it is the point COUNT and the - IDENTITY checks in `test_price_history.py` and `test_price_forecast.py`, - which compare the emitted series against the feed's own prints. - - It is not made complete here, and the reason is structural rather than - effort: `published` is `_published_by_hour(read)` over the WHOLE read, - unwindowed, while `fill_from_yesterday()` emits only [since, until). A - published point outside the window is legitimately absent, so "every - published point appears" is not a true post-condition of these two - arguments - it would need the window passed in, which is a different - signature and a different check. - - ⚠️ AND THAT IS MEASURED, NOT ASSUMED. FCST-05 built the rule - raise unless - every point of every `published[u]` is emitted, +162 B - and ran it. It - takes the 1 h, 12 h and 48 h HORIZON checks in test_price_forecast.py RED, - on the shipped fill and a healthy feed: at an off-the-hour instant the - feed's already-elapsed prints are outside [since, until) and are correctly - dropped, and the rule calls that a provenance failure. (The differential - beside those checks scores the same rule as 49 STRONGER cases, so it is a - real change of behaviour and not a no-op.) The rule as stated is therefore - not merely unimplemented, it is WRONG - and stating a false post-condition - is MUTATION.md habit 16's defect: an instrument that asserts a completeness - it does not have is worse than no instrument. - - ⚠️ 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. - """ + 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: @@ -351,37 +110,12 @@ def _audit(points, published) -> None: 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. + """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, carrying the provider's own objects - ALL of them, which on - a quarter-hourly feed is four inside one hour - for the hours it - published, and one `_carried()` point for each remaining whole hour, - 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). - - ⚠️ THE WINDOW IS WHOLE HOURS AT BOTH ENDS. `since` is floored to the hour - that CONTAINS it, because the current hour is still a tradeable hour; and - so is `until`, which is the half FCST-04's first draft left out. With - `since` floored and `until` compared raw, a 48 h horizon asked for at 08:17 - emitted 49 hours - the hour of `until` was still `< until` - and the count - was one too many for every `now` that is not exactly on the hour, which is - every `now` a running controller has. - - ⚠️ 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. - """ + `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 " @@ -390,7 +124,8 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead, 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 and printed local hour 2 twice. + # 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) @@ -424,14 +159,8 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead, _audit(out, published) carried = sum(1 for p in out if _utc_hour(p.start) not in published) if carried: - # ⚠️ THREE QUANTITIES, THREE NAMES (TARIFF-11). This line read - # "%d of %d hours ... ; %d published" with `carried` (a count of - # carried HOURS - the fill emits exactly one point per unpublished - # hour) over `len(out)` (a count of POINTS, four per hour wherever the - # feed printed) and `len(out) - carried` (POINTS again) under the same - # noun. On the Belgian quarter-hourly feed that reads "33 of 141 - # hours" about a 48-hour horizon. The fill's unit is the HOUR and the - # feed's is the POINT; nothing derived from one answers for the other. + # 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.", @@ -446,17 +175,12 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead, 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. - """ + """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 @@ -473,25 +197,16 @@ class Irreversible: 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. +# 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 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. - """ + `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) @@ -541,12 +256,7 @@ class _Blocked(Gate): 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. - """ + """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: @@ -562,13 +272,9 @@ def committed_hours(now: datetime, action: Irreversible) -> tuple: 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. - """ + """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), @@ -600,18 +306,10 @@ def _walk(series, hours, action) -> Gate: 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. - """ + """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))