diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 7ee66ba..4952fd8 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -65,6 +65,73 @@ 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. +**The grid-following loop is now capped against the month's billed capacity peak +(SAFETY-07).** New `app/peakguard.py`. TARIFF-01 measured the quarter; this is the +consumer it was built for. Every cycle the guard projects the open quarter, +compares it to a ceiling and files ONE arbiter claim: a lower bound on the +setpoint at `P_PEAK`. That shape is both behaviours §10.1 asks for — a hard clamp +on charging and pre-emptive discharge — and it cannot cap discharge or export, +because a floor cannot. `excess = 900 × (projected − ceiling) ÷ remaining`, and +the floor is the MEASURED battery power plus that: a floor built on the standing +command winds itself to the rail whenever the inverter fails to follow, and one +built on delivered power cannot. + +**It trips where SAFETY-18 only notifies, and the captures decided that.** On the +healthy reference day it engages on zero of 95 quarters; the action is +level-triggered, unlatched and re-derived every cycle, so a false engagement costs +one cycle of discharge rather than a parked battery. Replayed on 2026-08-20 the +billed quarter goes from **5029.8 W — above the inverter's own 5000 W rating — to +3485 W**, under the soft limit and without saturating. Quarters under the limit +across both committed days: 189/190 → 190/190. Two days is not thirty; AC 3 is +still TARIFF-01's open item and this does not claim it. + +**Blindness forbids importing, and it is checked before the projection is +graded.** A fresh averager projects a confident 0 W, which is under every ceiling +there is; a meter gap longer than `meter_max_age_s` makes the projection read LOW; +and a frozen meter reading export projects 0 W at full coverage. All three become +a floor of 0 W, which is one-directional and cannot cost pack energy. That is the +backstop SAFETY-18 could not be — its alarm arrives at 300 s of a 900 s quarter +and commands nothing. The frozen term is `freeze.looks_frozen()` itself, evaluated +on this cycle's standing command, not a second copy of the conjunction. + +**The ceiling is `peak_cap_w`, raised only by an OBSERVED reading.** `NO_RECORD` +and `NOT_OBSERVED` both leave it at the cap, so an unreadable peak store makes the +guard STRICTER. The gate is the reading's STATE, not whether a number happens to +be attached to it: today a `peak_w is not None` test behaves identically, and +depending on that coincidence is how a future last-known-peak field would silently +raise the ceiling off a read that failed. `headroom_w()` is deliberately not +consumed — `cap − peak` turns a zero peak into FULL freedom. + +**Two defects found building it, both in the interaction rather than the +arithmetic.** A LIMIT does not out-vote another LIMIT — `arbiter.resolve()` applies +every limit at or above the winner's priority and takes the most restrictive — so +the peak floor (`lo ≥ 0`) and the maintenance charge-only shaping (`hi ≤ 0`) +resolved to a CONTRADICTION and 0 W, parking the battery in the exact quarter it +should shave. Fixed by suppression rather than priority: main.py drops the +maintenance shaping while the guard is engaged, which is what that branch already +did for the optional `peak_forecast_entity` — a key that is empty on a default +install, so until now the suspension could not fire on the site that needed it. +And thresholding `excess > 0` released the claim the instant it started working, +producing seventeen engage/release cycles in one 900 s quarter (§10.2's +oscillation) and leaving that quarter 49 W over; the threshold is now the floor +itself, which is the same arithmetic read as a level rather than as a switch. + +**`PeakTracker.project()` no longer reads a private name with a default.** +`getattr(averager, "_last_net", 0.0)` failed quiet AND in the disarming direction: +a rename credits 0 W held, the projection reads low, and the money guard does not +engage. `QuarterAverager.held_net_w` is now a documented public property, read +without a default, and `Projection` carries it. TEST-13 item 4. + +**No new option, and no fourth defaults site.** The ceiling is `peak_cap_w`, the +coverage threshold is `meter_max_age_s`, the freeze predicate is `meter_freeze_s`, +and the achievable discharge comes from `max_w` and the device rating. +`config.yaml` and `validate.py` are untouched; §14's `capacity_peak_limit_kw` +stays declined as the kW/W mismatch trap it is. On `meter_source: off` there are +no telegrams, so the guard does not run at all and the status page says NOT +COVERED — running it there would have blinded it permanently and stopped every +legacy install from charging, which is what the first cut did until +`test_clamp.py`'s export case caught it. + **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/DOCS.md b/goodwe_controller/DOCS.md index 96f08c3..2fb8af2 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -439,6 +439,11 @@ maintenance schedule. #### Capacity-tariff exposure of the grid-following loop +⚠️ **This section described the exposure BEFORE SAFETY-07. The loop is now +capped — see "The capacity-peak guard" below — and what follows is kept because it +is still the clearest statement of WHAT is being guarded and why the number is +worth what it is.** + ⚠️ **`peak_cap_w` caps MAINTENANCE charging. It does not cap the loop.** The grid-following loop is bounded by `max_w` and by the device rating, and by nothing else. That is deliberate — the loop exists to *reduce* what crosses the @@ -463,16 +468,72 @@ What exists today, and what does not: |---|---| | `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude | | Maintenance charging capped under `peak_cap_w` | ✅ | -| **Loop capped under `peak_cap_w`** | ❌ — not built. This is SAFETY-07 | +| **Loop capped under `peak_cap_w`** | ✅ — SAFETY-07, on every `meter_source` but `off` | | Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** | -| Frozen meter *stopped* | ❌ — the alarm does not command anything | +| Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen | | SoC guard on the loop | ❌ — the BMS is the only energy backstop | -So the frozen-meter alarm below **narrows the window; it does not close it.** On -a capacity-tariff site where an unattended runaway is unacceptable, the mitigation -today is operational: set `max_w` no higher than the loop actually needs (the -tuning was measured at 2000 W and a 1.78 kW load step is tracked fine there), fit -the e-stop, and watch for the alarm. +On the legacy `meter_source: off` path there are no P1 telegrams, so there is no +quarter-hour average to guard, and the status page says **NOT COVERED**. There +the mitigation is still operational: set `max_w` no higher than the loop actually +needs (the tuning was measured at 2000 W and a 1.78 kW load step is tracked fine +there), fit the e-stop, and watch for the alarm. + +### The capacity-peak guard + +**What it does.** Every cycle the add-on projects the open quarter to its +end-of-interval average — from P1 telegrams only, never from the command — and +compares it to a ceiling. Over the ceiling it files one claim: a **lower bound on +the setpoint**, at `P_PEAK`. That single shape is both behaviours §10.1 asks for: +a hard clamp on charging while the bound sits above the standing charge, and +pre-emptive discharge while it sits above zero. It can never cap discharge or +export, because a floor cannot. + +**The number.** `excess = 900 × (projected − ceiling) ÷ seconds remaining`, and +the floor is the **measured** battery power plus that. The 900/remaining factor +is the design rather than an accident: it is 1.0 at the top of the quarter and +grows, so the guard is gentlest when its projection rests on the least evidence +and becomes aggressive only once the quarter is nearly measured. + +**The ceiling** is `peak_cap_w`, raised to the month's banked peak when that +already stands higher — once a month's bill is set at 5013 W, holding later +quarters to 3500 W buys nothing and costs real charging. **That relaxation is +granted only by an OBSERVED reading:** a peak store that cannot be read leaves the +ceiling at the cap, so a broken instrument makes the guard stricter, never looser. + +**When it cannot see, it forbids importing.** Three states are not evidence and +are checked before the projection is graded: no open quarter (a fresh averager +projects a confident 0 W, which is under every ceiling there is), a meter gap +longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all +three the claim becomes a floor of **0 W**. That is the backstop for the frozen +meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter +and commands nothing, and this stops the import. + +**It trips, where the freeze detector only notifies,** and the captures are why. +On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters. +The action is level-triggered and unlatched — re-derived from a fresh projection +every cycle, released the instant the projection drops back — so a false +engagement costs one cycle of discharge rather than a parked battery. And it is a +LIMIT, so every stop in the watchdog table still outranks it. + +**Replayed on 2026-08-20**, the day the vendor controller failed: the billed +quarter measured **5029.8 W**, above the inverter's own 5000 W rating, and the +guard brings it to **3485 W** — under the soft limit, without ever asking for more +than the inverter can give. Across both committed days the quarters under the +limit go from 189/190 to 190/190. Two days is not thirty and does not close AC 3; +it is the comparison two days can support. + +**What it alarms on is saturation, not engagement.** An engagement is an ordinary +control action and goes to the action log and the status page. A *saturated* +guard — asking for everything the inverter has while the quarter is still +projected over — raises one notification (`goodwe_peak_saturated`), because that +is the one state where the money will be spent and shedding load is the only +thing left that changes the bill. + +**No new option.** The ceiling is `peak_cap_w`, which already shipped. The +coverage threshold is `meter_max_age_s`, the freeze predicate is +`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device +rating — every one of them a key that already exists. ### The winding-loop alarm (the output clamp) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 066c7b4..5016450 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -33,17 +33,20 @@ from datetime import datetime, timezone import aiohttp from .arbiter import ( - P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_SAFETY, Claim, resolve, + P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve, ) from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk from .clamp import ( REASON_CODE as CLAMP_REASON, ClampResult, ClampWatchdog, clamp_to_rating, is_commandable, ) -from .freeze import FreezeWatchdog, FreezeWatchdogConfig, rail_w +from .freeze import ( + FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_w, +) from .hass import HomeAssistant from .maintenance import IDLE, MaintConfig, Maintenance from .mqtt import MqttPublisher +from . import peakguard from .p1 import P1Ingest, build_source, is_enabled from .static_tariff import static_status from .tariff import PeakStore, PeakTracker @@ -189,6 +192,22 @@ class Controller: cap_w=float(opts.get("peak_cap_w", 3500))) self.peak.restore(self.p1.averager) _LOG.info("capacity-tariff peak store: %s", self.peak.restore_state) + + # SAFETY-07. The guard TARIFF-01 was built for. It is stateless by + # design (peakguard.py holds no history between calls), so the only + # state here is what an EDGE needs: what the last cycle concluded, so + # the action log and the notification fire on transitions rather than + # once per cycle for a quarter of an hour. + # + # ⚠️ NO CONFIG OF ITS OWN, for the reason the clamp watchdog gives ~90 + # lines up and TARIFF-01 repeats: the ceiling is `peak_cap_w`, already + # shipped and already validated, and every other term is read off + # something the arbiter or the averager already owns. CFG-03 still + # tracks three places, not four. + self.peak_guard = None + self._peak_engaged = False + self._peak_saturated = False + self._maint_shape_suspended = False # Set by amain() once the transport is built, so the status page can show # what only the transport knows (homewizard_local's unchanged_s). Stays # None when P1 is off, or under a transport that has no such counter. @@ -424,6 +443,77 @@ class Controller: self.log_event("inputs missing - commanding 0 W") claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing")) + # --- SAFETY-07: the quarter-peak projection guard -------------------- + # ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument. + # What is decided HERE and nowhere else is what the guard is allowed to + # see and what it is allowed to ask for: + # + # ⚠️ `frozen` IS EVALUATED ON THIS CYCLE'S STANDING COMMAND, NOT ON + # `self.freeze_wd.active`. The watchdog object is advanced at the END of + # cycle() (check_meter_frozen), so reading its level here would grade + # this quarter against the PREVIOUS cycle's conjunction - one cycle of + # lag, in a guard whose entire job is to act inside a 900 s quarter. + # `standing` is the value SAFETY-02 already computed above for exactly + # this meaning: the command on the wire that this meter reading is a + # response to. The predicate itself is freeze.py's shipped + # `looks_frozen()`, not a copy of it - see loop_is_following() on what a + # transcribed safety predicate costs. + # + # ⚠️ `achievable_w` BOUNDS THE FLOOR SO THIS CLAIM CANNOT CONTRADICT THE + # SAFETY LIMITS ABOVE. `lo > hi` in the arbiter is a CONTRADICTION and + # resolves to 0 W - the battery parked, in the one minute of the month it + # should be discharging hardest. maintenance_charge_w had to learn the + # same lesson (config.yaml, validate.py rule 6); this claim is built so + # the config cannot express it. + # + # ⚠️ AND IT IS FILED EVERY CYCLE, unconditionally, like SAFETY-02's. A + # claim is not sticky. Filed once and skipped next cycle, the floor + # lapses and the actuator goes silently back to whatever was pushing the + # peak. Which of the higher-priority claims outranks it is arbiter.py's + # business, not this block's - and the answer is already right: a + # maintenance DRAIN or HOLD owns the setpoint at P_MAINTENANCE and + # commands discharge or zero, neither of which can create offtake, while + # a maintenance CHARGE is only ever a P_MAINT_SHAPE limit (40) and is + # therefore bound by this floor. That is §9.1's "money outranks the + # maintenance schedule", now as a consequence of the priorities. + # + # ⚠️ AND IT IS NOT RUN AT ALL WITHOUT P1. `meter_source: off` is the + # legacy single-entity path: there is no telegram stream, so + # `p1.averager` has no open block, so the guard would be BLIND on every + # cycle forever - and blind means "no charging". That is not a + # conservative default, it is a controller that silently stops charging + # on every install that has not opted in to P1 yet; test_clamp.py's + # export case caught it doing exactly that. The billed quantity simply + # is not measured on that path, and the honest report is NOT COVERED - + # the same answer, for the same reason, that the status page already + # gives for SAFETY-18 on a transport that cannot see `unchanged_s`. + dev_top = self.dev_max if self.dev_max is not None else max_w + self.peak_guard = None if not self.p1_enabled else peakguard.decide( + self.peak.project(self.p1.averager, now), + batt_w=self.batt, + cap_w=float(self.o.get("peak_cap_w", 3500)), + reading=self.peak.peak(now), + achievable_w=min(max_w, abs(float(dev_top))), + max_hold_s=self.p1.averager.max_hold_s, + frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None), + standing, max_w, self.freeze_wd.cfg), + ) + peak_claim = (peakguard.claim(self.peak_guard) + if self.peak_guard is not None else None) + if peak_claim is not None: + claims.append(peak_claim) + # ⚠️ EDGE-LOGGED, not level-logged. The action log holds 40 lines; a + # guard that appended one every 15 s for a whole quarter would push the + # cause of the event off the end of the log that is meant to explain it. + # SAFETY-03 logs every engagement because a clamp engagement IS the + # count that matters there; here the count is the seconds, and the + # engagement's own reason line already carries them. + engaged_now = self.peak_guard is not None and self.peak_guard.engaged + if engaged_now != self._peak_engaged: + self._peak_engaged = engaged_now + if self.peak_guard is not None: + self.log_event(self.peak_guard.explain()) + # --- maintenance ----------------------------------------------------- if result.owns_setpoint: claims.append(Claim.set("maintenance", P_MAINTENANCE, @@ -435,9 +525,44 @@ class Controller: # claimed, so the loop can discharge and shave the peak. When a # dedicated peak-shaving strategy arrives it will claim SET at # P_PEAK and outrank this limit without any code here changing. - if peak_at_risk(self.peak_fc, float(self.o.get("peak_cap_w", 3500))): - self.log_event("peak at risk - maintenance charge shaping suspended") + # ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A + # SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's claim + # is `lo >= 0`. Both are limits, arbiter.py applies EVERY limit at or + # above the winner's priority, and most restrictive wins - so a peak + # event during a maintenance charge window files `lo > hi`, which is + # a CONTRADICTION and resolves to 0 W. The battery would park in the + # exact quarter it should be shaving. Priority does not save it: + # clause 2 has no notion of a higher-priority limit overriding a + # lower one, and it should not - "most restrictive" is what makes the + # safety bounds unconditional. + # + # So the higher-priority claimant SUPPRESSES the lower one rather + # than out-voting it, which is what this branch already did for + # `peak_forecast_entity`. That entity is optional and empty on a + # default install, so until now the suspension could not fire on the + # site that needed it. `self.peak_guard` is measured from the meter + # and is always there. + # + # ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is not + # tidiness. It was inline, and a reviewer mutated it back to its + # historical broken form - re-gated on `peak_forecast_entity` alone - + # and every suite in this repository stayed green while a default + # install parked at 0 W in the quarter that sets the month's bill. + # Read shaping_suspended() before touching this: both terms are + # there, and why neither is sufficient alone. + if peakguard.shaping_suspended( + self.peak_guard, self.peak_fc, + float(self.o.get("peak_cap_w", 3500))): + # Edge-logged: level-logging here would write one line per cycle + # for as long as the event lasts and flush the 40-line action log + # of the very context that explains it. + if not self._maint_shape_suspended: + self._maint_shape_suspended = True + self.log_event("peak at risk - maintenance charge shaping " + "suspended so the peak claim is not " + "contradicted") else: + self._maint_shape_suspended = False hi = 0.0 reason = "charge-only" if self.cheap: @@ -552,6 +677,11 @@ class Controller: # is not, and test_freeze.py part 5 fails on exactly that. await self.check_meter_frozen() + # --- SAFETY-07's alarm, and it is NOT the engagement ------------------ + # Its boundary is unconstrained: it grades `self.peak_guard`, which this + # method assigned above and nothing since has touched. + await self.check_peak_saturated() + # --- TARIFF-01, and it is LAST on purpose ----------------------------- # Banking a quarter is bookkeeping about a moment that has already # happened; a slow SD card must never sit between the meter and the @@ -643,6 +773,59 @@ class Controller: f"for was itself out of range."), }) + async def check_peak_saturated(self) -> None: + """SAFETY-07. Alarm ONLY when the guard has run out of inverter. + + ⚠️ AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE + READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug signal + - clamp it, then alarm" is SAFETY-03's rule and it is right there: the + loop computing 14 768 W is a WINDING INTEGRATOR, a defect, and clipping + it silently is what let it keep climbing behind a dashboard full of legal + numbers. A peak floor above the inverter's rating is not a defect. It is + a house load bigger than the inverter - 2026-08-20 15:45 ran ~5900 W + against a 5000 W unit - and alarming on the ordinary case is how an + operator learns to dismiss the notification that matters. So an + engagement is an ordinary control action: it goes to the §15 action log + and the status page, and no further. + + What DOES earn the operator's attention is saturation: the guard asked + for more discharge than the inverter can give, so the quarter is going + over the ceiling anyway and the controller has already done everything + available to it. That is the one state where the money is going to be + spent and a human is the only remaining actuator. + + Edge-triggered, and de-asserting arms it again - the same shape as + FreezeWatchdog's `evaluate()`, and for the same reason: one notification + per episode, not one per cycle. + """ + g = self.peak_guard + saturated = bool(g is not None and g.saturated) + was, self._peak_saturated = self._peak_saturated, saturated + if not saturated or was: + return + self.log_event("PEAK GUARD SATURATED - " + g.reason) + _LOG.error( + "SAFETY-07 - %s. The guard is asking for everything the inverter " + "has and the quarter is still projected over the ceiling: this " + "quarter will set or raise the month's billed capacity peak. The " + "controller is STILL RUNNING and still shaving; there is nothing " + "further it can do. Shed load.", g.reason) + # A fixed notification_id, and a DIFFERENT one from every other alarm + # here - check_meter_frozen() gives the reasoning. A saturated peak guard + # is not a frozen meter and is not an out-of-range setpoint. + await self.hass.call("persistent_notification", "create", { + "notification_id": "goodwe_peak_saturated", + "title": "GoodWe: the capacity-peak guard has run out of inverter", + "message": ( + f"{g.reason}.\n\nThe battery is being commanded to everything " + f"it can give and the quarter is STILL projected over the " + f"{g.ceiling_w:.0f} W ceiling, so this quarter will set or raise " + f"the month's billed capaciteitstarief peak. This is not a " + f"controller fault - the load is simply larger than the " + f"inverter. Shedding load in the next few minutes is the only " + f"thing that changes the bill."), + }) + async def check_meter_frozen(self) -> None: """SAFETY-18. Alarm on a meter that answers forever without changing. @@ -918,6 +1101,40 @@ class Controller: f"{self.peak.cap_w:.0f} W soft limit" + f"; {self.peak.recorded} quarter(s) banked" + f"; on start: {self.peak.restore_state}"}) + + # SAFETY-07. Three rows, not one, because "idle" and "cannot see" + # are opposite states and a page that spelled them the same way + # would show a green line over a guard that is switched off. `None` + # is a fourth state on purpose: before the first cycle the guard has + # concluded nothing, and saying "idle" then would be a claim about a + # decision that has not been taken. + _pg = self.peak_guard + if _pg is None: + out.append({"ok": True, "warn": True, + "text": "peak guard: no decision yet - the first " + "control cycle has not run"}) + elif _pg.saturated: + out.append({"ok": False, "warn": False, + "text": "PEAK GUARD SATURATED - " + _pg.reason}) + elif _pg.blind: + out.append({"ok": False, "warn": True, "text": _pg.explain()}) + else: + out.append({"ok": not _pg.engaged, "warn": _pg.engaged, + "text": _pg.explain()}) + else: + # ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point. + # SAFETY-07 is NOT COVERED on the legacy single-entity path - the + # quarter-hour average is measured from P1 telegrams and this path + # has none - and a row that only rendered where the guard runs would + # leave the one install that is unprotected showing nothing at all. + # Same answer, same reason, as the freeze detector's NOT COVERED row + # a few lines up. + out.append({"ok": False, "warn": True, + "text": f"peak guard: NOT COVERED on meter_source " + f"{o.get('meter_source')} - the quarter-hour " + f"average is measured from P1 telegrams and this " + f"path has none, so a runaway cannot be capped"}) + rows = [("battery SoC", self.soc, o.get("soc_entity")), ("battery power", self.batt, o.get("batt_entity"))] if not self.p1_enabled: diff --git a/goodwe_controller/app/p1.py b/goodwe_controller/app/p1.py index c6ee380..6d23f34 100644 --- a/goodwe_controller/app/p1.py +++ b/goodwe_controller/app/p1.py @@ -286,6 +286,26 @@ class QuarterAverager: """Offtake watt-seconds accumulated in the open block so far.""" return self._acc + @property + def held_net_w(self) -> float: + """The signed net power currently being held forward, in W. + + PUBLIC BECAUSE SAFETY-07 DEPENDS ON IT, AND THAT IS THE WHOLE REASON. + `PeakTracker.project()` extrapolates the rest of the quarter from this + value and used to reach it as `getattr(averager, "_last_net", 0.0)`. A + default on a private name fails QUIET and fails in the DISARMING + direction: rename `_last_net` and every projection silently credits 0 W + held, which reads LOW, which puts the peak guard under the ceiling + exactly when the meter says it is over. Filed as TEST-13 item 4 and + fixed here rather than inherited - a money guard may not rest on a read + that can go wrong without a symptom. + + Positive is offtake, matching `P1Sample.net_w`. It is the raw held + value, NOT floored at zero: the flooring is the accumulator's rule (only + offtake is billed) and belongs where it is applied, not here. + """ + return self._last_net + @property def offtake_avg_w(self) -> float: """Average offtake over the part of the open block seen so far.""" diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py new file mode 100644 index 0000000..c8373f3 --- /dev/null +++ b/goodwe_controller/app/peakguard.py @@ -0,0 +1,517 @@ +"""SAFETY-07 - the quarter-peak projection guard, and the only thing in this +add-on that is allowed to spend the loop's freedom to save money. + +WHAT IT IS. TARIFF-01 built the INSTRUMENT: `tariff.py` measures the number the +Belgian capaciteitstarief bills on - the highest 15-minute average offtake of the +calendar month - and banks it where a restart cannot reach. It files no claim and +commands nothing, and says so in its own first paragraph. This module is the +CONSUMER it named. It turns that measurement into one arbiter claim and nothing +else: no writes, no I/O, no clock, no state between calls. Given a projection and +two measured numbers it returns a decision; `main.py` files it. + +------------------------------------------------------------------------------ +THE HAZARD, WITH THE EURO FIGURE + +specs.md:583 records a measured case: ONE 4998 W quarter moved a monthly peak +from 4832 W to 5013 W and billed the whole month at it. The capacity tariff has +no averaging and no forgiveness - the month's single worst quarter is the bill. + +SAFETY-18 shipped the ALARM for the way this system produces such a quarter by +itself: a meter that answers 200 OK forever with a document that never changes, +which every freshness mechanism in this stack reads as healthy while the loop +integrates a constant error to the rail and holds it there. freeze.py's own +table: at the live install's `max_w: 5000`, a meter frozen at >= 3000 W rails the +command in 75 s. SAFETY-18 notifies at `meter_freeze_s` (300 s default), against +a 900 s billing quarter. That is about nine minutes of human reaction time and +nothing else - freeze.py says so in the words "THIS CUT DOES NOT CAP THAT." + +This is the cap. It is deliberately NOT a second freeze detector: it consumes +freeze.py's shipped predicate rather than transcribing it. + +------------------------------------------------------------------------------ +THE MECHANISM: ONE `lo` LIMIT AT `P_PEAK`, AND WHY THAT SHAPE + +specs.md 10.1 row 2 asks for exactly two behaviours - "pre-emptive discharge as +the projected quarter average approaches the soft limit; hard clamp on charging +above it" - and 9.1 constrains the lever: "Discharge and export are never capped +by the peak guard; only charging and import are." + +A LOWER BOUND on the setpoint is both of those in one object, and it is the only +shape that is: + + * incapable of capping discharge - a floor never stops the loop discharging + HARDER, so 9.1's asymmetry is structural here rather than remembered; + * incapable of capping export, for the same reason; + * a hard clamp on charging whenever the floor is above the standing charge; + * pre-emptive discharge whenever the floor is above zero. + +It is a LIMIT, not a SET, and that matters at both ends of the priority table. +`P_PEAK` (50) is above `P_LOOP` (10), so it binds the grid-following loop. It is +BELOW `P_MAINTENANCE` (60) and `P_SAFETY` (100), so a maintenance phase that owns +the actuator and every layer-1 stop still outrank it: arbiter.py's clause 2 only +applies limits whose priority is >= the WINNING SET's, so a P_SAFETY 0 W stop is +not dragged off zero by this floor. That is the correct order - a controller +pushing power the wrong way is wrong however much money it is trying to save. + +⚠️ AND A LIMIT DOES NOT OUT-VOTE ANOTHER LIMIT. This was assumed, and it is +false: arbiter.py clause 2 applies EVERY limit at or above the winner's priority +and takes the MOST RESTRICTIVE, with no notion of priority between them - which +is exactly what makes the safety bounds unconditional and must not be changed. +So this floor (`lo >= 0`) and the maintenance charge-only shaping (`hi <= 0`) do +not resolve in P_PEAK's favour when both are filed. They resolve to `lo > hi`, a +CONTRADICTION, which arbiter.py answers with 0 W - the battery parked in the one +quarter of the month it should be shaving hardest. The rule for two limits that +can cross is therefore SUPPRESSION, not priority: main.py drops the maintenance +shaping for as long as this guard is engaged, which is the same thing that branch +already did for `peak_forecast_entity` and is why 9.1's "money outranks the +maintenance schedule" reads as a suppression there rather than as a priority +here. The floor's clamp to `achievable_w` closes the only other pair that could +cross - the P_SAFETY rating bounds. + +AND IT IS WHY OPT-04 NEEDS NO CHANGE HERE. 9.1 requires peak shaving to outrank +arbitrage. Arbitrage does not exist yet, but it will arrive as a SET below +P_PEAK, and arbiter.py clause 2 will bind it with this floor on the day it lands, +with no edit to this file. Expressing the priority as a claim rather than as an +if/else in main.py is the whole reason that holds. + +------------------------------------------------------------------------------ +THE FLOOR IS DERIVED, AND EVERY TERM OF IT IS MEASURED + +Let `ceiling_w` be the quarter average that must not be exceeded and `remaining` +the seconds left in the open block. `project()` gives + + projected = (acc + held * remaining) / 900 + +so the held level that would land the quarter exactly on the ceiling is +`held + 900 * (ceiling - projected) / remaining`, and the watts that must come +off the meter are the difference between the two: + + excess_w = 900 * (projected - ceiling) / remaining + +That single expression carries the whole control law, and two properties of it +are the argument for this design: + + 1. THE CLAIM IS FILED WHILE THE FLOOR IS POSITIVE, NOT WHILE THE EXCESS IS. + `excess` is what must come off the METER, so it falls to zero the moment the + battery is already taking it - and a guard that disengaged there would hand + the actuator back, watch the offtake spring straight to the raw load, and + engage again next cycle. That is not a hypothetical: it is what the first + cut of this file did to 2026-08-20's billed quarter on replay - seventeen + engage/release cycles in 900 s, and the quarter still finished 49 W over the + limit it had spent a quarter of an hour holding. 10.2 names the cost + exactly: "an EMS that oscillates is worse than no EMS, both for hardware + wear and - per 9.1 - for the peak it is trying to avoid." + + `batt_w + excess` IS the quantity to threshold: the offtake there would be + with the battery at zero, less the level this quarter can still afford. Read + as a LEVEL rather than as a switch, the same arithmetic gives a claim that + is filed for as long as a positive floor is needed and decays continuously + to nothing as the load falls. So there is still no hysteresis constant in + this file - not because oscillation was ruled out by argument, but because + the thresholded quantity is the one that does not oscillate. + + 2. THE GAIN IS 900/remaining, WHICH IS 1.0 AT THE START OF THE QUARTER AND + GROWS. This is the answer to the degeneracy at the top of a block, where the + projection is an extrapolation from almost no evidence: the guard is at its + GENTLEST exactly when its evidence is weakest, and becomes aggressive only + once most of the quarter is behind it and the projection is nearly a + measurement. The formula supplies that; no minimum-evidence threshold was + invented, because inventing one would delay the guard in the case it exists + for - a sustained rail from second zero. + +The floor itself is then + + floor_w = batt_w + excess_w + +`batt_w` IS THE MEASURED BATTERY POWER, NOT THE STANDING COMMAND. Both were +available. The measured one is right for two independent reasons. First, 9.1's +settled constraint is that the utility bills what FLOWED - the arithmetic +`grid = load - batt` is true of delivered power and false of intent. Second, and +concretely: when the inverter cannot follow (the saturation case control.py +already freezes the integrator for), a floor built on the command asks for +`command + excess` every cycle while the command never arrives, and winds itself +to the rail; a floor built on the measurement asks for `delivered + excess`, +which stops rising the moment delivery stops rising. The delivered form cannot +wind up. The intent form can. + +------------------------------------------------------------------------------ +THE CEILING IS `peak_cap_w`, RAISED ONLY BY AN OBSERVATION + +11.2's row is "Quarter peak | Projected quarter average approaches THE MONTHLY +PEAK | Clamp the command", and 9.1's soft limit is `peak_cap_w`. Both matter, +because the money is spent on `max(month's peak so far, this quarter)`: + + ceiling_w = max(peak_cap_w, the month's banked peak) + +Once a month's peak already stands at 5013 W, holding every later quarter to +3500 W buys nothing - the bill for that month is already 5013 W - while the +holding itself costs real charging the site wanted. Raising the ceiling to a peak +that has already been paid for is what stops one bad quarter from parking the +battery's charging for three weeks. + +AND THAT RELAXATION IS GRANTED ONLY BY AN `OBSERVED` READING. `NO_RECORD` and +`NOT_OBSERVED` both leave the ceiling at `peak_cap_w`, the tighter of the two, so +a peak store that cannot be read makes this guard STRICTER and never looser. That +direction is the whole point: tariff.py's `PeakReading` exists because a database +that did not answer must not become a number, and the number it would have become +here - a zero peak - is exactly the one that reads as "no peak recorded, full +freedom". It is also why this module does NOT consume `headroom_w()`, whose +disarming failure it would otherwise inherit: a peak read as 0 W returns FULL +headroom, and full headroom is a guard that is not there. + +------------------------------------------------------------------------------ +BLINDNESS COMES FIRST, AND IT IS A DIFFERENT ACTION + +A projection is evidence only if the quantity under it was actually measured. +Three ways it is not, all of them checked BEFORE the projection is graded, +because grading a stimulus where the quantity it grades is degenerate is how a +guard passes its own tests while being switched off: + + * NO OPEN BLOCK. `project()` answers a fresh averager with + `Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0)` - a ZERO projection, which + compares as comfortably under any ceiling. That is not "the quarter is fine", + it is "there is no quarter". Graded as a number it silently disarms the guard + on exactly the startup path where nothing else is watching. + + * COVERAGE. A meter gap longer than one `max_hold_s` window leaves seconds that + `QuarterAverager` deliberately does not accumulate (p1.py: holding a dead + meter's last value forward is how a fabricated peak gets onto a permanent + record). Those seconds make the projection read LOW, which is the + under-guarding direction. The threshold is `max_hold_s` itself - the point + p1.py already stops trusting a held sample - not a new number. + + * FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()` + rather than re-written here. A frozen meter is the worst case of all: the + samples keep ARRIVING, so coverage stays perfect and the projection is + confident - and if the stuck value is an export, the projection is a + confident ZERO while the real meter runs at the rail. This is the case + SAFETY-07 exists to backstop, and it is the case a projection cannot see. + +THE BLIND ACTION IS `floor_w = 0.0`: the battery may not IMPORT while the billed +quantity is unobservable. It is the minimal action that closes the hazard, and it +is one-directional - forbidding charging can only ever lower delivered offtake, +never raise it - so a false blindness costs a charge the site wanted and cannot +cost energy the pack did not want to give. Contrast the graded branch, which can +force discharge; blindness deliberately does not earn that, because there is by +definition no measurement to size it from. + +------------------------------------------------------------------------------ +WHY THIS ONE TRIPS WHERE SAFETY-18 ONLY NOTIFIES + +SAFETY-18 shipped notification-first, and config.yaml says why in one line: "A +false trip parks the battery; a false alarm costs an alert." That reasoning is +right for a DETECTOR whose negative case had to be established on a rig run. It +does not carry to this guard, and the two committed captures say so: + + * MEASURED FALSE-ENGAGEMENT RATE ON THE HEALTHY DAY: zero. 2026-08-23's worst + quarter is a few hundred watts against a 3500 W ceiling and nothing in its 95 + quarters comes near engaging. test_peakguard.py re-measures that every run + from the committed capture rather than quoting it here. + * THE ACTION IS LEVEL-TRIGGERED AND UNLATCHED. It is re-derived from a fresh + projection every cycle and releases the instant the projection drops back + under the ceiling. There is no state to clear and no operator to wake. A + false engagement costs one cycle of discharge, not a parked battery - which + is precisely the cost SAFETY-18 could not bound and this one can. + * THE ACTION IS A LIMIT, NOT A SET. Every safety stop in 11.2's table outranks + it, so the worst thing a bug in this file can do is fail to relax a floor - + and a floor is bounded above by `achievable_w`, which main.py takes from the + same `max_w` and device rating the arbiter already enforces. + +So it trips. It is also the only guard here whose trigger IS the billed quantity +itself, measured from parsed P1 telegrams, rather than a proxy for it. + +------------------------------------------------------------------------------ +SATURATION IS REPORTED, NOT ALARMED AS A BUG + +10.3's doctrine - "treat an out-of-range computed setpoint as a bug signal, clamp +it AND alarm" - is SAFETY-03's, and it is right there: a loop computing 14 768 W +is a winding integrator, i.e. a defect. A peak floor above the inverter's rating +is NOT a defect. It is the ordinary physics of a 5900 W house load on a 5000 W +inverter, which is literally what 2026-08-20 15:45 was. So the floor is clamped +to `achievable_w` and the decision carries `saturated`, and main.py raises ONE +notification on that - not because the controller is broken, but because it is +the one case where the money WILL be spent and the controller has already done +everything it can. An engagement that is not saturated is an ordinary control +action and goes to the action log, like the clamp's engagements do. + +------------------------------------------------------------------------------ +NO NEW OPTION. CFG-03 STILL TRACKS THREE PLACES. + +`peak_cap_w` already ships, is already validated `int(500,15000)`, and is already +read by `control.peak_at_risk()`. `max_hold_s` comes off the averager the caller +already owns; `achievable_w` off `max_w` and the device rating the arbiter +already enforces; the freeze predicate off `meter_freeze_s`. config.yaml and +validate.py are untouched by this ticket, exactly as TARIFF-01 left them, and +14's `capacity_peak_limit_kw` stays declined - a kW key beside a W key is the +mismatch trap 14's own warning box describes. + +------------------------------------------------------------------------------ +⚠️ WHAT THIS GUARD DOES NOT HAVE: AN ENERGY BACKSTOP. SEE SAFETY-24. + +The graded branch can FORCE DISCHARGE, and nothing in this file or in the loop +below it consults the state of charge - `soc_floor` belongs to the maintenance +cycle, and freeze.py already records that for the grid-following loop "the BMS +is the sole energy backstop". On the 2026-08-20 replay the guard asks for 878 Wh +in a day. Against a pack sitting near 9 % that is not nothing, and the guard will +keep asking as long as the meter says the quarter is over the ceiling. + +That is deliberate for THIS card and it is filed as SAFETY-24. It is also the +reason this must not go to a client install before SAFETY-24 lands: on the +reference site the BMS is a known quantity and an operator is present, and on a +client site neither is true. + +------------------------------------------------------------------------------ +AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE + +The sum/max/net reduction is an open product question with the owner. TARIFF-01 +put it behind `billed_w()` and defaulted to NET. Everything this module consumes +- `PeakReading.peak_w`, `Projection.projected_w` - is already reduced by that one +function, so whichever way the owner answers, this file does not change and does +not need re-testing. Nothing here indexes a phase. +""" + +from dataclasses import dataclass + +from .arbiter import P_PEAK, Claim +from .control import peak_at_risk +from .tariff import QUARTER_S + +# Why the projection may not be graded. Reasons, not booleans: the status page +# and the action log both have to say WHICH, and "blind" alone is unactionable. +BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet" +BLIND_COVERAGE = "meter gap" +BLIND_FROZEN = "SAFETY-18: the meter is answering without changing" + +# Where the ceiling came from. Only the second is a relaxation, and only an +# OBSERVED reading may produce it. +CEILING_CAP = "peak_cap_w" +CEILING_MONTH_PEAK = "the month's banked peak" + +SOURCE = "peak" + + +@dataclass(frozen=True) +class PeakDecision: + """What the guard concluded this cycle, with everything it concluded it from. + + Every field is derived from a measurement taken in the same cycle. There is + no `floor_w_or(default)` and there must not be one, for `PeakReading`'s + reason: an accessor that turns "not measured" into a number re-creates the + defect the type exists to remove. + """ + + engaged: bool + floor_w: float | None # the `lo` the claim carries; None = no claim + ceiling_w: float + ceiling_from: str + blind: str | None = None + projected_w: float | None = None + excess_w: float = 0.0 + wanted_w: float | None = None # the floor before `achievable_w` clamped it + saturated: bool = False + reason: str = "" + + def explain(self) -> str: + if not self.engaged: + if self.projected_w is None: + return f"peak guard idle - {self.reason}" + return (f"peak guard idle - quarter projects " + f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W " + f"ceiling ({self.ceiling_from})") + return self.reason + + +def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]: + """The average this quarter may not exceed, and where the number came from. + + ONLY an OBSERVED reading may raise it. `NO_RECORD` (the store answered, this + month has nothing yet) and `NOT_OBSERVED` (the store did not answer) both + return the cap, which is the tighter answer. A failed read therefore makes + the guard stricter, never looser - see the module note. + """ + cap = float(cap_w) + if (reading is not None and getattr(reading, "is_observed", False) + and reading.peak_w is not None and float(reading.peak_w) > cap): + return float(reading.peak_w), CEILING_MONTH_PEAK + return cap, CEILING_CAP + + +def excess_w(projection, ceiling_w) -> float: + """Watts that must come off the meter NOW to land the quarter on the ceiling. + + The whole control law, and the only place it is written down: + + excess = 900 * (projected - ceiling) / remaining + + Positive means the quarter is projected over; zero or negative means it is + not. `remaining <= 0` is not a large number, it is an undefined one - a + quarter with no seconds left cannot be influenced - so it answers 0.0 rather + than dividing. + """ + remaining = QUARTER_S - float(projection.elapsed_s) + if remaining <= 0.0: + return 0.0 + return QUARTER_S * (float(projection.projected_w) - float(ceiling_w)) / remaining + + +def uncovered_s(projection) -> float: + """Seconds of the open block the meter did not actually cover. + + Derived from what `Projection` already carries rather than re-measured: + `covered_frac` is `elapsed / elapsed_wall`, and `elapsed_s` IS that wall. + """ + return max(0.0, float(projection.elapsed_s) + * (1.0 - float(projection.covered_frac))) + + +def blindness(projection, max_hold_s, frozen: bool) -> str | None: + """Why this projection may not be graded, or None if it may be. + + Evaluated BEFORE the projection is compared to anything. Each of the three is + a way for the projected number to be confident and wrong, and the first - a + fresh averager's all-zero `Projection` - is confident, wrong AND under every + ceiling there is. + """ + if frozen: + return BLIND_FROZEN + if projection.block_start is None: + return BLIND_NO_BLOCK + gap = uncovered_s(projection) + if max_hold_s is not None and gap > float(max_hold_s): + return (f"{BLIND_COVERAGE}: {gap:.0f} s of this quarter uncovered, past " + f"the {float(max_hold_s):.0f} s the averager will hold a sample") + return None + + +def decide(projection, *, batt_w, cap_w, reading, achievable_w, + max_hold_s=None, frozen: bool = False) -> PeakDecision: + """The whole guard. Pure: no clock, no I/O, no state carried between calls. + + `batt_w` MEASURED battery power, positive = discharge. None when the + sensor for it is missing - and then there is nothing to size a + floor from, so no floor is invented. + `achievable_w` the largest discharge the arbiter could actually grant + (`min(max_w, device max)`). The floor is clamped to it so this + claim can NEVER produce `lo > hi` in the resolver - a + contradiction there resolves to 0 W, which would park the + battery in the exact minute it should be discharging hardest. + """ + ceiling, from_where = quarter_ceiling_w(cap_w, reading) + ceil_top = abs(float(achievable_w)) + + blind = blindness(projection, max_hold_s, frozen) + if blind is not None: + # 0.0, not a sized floor. There is no trustworthy measurement to size one + # from; the claim that remains is "do not add to the offtake you cannot + # see", which is one-directional and cannot cost pack energy. + return PeakDecision( + engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where, + blind=blind, projected_w=None, + reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import " + f"while the billed quarter cannot be measured; commanding no " + f"charging (floor 0 W)")) + + projected = float(projection.projected_w) + if batt_w is None: + # main.py's P_SAFETY stop already owns a cycle with a missing input, at + # 0 W. Filing nothing is the honest answer: a floor sized from a + # measurement that is not there is a made-up number. + return PeakDecision( + engaged=False, floor_w=None, ceiling_w=ceiling, + ceiling_from=from_where, projected_w=projected, + reason="no battery measurement to size a floor from") + if QUARTER_S - float(projection.elapsed_s) <= 0.0: + # A block with no seconds left cannot be influenced. This is degenerate + # rather than urgent - `excess_w` answers 0.0 there and a floor equal to + # the battery's own current output would be a claim that says nothing. + return PeakDecision( + engaged=False, floor_w=None, ceiling_w=ceiling, + ceiling_from=from_where, projected_w=projected, + reason="the quarter has no seconds left to influence") + + ex = excess_w(projection, ceiling) + wanted = float(batt_w) + ex + if wanted <= 0.0: + # ⚠️ THE TEST IS ON THE FLOOR, NOT ON `excess > 0`, AND THE REPLAY IS WHY. + # `excess` is the watts that must come off the METER, so it goes to zero + # as soon as the battery is already removing them - and a guard that + # disengaged there handed the actuator straight back, the offtake sprang + # back to the raw load, and the next cycle engaged again. On 2026-08-20's + # billed quarter that bang-bang cycled 17 times in 900 s and left the + # quarter 49 W over the limit it had just spent a quarter of an hour + # holding. §10.2: "an EMS that oscillates is worse than no EMS, both for + # hardware wear and - per §9.1 - for the peak it is trying to avoid." + # + # `batt_w + excess` is the offtake that would exist with the battery at + # zero, minus the level the quarter can afford. Testing THAT is the same + # arithmetic read as a level rather than as a switch: the claim is filed + # for as long as a positive floor is needed and decays continuously to + # nothing as the load falls, so there is still no hysteresis constant - + # the quantity being thresholded is simply the right one. + return PeakDecision( + engaged=False, floor_w=None, ceiling_w=ceiling, + ceiling_from=from_where, projected_w=projected, excess_w=ex, + wanted_w=wanted, + reason="the load is under the level this quarter can afford") + + floor = min(wanted, ceil_top) + saturated = floor < wanted + return PeakDecision( + engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where, + blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted, + saturated=saturated, + reason=( + f"PEAK GUARD: quarter projects {projected:.0f} W against a " + f"{ceiling:.0f} W ceiling ({from_where}) with " + f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must " + f"come off the meter; battery measured {float(batt_w):+.0f} W, " + f"floor {floor:+.0f} W" + + (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can " + f"only give {ceil_top:.0f} W" if saturated else ""))) + + +def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool: + """Must the maintenance charge-only shaping stand down this cycle? + + THE ONE PLACE THIS CONJUNCTION IS WRITTEN, and it is public for the reason + `watchdog.loop_is_following()` and `freeze.looks_frozen()` are: the check + that grades it has to assert on THE SHIPPED EXPRESSION. A safety predicate + transcribed into its own test is one copy plus a future disagreement about + what the check means, and the copy that drifts is always the grading one. + It was inline in `main.py` and nothing could reach it; a reviewer mutated it + back to its historical broken form and all twelve suites stayed green. + + ⚠️ WHY SUPPRESSION AND NOT PRIORITY. The shaping is a LIMIT (`hi <= 0`) and + the peak guard's claim is a LIMIT (`lo >= 0`). arbiter.py clause 2 applies + EVERY limit at or above the winner's priority and takes the MOST RESTRICTIVE, + with no notion of one limit outranking another - which is precisely what + makes the safety bounds unconditional and must not be changed. Two crossing + limits are therefore a CONTRADICTION, which resolves to 0 W: the battery + PARKED, in the one quarter of the month that sets the bill. P_PEAK 50 > + P_MAINT_SHAPE 40 does not save it and never could. + + ⚠️ AND THE FIRST TERM IS NOT ENOUGH ON ITS OWN. `peak_forecast_entity` is + OPTIONAL and is empty on a default install, so `peak_forecast_w` is None + there and `peak_at_risk()` answers False forever. Before this card the + suspension therefore could not fire on the very site that needed it. The + second term is the MEASURED guard, which is always present wherever P1 is. + Deleting either term - or re-gating the whole thing on the forecast - parks + a default install at 0 W, silently, in the billed quarter. test_peakguard.py + part 7 drives the real cycle with each of those mutations in place. + """ + if peak_at_risk(peak_forecast_w, float(cap_w)): + return True + return decision is not None and decision.engaged + + +def claim(decision: PeakDecision) -> Claim | None: + """The decision as one arbiter claim, or None when the guard is idle. + + A LIMIT with only `lo` set. `hi` stays `+inf`, which is 9.1's "discharge and + export are never capped by the peak guard" expressed as a fact about the + object rather than as a rule somebody has to remember. + """ + if not decision.engaged or decision.floor_w is None: + return None + return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w), + reason=("blind" if decision.blind else "quarter peak")) diff --git a/goodwe_controller/app/tariff.py b/goodwe_controller/app/tariff.py index 7893da5..59d4a78 100644 --- a/goodwe_controller/app/tariff.py +++ b/goodwe_controller/app/tariff.py @@ -522,6 +522,7 @@ class Projection: covered_frac: float complete: bool block_start: datetime | None + held_w: float def explain(self) -> str: return (f"quarter projects {self.projected_w:.0f} W " @@ -657,19 +658,38 @@ class PeakTracker: """ start = averager.block_start if start is None: - return Projection(0.0, 0.0, 0.0, 0.0, False, None) + # ⚠️ A ZERO PROJECTION, AND IT IS NOT "THE QUARTER IS FINE". It is + # "there is no quarter". `block_start is None` is the flag that says + # which, and SAFETY-07 grades that flag BEFORE it grades the number - + # see peakguard.blindness(). A consumer that reads only + # `projected_w` here reads 0 W, which is under every ceiling there + # is, on exactly the startup path where nothing else is watching. + return Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0) elapsed_wall = max(0.0, (now - start).total_seconds()) elapsed = averager.elapsed_s acc = averager.partial_ws remaining = max(0.0, QUARTER_S - elapsed_wall) - held = max(getattr(averager, "_last_net", 0.0), 0.0) + # ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read + # was `getattr(averager, "_last_net", 0.0)`: a private name with a + # fallback, so renaming the attribute would have kept every projection + # green while crediting 0 W held - a projection that reads LOW, which is + # the UNDER-GUARDING direction for SAFETY-07's floor. TEST-13 item 4. + # `held_net_w` is now a documented public property on QuarterAverager and + # is read without a default, so the same rename raises AttributeError on + # the first cycle instead of quietly disarming the money guard. + held = max(averager.held_net_w, 0.0) return Projection( projected_w=(acc + held * remaining) / QUARTER_S, elapsed_w=(acc / elapsed) if elapsed > 0 else 0.0, elapsed_s=elapsed_wall, covered_frac=(elapsed / elapsed_wall) if elapsed_wall > 0 else 0.0, complete=elapsed_wall <= 0 or elapsed >= elapsed_wall - 1e-9, - block_start=start) + block_start=start, + # Carried so a consumer can see WHAT was extrapolated, not only the + # extrapolation. It is the signed held value; `projected_w` uses the + # offtake-only form above, and the two differ whenever the site is + # exporting - which is the healthy operating point here. + held_w=averager.held_net_w) @dataclass(frozen=True) diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py new file mode 100644 index 0000000..793e2ba --- /dev/null +++ b/goodwe_controller/test_peakguard.py @@ -0,0 +1,1272 @@ +"""Runnable check for SAFETY-07, the quarter-peak projection guard. + + python3 test_peakguard.py + +Eight parts. What each is for, and - where it matters - what it is deliberately +NOT allowed to be tested at: + + 1. THE CONTROL LAW, `excess_w`, graded AWAY from the two points where it is + degenerate. At the start of a quarter the 900/remaining gain is exactly 1.0 + and `excess` collapses onto the plain difference `projected - ceiling`; a + check placed only there cannot tell the shipped law from that difference. + So part 1 first shows them coinciding (documenting the trap), then separates + them, then shows the two properties the whole design rests on: the law is + ZERO at the engagement boundary (so the claim is continuous and needs no + hysteresis constant) and its gain GROWS through the quarter (so the guard is + gentlest where its evidence is weakest). + 2. THE CLAIM, through the REAL `arbiter.resolve()`. That it binds the loop and + the maintenance charge shaping, that it does NOT bind a maintenance phase + that owns the actuator or any safety stop, that it can never cap discharge + or export, and - the load-bearing one - that it can never produce the + `lo > hi` contradiction the arbiter resolves to 0 W. + 3. THE CEILING, AND A FAILED READ. Only an `OBSERVED` reading may raise it. + `NO_RECORD` and `NOT_OBSERVED` both leave it at the cap, so an unreadable + store makes the guard STRICTER. Graded against a REAL `PeakStore` that + genuinely cannot open, not a stub - and the acceptance half is that the + guard still ENGAGES with that store, because "fail open on an unreadable + peak" is the one bug that would leave every other check in this file green. + 4. BLINDNESS, all three kinds, each one evaluated exactly where the number it + replaces is degenerate. A fresh averager projects 0 W, which is under every + ceiling there is; a frozen meter projects confidently; a meter gap projects + LOW. None of the three may be graded as a number. + 5. `project()`'s DEFAULTED READ - TEST-13 item 4, fixed here because this card + is the consumer that would have inherited it. The old form failed quiet AND + in the disarming direction; part 5 computes what it would have concluded on + the same fixture the shipped form engages on. + 6. THE REPLAY. 2026-08-20's 5029.8 W billed quarter, closed-loop, and the + healthy day's baseline. Every figure re-derived from the committed capture. + 7. THE WIRING, through the real `Controller.cycle()` and its real status page. + 8. MUTATION. Six one-line defeats of this guard, each run against the checks + above to show they are not vacuous. + +------------------------------------------------------------------------------ +WHAT THE REPLAY IS AND IS NOT + +The captures carry ONE series: the signed net power at the meter +(`{"t": iso8601, "w": signed_watts}`). They do not carry battery power, so the +replay cannot reconstruct how much of 2026-08-20's offtake was the house and how +much was an inverter. The model is therefore stated rather than hidden: + + * the captured `w` is treated as the load presented to the meter, and + * the guard's battery is modelled as an ADDITIONAL discharge subtracted from + it, delivered on the next telegram (a one-sample actuation delay), and + * when the guard is idle the modelled battery delivers 0 W. + +That last line is deliberately PESSIMISTIC and it is what makes the number mean +something. The real grid-following loop would itself have been discharging on +2026-08-20 - it was not, because that is the day the vendor controller failed - +so crediting the guard with zero baseline discharge isolates SAFETY-07's own +contribution instead of flattering it with the loop's. + +Two days is also not thirty. AC 3's 95 %-over-30-days criterion is TARIFF-01's +and is still not met; `test_tariff.py`'s part 9 says so at length and this file +does not re-litigate it. What part 6 reports is the comparison that two days CAN +support: the same quarters, with and without the guard. +""" + +import asyncio +import ast +import gzip +import json +import re +import shutil +import sys +import tempfile +from datetime import datetime, timedelta, timezone +from math import inf, isinf +from pathlib import Path + +from app import peakguard +from app.arbiter import ( + P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve, +) +from app.control import peak_at_risk +from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w +from app.main import Controller +from app.p1 import ( + PLAUSIBLE_MAX_W, P1Ingest, QUARTER_S, QuarterAverager, make_sample, + split_signed, +) +from app.peakguard import ( + BLIND_COVERAGE, BLIND_FROZEN, BLIND_NO_BLOCK, CEILING_CAP, + CEILING_MONTH_PEAK, PeakDecision, blindness, decide, excess_w, + quarter_ceiling_w, uncovered_s, +) +from app.tariff import ( + NOT_OBSERVED, NO_RECORD, OBSERVED, PeakReading, PeakStore, PeakTracker, + month_key, +) + +fails = [] +HERE = Path(__file__).resolve().parent +TMP = Path(tempfile.mkdtemp(prefix="goodwe-peakguard-")) + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +def near(a, b, tol=1e-6): + return a is not None and b is not None and abs(a - b) <= tol + + +def sample(t: datetime, net_w: float, phases=1, phase_net=None): + """One P1 telegram through the SHIPPED derivation - test_tariff.py's helper, + for its reason: the sign convention under test must be the one that ships.""" + imp, exp = split_signed(net_w) + pi = pe = None + if phase_net is not None: + pairs = [split_signed(v) for v in phase_net] + pi = [a for a, _ in pairs] + pe = [b for _, b in pairs] + return make_sample("ha_signed", imp, exp, phases=phases, + phase_import_w=pi, phase_export_w=pe, + ingest_ts=t, ingest_mono=t.timestamp()) + + +def fresh_store(name: str) -> PeakStore: + return PeakStore(str(TMP / f"{name}.db")) + + +def read_option(key: str): + """The shipped default for `key`, out of config.yaml. + + ⚠️ `peak_cap_w:` appears TWICE - once under `options:`, once under `schema:`. + test_tariff.py's part 9 documents the trap; this takes the FIRST match, which + is the options block, i.e. the number an install actually lands on. + """ + for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines(): + if line.strip().startswith(f"{key}:"): + try: + return float(line.split(":", 1)[1].strip()) + except ValueError: + continue + return None + + +def read_schema_max(key: str): + """The schema's upper bound for `key` - `int(lo,hi)` -> hi.""" + body = (HERE / "config.yaml").read_text(encoding="utf-8") + marker = body.index("\nschema:") + for line in body[marker:].splitlines(): + st = line.strip() + if st.startswith(f"{key}:") and "(" in st and ")" in st: + return float(st[st.index("(") + 1:st.rindex(")")].split(",")[1]) + return None + + +# Everything numeric below is read out of the shipped file, not typed here. +CAP_W = read_option("peak_cap_w") +MAX_HOLD_S = read_option("meter_max_age_s") +FREEZE_S = read_option("meter_freeze_s") +CYCLE_S = read_option("cycle_floor_s") +# The live install runs max_w 5000 (NOTES.md, freeze.py). That is also the schema +# ceiling and the GW5000S-BP's rating, which is why the two agree - so the +# device figure is taken from the schema rather than typed. +LIVE_MAX_W = read_schema_max("max_w") + +print("SAFETY-07 - the quarter-peak projection guard") +print(f" read from config.yaml: peak_cap_w {CAP_W:.0f} W, " + f"meter_max_age_s {MAX_HOLD_S:.0f} s, meter_freeze_s {FREEZE_S:.0f} s, " + f"cycle_floor_s {CYCLE_S:.0f} s, max_w schema ceiling {LIVE_MAX_W:.0f} W") +check("every threshold this check grades against came out of config.yaml", + None not in (CAP_W, MAX_HOLD_S, FREEZE_S, CYCLE_S, LIVE_MAX_W)) + +B = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc) # a quarter boundary + + +def averager_at(net_w, seconds, *, start=B, step=5.0, gap_at=None, gap_s=0.0): + """A real `QuarterAverager` driven `seconds` into the block at `net_w`. + + Optionally with a real hole in the telegram stream, which is how a coverage + shortfall is produced here: manufactured by NOT sending telegrams, exactly as + a dead meter produces it, rather than by writing to a private field. + """ + a = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) + t = 0.0 + while t <= seconds: + if gap_at is not None and gap_at <= t < gap_at + gap_s: + t += step + continue + a.add(sample(start + timedelta(seconds=t), net_w)) + t += step + return a + + +# ============================================================================== +print() +print("1. the control law, and the point where it is indistinguishable") + +# THE DEGENERACY, NAMED FIRST. At elapsed 0 the gain 900/remaining is exactly +# 1.0, so `excess_w` and the naive difference `projected - ceiling` are the same +# number. Anything graded only here separates neither. +CEIL = CAP_W +top = averager_at(5000.0, 0.0) +p_top = PeakTracker.project(top, B) +e_top = excess_w(p_top, CEIL) +naive_top = p_top.projected_w - CEIL +print(f" at the top of the quarter: projected {p_top.projected_w:.0f} W, " + f"excess {e_top:.1f} W, naive difference {naive_top:.1f} W") +check("at elapsed 0 the shipped law and the plain difference coincide - so a " + "check placed only there proves nothing about either", + near(e_top, naive_top, 1e-6)) + +# Away from it they must separate, and the direction is the design: the later +# the guard engages, the harder it has to pull to recover the same average. +for el in (300.0, 600.0, 800.0): + a = averager_at(5000.0, el) + p = PeakTracker.project(a, B + timedelta(seconds=el)) + e = excess_w(p, CEIL) + gain = QUARTER_S / (QUARTER_S - p.elapsed_s) + print(f" at {el:.0f} s elapsed: projected {p.projected_w:.0f} W, " + f"excess {e:.0f} W, gain {gain:.2f}x") + check(f"at {el:.0f} s the law is the difference times {gain:.2f} - the " + f"900/remaining gain, not a constant offset", + near(e, (p.projected_w - CEIL) * gain, 1e-6) and e > (p.projected_w - CEIL)) + +a300 = averager_at(5000.0, 300.0) +a800 = averager_at(5000.0, 800.0) +e300 = excess_w(PeakTracker.project(a300, B + timedelta(seconds=300)), CEIL) +e800 = excess_w(PeakTracker.project(a800, B + timedelta(seconds=800)), CEIL) +check("...and it grows monotonically through the quarter, so the guard is at " + "its GENTLEST when its projection rests on the least evidence", + e800 > e300 > 0.0) + +# CONTINUITY AT THE BOUNDARY. This is what stands in for §10.2's hysteresis. +BATT_NOW = 400.0 +a_edge = averager_at(CEIL, 450.0) +p_edge = PeakTracker.project(a_edge, B + timedelta(seconds=450)) +d_edge = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S) +e_edge = excess_w(p_edge, CEIL) +print(f" held exactly AT the ceiling for 450 s: projected " + f"{p_edge.projected_w:.2f} W, excess {e_edge:.3f} W, " + f"floor {'(none)' if d_edge.floor_w is None else f'{d_edge.floor_w:.1f} W'}, " + f"battery measured {BATT_NOW:.0f} W") +check("at the engagement boundary the excess is zero, so the floor is exactly " + "the battery's own measured power - i.e. 'carry on'. The law is CONTINUOUS " + "and there is no step for a hysteresis constant to damp", + near(e_edge, 0.0, 1e-6) and near(d_edge.floor_w, BATT_NOW, 1e-6)) +# THE OSCILLATION, and it is the reason the threshold is on the floor rather +# than on the excess. Re-derived here rather than described: a guard that +# released as soon as `excess <= 0` hands the actuator back at the exact instant +# it is working, and the load springs straight back. +released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S) +would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0 +print(f" at the boundary: excess {e_edge:.3f} W (an `excess > 0` test would " + f"RELEASE: {would_release_on_excess}), floor {released.floor_w:.0f} W " + f"(the shipped `floor > 0` test holds: {released.engaged})") +check("...and the claim is held there, not released. `excess` falls to zero as " + "soon as the battery is already taking the watts, so thresholding IT is " + "the bang-bang §10.2 forbids; thresholding the FLOOR is the same " + "arithmetic read as a level", would_release_on_excess and released.engaged) +d_gone = decide(PeakTracker.project(averager_at(500.0, 450.0), + B + timedelta(seconds=450)), + batt_w=BATT_NOW, cap_w=CAP_W, reading=None, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S) +check("...and it DOES release once the load falls under what the quarter can " + "afford, so 'threshold the floor' is not 'never let go'", + not d_gone.engaged and d_gone.floor_w is None) + +# A hair over, and the floor is the battery plus the excess. Not a constant. +a_over = averager_at(CEIL + 200.0, 450.0) +p_over = PeakTracker.project(a_over, B + timedelta(seconds=450)) +d_over = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=None, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S) +print(f" 200 W over for 450 s: {d_over.explain()}") +check("a quarter projected over the ceiling engages, and the floor is the " + "MEASURED battery power plus the derived excess", + d_over.engaged + and near(d_over.floor_w, BATT_NOW + excess_w(p_over, CEIL), 1e-6)) +check("...and the floor moves with the battery measurement, so it is a claim " + "about delivered power rather than a fixed setpoint", + not near(decide(p_over, batt_w=BATT_NOW + 250.0, cap_w=CAP_W, reading=None, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S).floor_w, + d_over.floor_w, 1.0)) + +# THE WIND-UP ARGUMENT, made concrete. A floor built on INTENT re-adds its own +# unmet ask every cycle; one built on delivered power does not. +stuck_batt = 0.0 # the inverter is not following +intent = 0.0 +delivered_floor = [] +intent_floor = [] +for _ in range(5): + d_del = decide(p_over, batt_w=stuck_batt, cap_w=CAP_W, reading=None, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S) + delivered_floor.append(d_del.floor_w) + intent = intent + excess_w(p_over, CEIL) # what the intent form would do + intent_floor.append(intent) +print(f" five cycles with the inverter not following: delivered-power floor " + f"{[f'{v:.0f}' for v in delivered_floor]}, intent floor " + f"{[f'{v:.0f}' for v in intent_floor]}") +check("a floor sized from DELIVERED battery power is stationary while the " + "inverter fails to follow; one sized from the standing command winds up", + len(set(f"{v:.3f}" for v in delivered_floor)) == 1 + and intent_floor[-1] > intent_floor[0] * 4) + + +# ============================================================================== +print() +print("2. the claim, through the real arbiter") + +SAFE = [Claim.limit("device", P_SAFETY, -LIVE_MAX_W, LIVE_MAX_W, "rating"), + Claim.limit("supervised", P_SAFETY, -LIVE_MAX_W, LIVE_MAX_W, "max_w")] +pc = peakguard.claim(d_over) +print(f" claim: {pc.source} {pc.kind} lo={pc.lo:.0f} hi={pc.hi} " + f"priority={pc.priority}") +check("it is a LIMIT at P_PEAK, not a SET - so no safety stop can be dragged " + "off zero by it", pc.kind == "limit" and pc.priority == P_PEAK) +check("...with only a LOWER bound. `hi` is +inf, which is §9.1's 'discharge and " + "export are never capped by the peak guard' as a property of the object " + "rather than a rule to remember", isinf(pc.hi) and pc.hi > 0) + +# It binds the loop. +LOOP_ASK = -1500.0 # the loop wants to CHARGE at 1500 W +r = resolve(SAFE + [Claim.set("loop", P_LOOP, LOOP_ASK, "grid"), pc]) +print(f" loop asks {LOOP_ASK:+.0f} W (charging): {r.explain()}") +check("the floor binds the grid-following loop and turns a charge into the " + "commanded discharge", near(r.target_w, pc.lo, 1e-9) and r.target_w > 0) +check("...and the arbiter names the peak claim as what bound it, so an operator " + "reading one line knows why the battery is discharging", + r.bound_by is not None and "peak" in r.bound_by) + +# TWO LIMITS THAT CROSS. This is the defect this check was written to assume +# away and instead found: a LIMIT does not out-vote another LIMIT. Clause 2 takes +# the MOST RESTRICTIVE of every limit at or above the winner's priority, with no +# priority between them - which is what makes the safety bounds unconditional. +r2 = resolve(SAFE + [Claim.set("loop", P_LOOP, LOOP_ASK, "grid"), + Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, + reason="charge-only"), pc]) +print(f" peak floor AND maintenance charge-only shaping both filed: " + f"{r2.explain()}") +check("filing both is a CONTRADICTION, not a P_PEAK win - `lo > hi` resolves to " + "0 W, i.e. the battery parked in the one quarter it should be shaving. " + "P_PEAK 50 > P_MAINT_SHAPE 40 does NOT save it", + r2.contradiction and near(r2.target_w, 0.0)) +check("...so the rule for two limits that can cross is SUPPRESSION, and " + "removing the shaping gives the floor, which is what main.py now does", + near(resolve(SAFE + [Claim.set("loop", P_LOOP, LOOP_ASK, "grid"), pc] + ).target_w, pc.lo, 1e-9)) + +# It does NOT bind a maintenance phase that owns the actuator, nor any stop. +r3 = resolve(SAFE + [Claim.set("maintenance", P_MAINTENANCE, 0.0, "hold"), pc]) +r4 = resolve(SAFE + [Claim.set("safety", P_SAFETY, 0.0, "stopped"), pc]) +print(f" maintenance HOLD owns the setpoint: {r3.explain()} | " + f"control stopped: {r4.explain()}") +check("a maintenance phase that OWNS the actuator (P_MAINTENANCE 60) is not " + "dragged off its own setpoint by the peak floor", near(r3.target_w, 0.0)) +check("...and neither is any P_SAFETY stop - a controller pushing power the " + "wrong way is wrong however much money it is saving", + near(r4.target_w, 0.0)) +check("...and neither resolution is a contradiction, which would ALSO have " + "produced 0.0 and would have meant something entirely different", + not r3.contradiction and not r4.contradiction) + +# Discharge is never capped: the loop asking for MORE discharge than the floor +# must be left alone. +BIG = pc.lo + 900.0 +r5 = resolve(SAFE + [Claim.set("loop", P_LOOP, BIG, "grid"), pc]) +check(f"a loop already discharging harder ({BIG:.0f} W) than the floor is not " + f"pulled back to it - a floor cannot cap discharge", + near(r5.target_w, BIG, 1e-9)) + +# THE CONTRADICTION, which is the one that would park the battery. +absurd = averager_at(LIVE_MAX_W * 4, 870.0) +p_absurd = PeakTracker.project(absurd, B + timedelta(seconds=870)) +DEV_MAX = LIVE_MAX_W - 500.0 # a device rating tighter than max_w +d_absurd = decide(p_absurd, batt_w=1000.0, cap_w=CAP_W, reading=None, + achievable_w=min(LIVE_MAX_W, DEV_MAX), max_hold_s=MAX_HOLD_S) +r6 = resolve([Claim.limit("device", P_SAFETY, -DEV_MAX, DEV_MAX, "rating"), + Claim.limit("supervised", P_SAFETY, -LIVE_MAX_W, LIVE_MAX_W, "max_w"), + Claim.set("loop", P_LOOP, -200.0, "grid"), + peakguard.claim(d_absurd)]) +print(f" an absurd projection ({p_absurd.projected_w:.0f} W with 30 s left) " + f"wanted {d_absurd.wanted_w:.0f} W, floor clamped to " + f"{d_absurd.floor_w:.0f} W; {r6.explain()}") +check("the floor is clamped to what the arbiter could actually grant, so the " + "claim can NEVER produce lo > hi - a contradiction resolves to 0 W, i.e. " + "the battery parked in the one minute of the month it should be pulling " + "hardest", not r6.contradiction and near(r6.target_w, DEV_MAX, 1e-9)) +check("...and the decision SAYS it saturated rather than reporting the clamped " + "number as if it were the answer", + d_absurd.saturated and d_absurd.wanted_w > d_absurd.floor_w) +check("...while the un-clamped case does not claim saturation", + not d_over.saturated) + + +# ============================================================================== +print() +print("3. the ceiling, and what an unreadable peak store does to it") + +obs_hi = PeakReading(OBSERVED, "2026-08", peak_w=CAP_W + 1513.0, + quarter_start=B) +obs_lo = PeakReading(OBSERVED, "2026-08", peak_w=CAP_W - 800.0, + quarter_start=B) +norec = PeakReading(NO_RECORD, "2026-08") +unread = PeakReading(NOT_OBSERVED, "2026-08", detail="disk gone") +zero = PeakReading(OBSERVED, "2026-08", peak_w=0.0, quarter_start=B) + +for name, rd in (("OBSERVED above the cap", obs_hi), + ("OBSERVED below the cap", obs_lo), + ("NO_RECORD", norec), ("NOT_OBSERVED", unread), + ("OBSERVED at 0 W", zero)): + c_w, c_from = quarter_ceiling_w(CAP_W, rd) + print(f" {name:<24} -> ceiling {c_w:.0f} W ({c_from})") + +check("a month whose peak ALREADY stands above the cap raises the ceiling to " + "it - the bill for that month is already that number, and holding every " + "later quarter to the cap buys nothing while costing real charging", + quarter_ceiling_w(CAP_W, obs_hi) == (CAP_W + 1513.0, CEILING_MONTH_PEAK)) +check("a month peak UNDER the cap does not lower the ceiling below the cap", + quarter_ceiling_w(CAP_W, obs_lo) == (CAP_W, CEILING_CAP)) +check("NO_RECORD leaves the ceiling at the cap", + quarter_ceiling_w(CAP_W, norec) == (CAP_W, CEILING_CAP)) +check("...and so does NOT_OBSERVED: a store that did not answer makes this " + "guard STRICTER, never looser. That direction is the whole point - the " + "number a failed read would have become is a zero peak, and a zero peak " + "reads as 'quiet month, full freedom'", + quarter_ceiling_w(CAP_W, unread)[0] <= quarter_ceiling_w(CAP_W, norec)[0] + and quarter_ceiling_w(CAP_W, unread) == (CAP_W, CEILING_CAP)) +# ⚠️ GRADED ON THE STATE, NOT ON `peak_w is not None`. Today a NOT_OBSERVED +# reading always carries `peak_w=None`, so a `peak_w is not None` test happens to +# behave - and dropping `is_observed` from quarter_ceiling_w survives every other +# check in this file. That coincidence is exactly the thing not to depend on: a +# future PeakReading that carried a LAST-KNOWN peak alongside NOT_OBSERVED - the +# obvious next feature - would silently raise the ceiling off a read that failed. +# So the fixture is that reading, built here, and the rule graded is the state. +stale = PeakReading(NOT_OBSERVED, "2026-08", peak_w=CAP_W + 1500.0, + quarter_start=B, detail="disk gone, last known peak kept") +print(f" NOT_OBSERVED CARRYING a {stale.peak_w:.0f} W last-known peak " + f"-> ceiling {quarter_ceiling_w(CAP_W, stale)[0]:.0f} W " + f"({quarter_ceiling_w(CAP_W, stale)[1]})") +check("a NOT_OBSERVED reading that CARRIES a peak still does not raise the " + "ceiling - the gate is the reading's STATE, not whether a number happens " + "to be attached to it. A failed read is not an observation", + quarter_ceiling_w(CAP_W, stale) == (CAP_W, CEILING_CAP)) +check("...and the relaxation is granted ONLY by an observation: the three " + "non-observations agree, and only the OBSERVED-above-cap case differs", + len({quarter_ceiling_w(CAP_W, r)[0] + for r in (norec, unread, zero)}) == 1 + and quarter_ceiling_w(CAP_W, obs_hi)[0] > CAP_W) + +# THE ACCEPTANCE HALF, and it is graded on a REAL store that cannot open. +broken = PeakStore(str(TMP / "no-such-volume" / "tariff.db")) +rd_broken = broken.peak(B) +tr_broken = PeakTracker(broken, cap_w=CAP_W) +print(f" a real PeakStore on a missing volume: state={rd_broken.state}, " + f"headroom_w()={tr_broken.headroom_w(B)}") +check("the real store answers NOT_OBSERVED rather than a peak of 0 W", + rd_broken.state == NOT_OBSERVED and rd_broken.peak_w is None) +d_broken = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=rd_broken, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S) +print(f" {d_broken.explain()}") +check("AND THE GUARD STILL ENGAGES ON IT. An unreadable peak must not disarm " + "the cap - 'no peak recorded' is precisely the reading that would, and it " + "is the one bug that would leave every other check in this file green", + d_broken.engaged and near(d_broken.floor_w, d_over.floor_w, 1e-9)) +check("...and it does so at the CAP ceiling, not at some number derived from " + "the store that could not answer", + d_broken.ceiling_w == CAP_W and d_broken.ceiling_from == CEILING_CAP) +check("`headroom_w()` is None on that same store, which is why this module does " + "not consume it: a consumer that defaulted that None to the cap would " + "have full headroom and no guard", tr_broken.headroom_w(B) is None) +src = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8") +_calls = {n.func.attr for n in ast.walk(ast.parse(src)) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)} +check("...and peakguard.py calls no headroom accessor at all - graded on the " + "parsed call syntax, so deleting the paragraph above would not satisfy it", + "headroom_w" not in _calls and "warning_frac" not in _calls) + + +# ============================================================================== +print() +print("4. blindness: three ways a projection is not evidence") + +# (a) NO OPEN BLOCK. A fresh averager projects 0 W - under every ceiling. +fresh = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) +p_fresh = PeakTracker.project(fresh, B) +d_fresh = decide(p_fresh, batt_w=0.0, cap_w=CAP_W, reading=None, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S) +print(f" fresh averager: projected {p_fresh.projected_w:.0f} W, " + f"block_start={p_fresh.block_start}; {d_fresh.explain()}") +check("a fresh averager's projection is 0 W, i.e. comfortably under any " + "ceiling - the degenerate value, present and quiet", + near(p_fresh.projected_w, 0.0) and p_fresh.projected_w < CAP_W) +check("...and the guard grades block_start BEFORE the number, so it reports " + "BLIND rather than 'the quarter is fine'", + d_fresh.blind == BLIND_NO_BLOCK and d_fresh.projected_w is None) +check("...and the blind action is a floor of 0 W - no charging while the " + "billed quantity cannot be measured - which is one-directional: " + "forbidding import can only lower delivered offtake, never raise it", + d_fresh.engaged and near(d_fresh.floor_w, 0.0)) +r_blind = resolve(SAFE + [Claim.set("loop", P_LOOP, -2500.0, "grid"), + peakguard.claim(d_fresh)]) +print(f" a loop asking to charge 2500 W while blind: {r_blind.explain()}") +check("...and through the real arbiter that stops the charge dead at 0 W " + "without forcing any discharge", near(r_blind.target_w, 0.0) + and not r_blind.contradiction) + +# (b) FROZEN - the case SAFETY-07 exists to backstop, and the worst of the three: +# coverage is PERFECT and the projection is confident and wrong. +frozen_av = averager_at(-4000.0, 600.0) # meter stuck reading 4 kW of EXPORT +p_frozen = PeakTracker.project(frozen_av, B + timedelta(seconds=600)) +RAILED = LIVE_MAX_W # the loop has wound to the rail +is_frozen = looks_frozen(FREEZE_S + 1.0, -RAILED, LIVE_MAX_W, + FreezeWatchdogConfig(hold_s=FREEZE_S)) +d_frozen = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, + frozen=is_frozen) +print(f" meter stuck at -4000 W with the command at {-RAILED:+.0f} W: " + f"projected {p_frozen.projected_w:.0f} W, coverage " + f"{p_frozen.covered_frac * 100:.0f} %, looks_frozen={is_frozen}") +print(f" {d_frozen.explain()}") +check("a frozen meter reading export projects a CONFIDENT ZERO at full " + "coverage - the projection cannot see this failure at all", + near(p_frozen.projected_w, 0.0) and p_frozen.covered_frac >= 1.0) +check("...so blindness is decided by freeze.py's SHIPPED predicate, not by the " + "projection, and not by a second copy of the conjunction here", is_frozen) +check("...and the guard goes blind and forbids the import that is railing the " + "meter", d_frozen.blind == BLIND_FROZEN and near(d_frozen.floor_w, 0.0)) +r_fz = resolve(SAFE + [Claim.set("loop", P_LOOP, -RAILED, "grid"), + peakguard.claim(d_frozen)]) +print(f" the railed loop, arbitrated: {r_fz.explain()}") +check(f"THE BACKSTOP: SAFETY-18's runaway - a {-RAILED:+.0f} W grid charge held " + f"for a quarter - is cut to 0 W. freeze.py's table puts the rail at 75 s; " + f"the alarm alone arrives at {FREEZE_S:.0f} s of a {QUARTER_S} s billing " + f"quarter and stops nothing", near(r_fz.target_w, 0.0)) +check("...and the same command NOT frozen is left alone, so the blind branch is " + "not simply always on", + near(resolve(SAFE + [Claim.set("loop", P_LOOP, -RAILED, "grid"), + peakguard.claim(decide( + p_frozen, batt_w=-RAILED, cap_w=CAP_W, + reading=None, achievable_w=LIVE_MAX_W, + max_hold_s=MAX_HOLD_S, frozen=False)) + or Claim.limit("noop", P_PEAK)]).target_w, + -RAILED, 1e-9)) + +# (c) COVERAGE. A real hole in the telegram stream, longer than max_hold_s. +GAP = MAX_HOLD_S * 3 +gapped = averager_at(4200.0, 600.0, gap_at=200.0, gap_s=GAP) +p_gap = PeakTracker.project(gapped, B + timedelta(seconds=600)) +clean = averager_at(4200.0, 600.0) +p_clean = PeakTracker.project(clean, B + timedelta(seconds=600)) +d_gap = decide(p_gap, batt_w=0.0, cap_w=CAP_W, reading=None, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S) +print(f" a {GAP:.0f} s hole at 4200 W: uncovered {uncovered_s(p_gap):.0f} s, " + f"projected {p_gap.projected_w:.0f} W vs {p_clean.projected_w:.0f} W with " + f"no hole") +check("a gap makes the projection read LOW - the under-guarding direction, " + "which is why it may not simply be graded as a number", + p_gap.projected_w < p_clean.projected_w - 1.0) +check("...and `uncovered_s` is derived from what Projection already carries", + near(uncovered_s(p_gap), GAP - MAX_HOLD_S, MAX_HOLD_S)) +check("...and the guard goes blind on it, at the threshold p1.py already uses " + f"to stop trusting a held sample ({MAX_HOLD_S:.0f} s) rather than at a " + f"new number", d_gap.blind is not None + and d_gap.blind.startswith(BLIND_COVERAGE)) +small = averager_at(4200.0, 600.0, gap_at=200.0, gap_s=MAX_HOLD_S / 2) +check("...while a hole SHORTER than one hold window is not blindness - the " + "averager covered it, so there is nothing unmeasured", + blindness(PeakTracker.project(small, B + timedelta(seconds=600)), + MAX_HOLD_S, False) is None) + +check("blindness is decided before the projection is compared to anything: the " + "frozen case would otherwise have read 0 W and idled, and the fresh case " + "would have read 0 W and idled", + d_fresh.engaged and d_frozen.engaged + and d_fresh.projected_w is None and d_frozen.projected_w is None) + + +# ============================================================================== +print() +print("5. project()'s defaulted read - TEST-13 item 4, fixed here") + +HELD = 4800.0 +held_av = averager_at(HELD, 300.0) +p_held = PeakTracker.project(held_av, B + timedelta(seconds=300)) +check("QuarterAverager exposes the held value as a documented public property", + hasattr(held_av, "held_net_w") and near(held_av.held_net_w, HELD)) +check("...and Projection carries it, so a consumer can see WHAT was " + "extrapolated and not only the extrapolation", near(p_held.held_w, HELD)) + + +class _Renamed: + """An averager whose held value has been renamed - the TEST-13 scenario.""" + + block_start = B + elapsed_s = 300.0 + partial_ws = HELD * 300.0 + max_hold_s = MAX_HOLD_S + + +raised = None +try: + PeakTracker.project(_Renamed(), B + timedelta(seconds=300)) +except AttributeError as err: + raised = err +print(f" project() against a renamed averager: " + f"{'AttributeError: ' + str(raised) if raised else 'NO ERROR - silent'}") +check("a renamed held value now raises AttributeError on the first cycle. The " + "shipped read was `getattr(averager, '_last_net', 0.0)`: a private name " + "with a fallback, which fails QUIET", raised is not None) + +# ...and the direction of the quiet failure is the reason it mattered here. +OLD = (p_held.partial_ws if hasattr(p_held, "partial_ws") + else HELD * 300.0) # acc, which the old form would still have had +old_projected = (HELD * 300.0 + 0.0 * (QUARTER_S - 300.0)) / QUARTER_S +d_true = decide(p_held, batt_w=0.0, cap_w=CAP_W, reading=None, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S) +print(f" same fixture: shipped projection {p_held.projected_w:.0f} W " + f"(engaged={d_true.engaged}), the defaulted form's " + f"{old_projected:.0f} W (would engage={old_projected > CAP_W})") +check("...and it fails in the DISARMING direction: crediting 0 W held reads " + f"{p_held.projected_w - old_projected:.0f} W LOW on this fixture, which " + f"is the difference between a guard that engages and one that does not", + p_held.projected_w > CAP_W >= old_projected and d_true.engaged) + + +# ============================================================================== +print() +print("6. the replay: what the guard does to the two days that exist") + +REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz")) +check("the committed replay fixtures are present", len(REPLAY) >= 2) + + +def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W): + """Drive one capture through the real ingest, tracker and guard. + + See the module docstring for the model and why its baseline is pessimistic. + """ + ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S) + store = fresh_store(f"{path.stem}-{'g' if guarded else 'u'}") + tracker = PeakTracker(store, cap_w=cap_w) + batt = 0.0 # delivered discharge, W (positive) + next_cycle = None + graded_engaged = blind_engaged = saturated = cycles = 0 + wh = 0.0 + prev_t = None + for line in gzip.open(path, "rt", encoding="utf-8"): + row = json.loads(line) + t = datetime.fromisoformat(row["t"]) + if prev_t is not None: + wh += batt * (t - prev_t).total_seconds() / 3600.0 + prev_t = t + ing.submit(sample(t, float(row["w"]) - batt)) + tracker.sync(ing) + if next_cycle is not None and t < next_cycle: + continue + next_cycle = t + timedelta(seconds=CYCLE_S) + cycles += 1 + d = decide(PeakTracker.project(ing.averager, t), + batt_w=batt, cap_w=cap_w, reading=tracker.peak(t), + achievable_w=max_w, max_hold_s=MAX_HOLD_S, frozen=False) + if d.engaged: + (globals(), None) + if d.blind: + blind_engaged += 1 + else: + graded_engaged += 1 + saturated += 1 if d.saturated else 0 + if not guarded: + continue + batt = max(0.0, d.floor_w) if (d.engaged and d.floor_w is not None) else 0.0 + month = month_key(datetime.fromisoformat(json.loads( + gzip.open(path, "rt", encoding="utf-8").readline())["t"])) + return { + "blocks": dict(store.blocks_in(month)), "cycles": cycles, + "graded": graded_engaged, "blind": blind_engaged, + "saturated": saturated, "wh": wh, "month": month, + } + + +totals = {"u_q": 0, "u_under": 0, "g_q": 0, "g_under": 0} +worst_start = None +for path in REPLAY: + u = replay(path, guarded=False) + g = replay(path, guarded=True) + uq, gq = u["blocks"], g["blocks"] + u_peak_start = max(uq, key=lambda k: uq[k]) + u_under = sum(1 for v in uq.values() if v <= CAP_W) + g_under = sum(1 for v in gq.values() if v <= CAP_W) + totals["u_q"] += len(uq); totals["u_under"] += u_under + totals["g_q"] += len(gq); totals["g_under"] += g_under + print(f" {path.name}: {len(uq)} quarters, " + f"uncontrolled peak {max(uq.values()):.1f} W -> " + f"guarded peak {max(gq.values()):.1f} W; " + f"under {CAP_W:.0f} W: {u_under}/{len(uq)} -> {g_under}/{len(gq)}") + print(f" guard cycles {g['cycles']}, engaged on the measurement " + f"{g['graded']}, blind {g['blind']}, saturated {g['saturated']}; " + f"modelled discharge {g['wh']:.0f} Wh") + check(f"{path.name}: the guard never RAISES a quarter - it caps charging " + f"and forces discharge, and neither can add offtake", + all(gq.get(k, 0.0) <= v + 1e-6 for k, v in uq.items())) + check(f"{path.name}: no quarter that was under the limit is pushed over it", + g_under >= u_under) + if max(uq.values()) > CAP_W: + worst_start = u_peak_start + WORST_U = uq[u_peak_start] + WORST_G = gq[u_peak_start] + WORST_DAY = path.name + G_SAT = g["saturated"] + +check("one of the two days contains a quarter over the soft limit, or there is " + "nothing here to grade", worst_start is not None) +print() +print(f" THE BILLED QUARTER. {WORST_DAY}, block starting " + f"{worst_start.isoformat()}:") +print(f" uncontrolled {WORST_U:.1f} W -> guarded {WORST_G:.1f} W " + f"({WORST_U - WORST_G:.1f} W removed, " + f"{100.0 * (WORST_U - WORST_G) / WORST_U:.1f} %)") +check(f"the guard brings 2026-08-20's billed quarter under the " + f"{CAP_W:.0f} W soft limit", WORST_G <= CAP_W) +check("...and it does it without ever asking for more than the inverter has - " + "no saturation notification would have fired on this day", + G_SAT == 0) +check(f"...and that quarter really was the one specs.md:583's argument is " + f"about - a billed quarter over the live install's own " + f"{LIVE_MAX_W:.0f} W rating", WORST_U > LIVE_MAX_W) + +U_PCT = 100.0 * totals["u_under"] / totals["u_q"] +G_PCT = 100.0 * totals["g_under"] / totals["g_q"] +print() +print(f" BASELINE {totals['u_under']}/{totals['u_q']} = {U_PCT:.1f} % of " + f"quarters under {CAP_W:.0f} W, uncontrolled") +print(f" GUARDED {totals['g_under']}/{totals['g_q']} = {G_PCT:.1f} %") +print(f" Two days, not thirty. AC 3 is TARIFF-01's and is still not met - " + f"see test_tariff.py part 9. What this compares is the same quarters with " + f"and without the guard, which is what two days can support.") +check("the guard improves on the uncontrolled baseline rather than merely " + "matching it", G_PCT > U_PCT) +check("...and it does not degrade a single quarter anywhere in the two days", + totals["g_under"] >= totals["u_under"]) + +healthy = [p for p in REPLAY if "2026-08-23" in p.name] +if healthy: + h = replay(healthy[0], guarded=True) + print(f" HEALTHY DAY FALSE-ENGAGEMENT RATE: {h['graded']} engagements on " + f"the measurement and {h['blind']} blind, over {h['cycles']} guard " + f"cycles ({h['wh']:.1f} Wh of modelled discharge)") + check("zero engagements on the healthy reference day - measured, not " + "asserted from principle. This is the number that decides trip vs " + "notify: SAFETY-18 shipped notification-first because a false trip " + "parks the battery, and this guard's false-trip rate on the only " + "healthy day that exists is 0", + h["graded"] == 0 and h["blind"] == 0 and h["wh"] == 0.0) + + +# ============================================================================== +print() +print("7. the wiring: the real Controller, the real cycle, the real status page") + + +class _Store: + # ⚠️ PER INSTANCE. `data` was a CLASS attribute, which is fine while + # nothing writes it and a trap the moment part 7 forces a maintenance phase + # on one controller: every controller built afterwards would inherit it. + def __init__(self, data=None): + self.data = dict(data or {}) + + def set(self, key, value): + self.data[key] = value + + def get_time(self, *a): + return None + + def set_time(self, *a): + pass + + +class _Pub: + enabled = False + + def publish(self, *a, **k): + pass + + def close(self): + pass + + +class _Hass: + def __init__(self): + self.writes = [] + self.notifications = [] + + async def limits(self, entity): + return (-LIVE_MAX_W, LIVE_MAX_W, 1.0) + + async def number(self, entity, invert=False): + return None + + async def set_number(self, entity, value): + self.writes.append(value) + return True + + async def call(self, domain, service, data): + self.notifications.append(data.get("notification_id")) + return True + + +def built(db_path, opts=None, store=None): + hass = _Hass() + c = Controller(dict( + {"setpoint_entity": "number.sp", "meter_source": "ha_signed", + "p1_net_entity": "sensor.net", "meter_phases": 1, + "max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 1000, "deadband_w": 15, + "target_grid_w": 0, "step_w": 10, "heartbeat_s": 10, + "stale_input_s": 15}, **(opts or {})), + hass, store or _Store(), _Pub(), stop=asyncio.Event(), + peak_db_path=str(db_path)) + c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W + return c, hass + + +c, hass = built(TMP / "wired.db") +check(f"a Controller built with peak_cap_w ABSENT takes the shipped " + f"{CAP_W:.0f} W - config.yaml and main.py's fallback cannot drift apart", + c.peak.cap_w == CAP_W) +check("...and before the first cycle the guard has NO decision, which the " + "status page must not spell as 'idle'", c.peak_guard is None) +pre = [r for r in c.checks() if "peak guard" in r["text"]] +print(" status row before the first cycle: " + + (pre[0]["text"] if pre else "(none)")) +check("the status page says so in its own row", + len(pre) == 1 and "no decision yet" in pre[0]["text"]) + +# ⚠️ THE LEGACY PATH, AND IT IS A REGRESSION THIS CARD ACTUALLY CAUSED. +# `meter_source: off` is the single-entity path an install starts on. There is no +# telegram stream, so `p1.averager` never opens a block, so a guard that ran +# there would be BLIND on every cycle forever - and blind means NO CHARGING. The +# first cut of this card did exactly that, silently, on every install that has +# not opted in to P1; test_clamp.py's export case is what caught it. +c_off, hass_off = built(TMP / "legacy.db", + {"meter_source": "off", "meter_entity": "sensor.grid"}) +c_off.auto, c_off.soc, c_off.batt, c_off.grid = True, 55.0, 0.0, -6000.0 +asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c_off.cycle()) +off_rows = [r for r in c_off.checks() if "peak guard" in r["text"]] +print(f" meter_source off: guard={c_off.peak_guard}, wrote " + f"{hass_off.writes[-1]:+.0f} W") +print(" status row: " + (off_rows[0]["text"] if off_rows else "(none)")) +check("with no P1 the guard does not run at all - it files nothing, and the " + "legacy path can still charge", c_off.peak_guard is None + and hass_off.writes and hass_off.writes[-1] < 0) +check("...and the status page says NOT COVERED rather than 'idle' or a green " + "row, which is the same answer SAFETY-18 already gives on a transport " + "that cannot see it", len(off_rows) == 1 + and "NOT COVERED" in off_rows[0]["text"] and off_rows[0]["warn"]) + +# ⚠️ THE FIXTURE MUST LAND IN THE CURRENT QUARTER, AND THAT IS NOT PEDANTRY. +# `cycle()` reads the real clock, and `project()` measures the open block against +# it. Telegrams stamped at a fixed date make `elapsed_wall` the AGE OF THE FIXTURE +# - tens of thousands of uncovered seconds - and the guard correctly answers +# "meter gap". Which is a true answer to a question this part is not asking: the +# first version of part 7 graded its own fixture and not the wiring. +# +# So the telegrams are laid from the START OF THE QUARTER THAT IS OPEN NOW up to +# now. There is one narrow race left - the quarter rolling between the last +# telegram and `cycle()`'s own `datetime.now()`, which leaves the averager on a +# block with no seconds remaining - so the whole setup is retried on a fresh +# Controller rather than left to flake. It is bounded at three attempts: a fourth +# would mean something is wrong that is not a boundary. +def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0, + standing_w=None): + """A real Controller driven one real cycle over a real over-limit quarter. + + ⚠️ THE LOAD IS DERIVED FROM WHERE IN THE QUARTER THE WALL CLOCK LANDS, and + it has to be. `excess = 900 × (projected − ceiling) ÷ remaining`, so a fixed + 5200 W fixture asks for 1700 W with ten minutes left and 300 kW with five + seconds left - the second of which saturates, and a check that saturated or + not depending on the minute it ran would be a coin toss wearing a verdict. + So the caller names the FLOOR it wants and the load that produces it is + computed backwards from `remaining` at the moment of the run. + """ + for attempt in range(4): + ctl, hs = built(db if attempt == 0 else Path(f"{db}.{attempt}"), opts, + store=_Store(store) if store is not None else None) + ctl.auto, ctl.soc, ctl.batt = True, 55.0, batt_w + ctl.dev_min, ctl.dev_max = -LIVE_MAX_W, LIVE_MAX_W + if standing_w is not None: + # The command STANDING ON THE WIRE, which is what B3 below needs to + # differ from the measured battery power. + ctl.target = ctl.last_write = standing_w + now = datetime.now(timezone.utc) + blk = datetime.fromtimestamp( + int(now.timestamp() // QUARTER_S) * QUARTER_S, timezone.utc) + span = int((now - blk).total_seconds()) + remaining = max(1.0, QUARTER_S - (now - blk).total_seconds()) + net_w = CAP_W + want_floor_w * remaining / QUARTER_S + for i in range(0, span + 1, 5): + ctl.p1.submit(sample(blk + timedelta(seconds=i), net_w)) + # ⚠️ ASSIGNED, not read back from `p1.net_w`. This helper stamps + # `ingest_mono` with an epoch second so the averager (which keys on + # `ingest_ts`) replays correctly, while `net_w` keys on a MONOTONIC age + # that the same stamp makes meaningless - so `net_w` is None here and the + # cycle would take the "inputs missing" early return before it ever + # reached the guard. + ctl.grid = net_w + asyncio.get_event_loop_policy().new_event_loop().run_until_complete( + ctl.cycle()) + g = ctl.peak_guard + if g is not None and g.engaged and not g.blind: + return ctl, hs, net_w, remaining + # Only a quarter boundary crossed between the last telegram and + # `cycle()`'s own clock gets here. Bounded at four: a fifth would mean + # something is wrong that is not a boundary. + return ctl, hs, net_w, remaining + + +# 30 % of what the inverter can give: comfortably engaged, comfortably short of +# saturation, at any point in any quarter. +c, hass, NET_W, REMAIN = wired_over_limit(TMP / "wired2.db", LIVE_MAX_W * 0.3) +print(f" after one real cycle over a {NET_W:.0f} W quarter " + f"({REMAIN:.0f} s left in it): {c.reason}") +print(f" {c.peak_guard.explain()}") +check("the real cycle files the peak claim and the real arbiter binds the real " + "loop with it", c.peak_guard is not None and c.peak_guard.engaged + and not c.peak_guard.saturated and "peak" in (c.reason or "")) +check("...and the number written to the inverter is the guard's floor", + hass.writes and near(hass.writes[-1], c.peak_guard.floor_w, 1.5)) +rows = [r for r in c.checks() if "peak guard" in r["text"].lower()] +print(" status row: " + (rows[0]["text"] if rows else "(none)")) +check("the status page carries exactly one peak-guard row, flagged", + len(rows) == 1 and rows[0]["warn"]) +check("an engagement that is not saturated raises NO notification - §10.3's " + "'clamp AND alarm' is SAFETY-03's rule about a BUG SIGNAL, and a house " + "load bigger than the inverter is not a bug", + "goodwe_peak_saturated" not in hass.notifications) +check("...but it does reach the action log, which is where a control action " + "that moves energy belongs", + any("PEAK GUARD" in e for e in c.events)) +n_events = sum(1 for e in c.events if "PEAK GUARD" in e) +asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c.cycle()) +check("...and it is edge-logged, not level-logged: a second engaged cycle adds " + "no second line, so a quarter of engagement cannot push the cause of the " + "event off a 40-line log", + sum(1 for e in c.events if "PEAK GUARD" in e) == n_events) + +# The saturated case, through the same real cycle. +# A floor of 1.5x what the inverter can give: it saturates wherever in the +# quarter the clock happens to be, and the load it implies stays inside +# p1.PLAUSIBLE_MAX_W - a fixture the ingest itself rejects would grade nothing. +c2, hass2, NET2, _ = wired_over_limit(TMP / "wired3.db", LIVE_MAX_W * 1.5) +check(f"the saturating fixture ({NET2:.0f} W) is a load the real ingest " + f"accepts, not one it rejects as implausible", NET2 < PLAUSIBLE_MAX_W) +print(f" saturated case: {c2.peak_guard.explain()}") +check("a load larger than the inverter saturates the guard", c2.peak_guard.saturated) +check("...and THAT raises exactly one notification, with its own id - the one " + "state where the money will be spent and a human is the only remaining " + "actuator", hass2.notifications.count("goodwe_peak_saturated") == 1) +asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c2.cycle()) +check("...once per episode, not once per cycle", + hass2.notifications.count("goodwe_peak_saturated") == 1) +check("...and the command is still bounded by the rating, so a saturated guard " + "never asks the hardware for more than it has", + abs(hass2.writes[-1]) <= LIVE_MAX_W + 1e-6) + +# ============================================================================== +# THE WIRING'S OWN MUTATIONS. Everything above this line grades `peakguard.py`, +# and a reviewer showed that is not the same thing as grading the guard: THREE +# one-line changes to main.py's CALL SITE left all twelve suites green while a +# default install parked at 0 W in the quarter that sets the month's bill. +# +# So these three are executed, not described. Each mutation is put in place on +# the SHIPPED module and the REAL `Controller.cycle()` is driven with it - which +# also proves main.py routes through the thing being mutated rather than +# inlining it, because a monkeypatch of an inlined expression would do nothing +# and these checks would pass vacuously. +print() +print(" the wiring's own mutations - a default install, maintenance charging") + +# A DEFAULT INSTALL: `peak_forecast_entity` is absent, so `peak_fc` is None and +# `peak_at_risk()` answers False forever. That is the whole point - it is the +# configuration on which the old suspension could not fire. +MAINT_CHARGING = {"phase": "charge"} +_mainsrc = (HERE / "app" / "main.py").read_text(encoding="utf-8") + + +def maint_cycle(tag, want_floor_w=LIVE_MAX_W * 0.3, **kw): + """One real cycle with maintenance in CHARGE and no forecast entity.""" + return wired_over_limit(TMP / tag, want_floor_w, store=MAINT_CHARGING, **kw) + + +base, base_hass, BASE_NET, BASE_REM = maint_cycle("mut-base.db") +res0 = base.maint.tick(datetime.now(timezone.utc), base.soc) +print(f" maintenance phase {res0.phase!r}: owns_setpoint={res0.owns_setpoint}, " + f"charge_only={res0.charge_only}; peak_forecast_entity absent so " + f"peak_fc={base.peak_fc}") +check("the fixture really is the dangerous one: maintenance in its CHARGE phase " + "(a P_MAINT_SHAPE `hi <= 0` limit) on an install with no " + "peak_forecast_entity, and the guard genuinely engaged on the measurement", + res0.charge_only and not res0.owns_setpoint and base.peak_fc is None + and base.peak_guard is not None and base.peak_guard.engaged + and base.peak_guard.blind is None) +print(f" SHIPPED: suspended=" + f"{peakguard.shaping_suspended(base.peak_guard, base.peak_fc, CAP_W)}, " + f"{base.reason}, target {base.target:+.1f} W") +check("SHIPPED: the shaping stands down, the arbiter does not contradict, and " + "the battery discharges into the peak", + peakguard.shaping_suspended(base.peak_guard, base.peak_fc, CAP_W) + and "CONTRADICTION" not in base.reason + and near(base.target, base.peak_guard.floor_w, 1.5) + and base.target > 0.0) + +# ---- B1: the suppression removed altogether -------------------------------- +_shipped_pred = peakguard.shaping_suspended +try: + peakguard.shaping_suspended = lambda d, fc, cap: False + b1, b1_hass, _, _ = maint_cycle("mut-b1.db") +finally: + peakguard.shaping_suspended = _shipped_pred +print(f" B1 (suppression removed): {b1.reason}, target {b1.target:+.1f} W") +check("B1: with the suppression gone the two LIMITS cross - `lo >= 0` from the " + "peak guard against `hi <= 0` from the shaping - and the arbiter answers " + "CONTRADICTION and 0 W. The battery is PARKED in the billed quarter, and " + "P_PEAK 50 > P_MAINT_SHAPE 40 does not save it", + "CONTRADICTION" in b1.reason and near(b1.target, 0.0) + and b1.peak_guard.engaged) + +# ---- B2: re-gated on peak_forecast_entity, i.e. the historical bug ---------- +# WARNING - THIS IS THE ONE THAT MUST NOT SHIP UNDEFENDED. It is verbatim the +# defect this card's own comment describes, and until this check existed the +# comment explained a fix that nothing could regress. That is the same shape as +# a corrected comment nobody tests, which this project has already been caught +# by once. +try: + peakguard.shaping_suspended = lambda d, fc, cap: peak_at_risk(fc, float(cap)) + b2, b2_hass, _, _ = maint_cycle("mut-b2.db") +finally: + peakguard.shaping_suspended = _shipped_pred +print(f" B2 (re-gated on peak_forecast_entity): " + f"peak_at_risk({b2.peak_fc}, {CAP_W:.0f}) = " + f"{peak_at_risk(b2.peak_fc, CAP_W)}, {b2.reason}, " + f"target {b2.target:+.1f} W") +check("B2: gating the suspension on peak_forecast_entity ALONE parks a default " + "install at 0 W. That entity is optional and empty there, so peak_at_risk " + "is False forever and the shaping is filed against the guard's floor - " + "silently, in the quarter that sets the month's bill", + not peak_at_risk(b2.peak_fc, CAP_W) + and "CONTRADICTION" in b2.reason and near(b2.target, 0.0)) +check("...and the shipped predicate differs from that mutation on exactly this " + "input, which is what makes the measured term load-bearing rather than " + "decorative", + _shipped_pred(base.peak_guard, None, CAP_W) is True + and peak_at_risk(None, CAP_W) is False) +check("...while the forecast term is not redundant either: on a site that DOES " + "set peak_forecast_entity, a forecast over the cap suspends the shaping " + "even before the measured guard engages", + _shipped_pred(None, CAP_W + 1000.0, CAP_W) is True + and _shipped_pred(None, None, CAP_W) is False) + +# ---- B3: the floor sized from intent rather than from delivered power ------- +# `decide()` is graded above with a hand-passed `batt_w`; nothing graded WHICH +# value main.py hands it. Recorded off the real call, with the measured battery +# and the standing command deliberately far apart. +STANDING = 3000.0 +_seen = {} +_shipped_decide = peakguard.decide + + +def _recording_decide(projection, **kw): + _seen.update(kw) + return _shipped_decide(projection, **kw) + + +try: + peakguard.decide = _recording_decide + b3, b3_hass, _, _ = maint_cycle("mut-b3.db", batt_w=0.0, standing_w=STANDING) +finally: + peakguard.decide = _shipped_decide +print(f" B3: measured battery {b3.batt:+.0f} W, command standing on the wire " + f"{STANDING:+.0f} W; main.py handed decide() batt_w={_seen.get('batt_w')}") +check("B3: main.py sizes the floor from the MEASURED battery power, not from " + "the command. The utility bills what FLOWED, and `grid = load - batt` is " + "true of delivered power and false of intent", + near(_seen.get("batt_w"), b3.batt) + and not near(_seen.get("batt_w"), STANDING)) +# THE CONSEQUENCE, ITERATED. One cycle of the intent form is merely inflated by +# the standing command; the failure is what happens on the NEXT cycle, when its +# own unmet ask has become the standing command. So both forms are run forward +# against the same frozen projection - the inverter never follows, so the meter +# never moves - and the two are allowed to separate over time rather than being +# judged at a single point where they need not differ. +_proj3 = PeakTracker.project(b3.p1.averager, datetime.now(timezone.utc)) +_kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W, + max_hold_s=b3.p1.averager.max_hold_s) +_intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw) +check("one cycle of the intent form is exactly the delivered form plus the " + "command standing on the wire - which is the whole defect in one line: " + "watts that were asked for and never arrived, counted as if they had", + near(_intent.floor_w - b3.peak_guard.floor_w, STANDING, 1.5)) + +_i_cmd, _d_batt = STANDING, 0.0 +_i_track, _d_track, _i_sat_at = [], [], None +for _k in range(1, 9): + _di = _shipped_decide(_proj3, batt_w=_i_cmd, **_kw) + _dd = _shipped_decide(_proj3, batt_w=_d_batt, **_kw) + _i_track.append(_di.floor_w) + _d_track.append(_dd.floor_w) + if _di.saturated and _i_sat_at is None: + _i_sat_at = _k + _i_cmd = _di.floor_w # the intent form's ask becomes what stands + # ...while the inverter delivers nothing, so the delivered form's input + # does not move at all. +print(f" ...iterated against a frozen meter and an inverter that never " + f"follows:") +print(f" intent-sized floor {[f'{v:.0f}' for v in _i_track]}" + f" -> SATURATED at cycle {_i_sat_at}") +print(f" delivered-power floor {[f'{v:.0f}' for v in _d_track]} -> stationary") +check("...and the consequence is the wind-up part 1 demonstrates, now on the " + "REAL controller's own projection: the intent-sized floor re-adds its own " + "unmet ask every cycle and reaches the rail SATURATED, while the " + "delivered-power floor does not move at all", + _i_sat_at is not None and _i_track[-1] > _i_track[0] + and len({f"{v:.3f}" for v in _d_track}) == 1 + and not b3.peak_guard.saturated) +# Graded on the parsed call syntax too, so a rename cannot quietly reintroduce +# it: the keyword main.py passes must be the measured attribute. +_call = next(n for n in ast.walk(ast.parse(_mainsrc)) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) + and n.func.attr == "decide") +_batt_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "batt_w")) +print(f" main.py's decide(batt_w=...) is `{_batt_src}`") +check("...and the call site says so in the source: the argument is the measured " + "battery attribute, not the standing command and not the resolved target", + _batt_src == "self.batt") + +# SAFETY-24 must be pointed at from the module that needs it, not only from a +# review thread - a ticket nobody can find from the code is a ticket nobody +# reads before shipping this to a client install. +check("peakguard.py names SAFETY-24, the missing SoC backstop on the forced " + "discharge this guard can command", "SAFETY-24" in src) + + +# ============================================================================== +# CFG-03: no fourth defaults site. +cfg = (HERE / "config.yaml").read_text(encoding="utf-8") +val = (HERE / "app" / "validate.py").read_text(encoding="utf-8") +check("no new option was added: config.yaml names no peak-guard key, and " + "§14's kW/W trap `capacity_peak_limit_kw` stays declined", + "peak_guard" not in cfg and "capacity_peak_limit_kw" not in cfg + and "peak_shave" not in cfg) +# Graded on the parsed call syntax, not on the text: the module docstring names +# `peak_cap_w` while arguing that it does not read it, so a grep would be +# satisfied by deleting the explanation and would fail on writing one. +_decide = next(n for n in ast.walk(ast.parse(src)) + if isinstance(n, ast.FunctionDef) and n.name == "decide") +_DECIDE_ARGS = [a.arg for a in _decide.args.args + _decide.args.kwonlyargs] +_getters = [n for n in ast.walk(ast.parse(src)) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) + and n.func.attr == "get"] +print(f" peakguard.py makes {len(_getters)} options-style `.get()` calls") +check("peakguard.py reads no option of its own: it makes no `.get()` call at " + "all and takes `cap_w` as an argument. CFG-03's three defaults sites are " + "config.yaml's `options:`, config.yaml's `schema:` and main.py's literal " + "fallbacks - this card adds no fourth", + not _getters and "cap_w" in _DECIDE_ARGS) +# And the fallback in main.py must equal the shipped default, for the reason +# test_validate.py's CFG-03 block spells out at length: the two diverging is a +# live defect in this repo for `maintenance_charge_w`, and it is not to be +# repeated on the key this guard's ceiling comes from. +_fallbacks = {float(m) for m in + re.findall(r'peak_cap_w"\s*,\s*([0-9]+(?:\.[0-9]+)?)', _mainsrc)} +print(f" peak_cap_w: config.yaml ships {CAP_W:.0f}, main.py falls back to " + f"{sorted(_fallbacks)}") +check("...and every literal fallback for peak_cap_w in main.py equals the " + "shipped default, so the guard's ceiling cannot differ between a " + "Supervisor install and a test harness", + _fallbacks and all(v == CAP_W for v in _fallbacks)) + + +# ============================================================================== +print() +print("8. mutation: what defeats this guard, and which check bites") + +# Each entry re-implements ONE defeat and reports whether a check above would +# have gone red. The last row is the honest one: it does NOT bite, and saying so +# is worth more than a green line that is not true. +MUT = [] + +# 1. blindness() disabled - the fresh averager's 0 W projection graded as a +# number. Part 4 asserts d_fresh.engaged, so the defeat is visible there. +_ex_fresh = excess_w(p_fresh, CEIL) +MUT.append(("blindness() returns None - the degenerate 0 W projection is graded", + (0.0 + _ex_fresh) <= 0.0 and d_fresh.engaged, + "part 4: 'a fresh averager ... reports BLIND'")) + +# 2. the frozen term dropped from the guard's inputs. +MUT.append(("frozen=False - SAFETY-18's term dropped", + not decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, + frozen=False).engaged and d_frozen.engaged, + "part 4: 'THE BACKSTOP: SAFETY-18's runaway ... cut to 0 W'")) + +# 3. `is_observed` dropped from quarter_ceiling_w, i.e. any reading's peak +# trusted. A NOT_OBSERVED reading carries peak_w=None, so the defeat is LOUD. +def _ceiling_without_the_gate(cap, rd): + return max(float(cap), float(rd.peak_w)) + + +_loud = False +try: + _ceiling_without_the_gate(CAP_W, unread) +except TypeError: + _loud = True +MUT.append(("the `is_observed` gate dropped from quarter_ceiling_w", + _loud, "TypeError on PeakReading.peak_w=None - it fails loud")) + +# 4. the 900/remaining gain flattened to a plain difference. +_p800 = PeakTracker.project(a800, B + timedelta(seconds=800)) +MUT.append(("the 900/remaining gain replaced by a plain difference", + not near(excess_w(_p800, CEIL), _p800.projected_w - CEIL, 1.0), + "part 1: 'the law is the difference times 9.00x'")) + +# 5. the achievable_w clamp removed, so the floor reaches the arbiter unbounded. +MUT.append(("the achievable_w clamp removed", + resolve([Claim.limit("device", P_SAFETY, -DEV_MAX, DEV_MAX, "r"), + Claim.set("loop", P_LOOP, -200.0, "grid"), + Claim.limit("peak", P_PEAK, + lo=d_absurd.wanted_w)]).contradiction, + "part 2: 'the claim can NEVER produce lo > hi'")) + +# 6. the claim filed as a SET rather than a LIMIT - it would then outrank +# nothing it should, but it WOULD survive a safety stop losing the tie. +_as_set = resolve(SAFE + [Claim.set("safety", P_SAFETY, 0.0, "stopped"), + Claim.set("peak", P_PEAK, pc.lo, "x")]) +MUT.append(("filed as a SET at P_PEAK instead of a LIMIT", + near(_as_set.target_w, 0.0) and pc.kind == "limit", + "part 2: 'it is a LIMIT at P_PEAK, not a SET'")) + +# 7. THE ONE THAT DOES NOT BITE, and it is stated rather than hidden. +_zero_peak = PeakReading(OBSERVED, "2026-08", peak_w=0.0, quarter_start=B) +_same = quarter_ceiling_w(CAP_W, _zero_peak) == quarter_ceiling_w(CAP_W, unread) +MUT.append(("NOT_OBSERVED silently replaced by a peak of 0.0 W", + not _same, + "NOTHING - and by construction: the ceiling is max(cap, peak), so a " + "zero peak is the TIGHTEST possible input to it")) + +for label, caught, where in MUT: + print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}") + print(f" {where}") +check("the six mutations that CAN disarm this guard are each caught by a check " + "above - a guard whose checks survive its own defeat is not a guard", + all(m[1] for m in MUT[:6])) +check("...and the seventh is honestly reported as not caught, because it cannot " + "disarm anything: `max(cap, peak)` makes a zero peak the strictest " + "ceiling there is, so the disarming direction is closed by the shape of " + "the expression rather than by a check. THAT is why the ceiling is a max " + "and not a subtraction - `headroom = cap - peak` would have turned the " + "same zero into FULL freedom, which part 3 shows is why headroom_w() is " + "not consumed here", not MUT[6][1] + and quarter_ceiling_w(CAP_W, _zero_peak)[0] == CAP_W) + + +# ============================================================================== +print() +shutil.rmtree(TMP, ignore_errors=True) +if fails: + print(f"{len(fails)} FAILED: {', '.join(fails)}") + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/test_price_forecast.py b/goodwe_controller/test_price_forecast.py index b892121..fbaffac 100644 --- a/goodwe_controller/test_price_forecast.py +++ b/goodwe_controller/test_price_forecast.py @@ -595,12 +595,33 @@ check("...so a block with no reason is a TypeError at the call site, not a " 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", +# ⚠️ ONE CALL PER ROLE, AND THE FIRST DRAFT SHOWED WHY. Passing the provisional +# point as BOTH `cheapest` and `dearest` is a stimulus that cannot distinguish +# "the constructor guards the cheapest hour" from "the constructor guards both +# hours": a guard on either one alone raises, and the check goes green for an +# implementation that leaves the sell hour unguarded. That is the degeneracy +# rule this file states at the top, in a new dress - so the two roles are +# stimulated SEPARATELY, each with a firm point in the other seat. +_firm_pt = TODAY_PART.points[0] +check("the two points these constructor checks use really differ in provenance, " + "or neither of them grades anything", + _prov_pt.provisional is True and _firm_pt.provisional is False) +check("an ALLOWED arbitrage verdict cannot be constructed with a provisional " + "CHEAPEST hour - the buy 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, _firm_pt, "normal", PriceProduct.DAY_AHEAD, 0.0, frozenset()), ValueError)) +check("...nor with a provisional DEAREST hour - the sell hour - which a guard " + "written for `cheapest` alone would have let through", + raises(lambda: Arbitrage.allow(0.1, _firm_pt, _prov_pt, "normal", + PriceProduct.DAY_AHEAD, 0.0, frozenset()), + ValueError)) +check("...while two firm points build the verdict perfectly well, so the two " + "refusals above are discriminating rather than universal", + Arbitrage.allow(0.1, _firm_pt, _firm_pt, "normal", + PriceProduct.DAY_AHEAD, 0.0, frozenset()).kind + == "allowed") # --------------------------------------------------------------------------- # @@ -789,6 +810,17 @@ check("the cheap-window flag is decided per HOUR: a provisional hour is never " 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) +# ⚠️ GRADED DIRECTLY, BECAUSE ITS CONSUMERS CANNOT GRADE IT. The §11.4 refusal +# and `cheap_now` both guard provenance independently, so `firm_points()` +# returning the carried hours TOO still leaves both of them answering correctly +# - a stimulus that cannot distinguish an over-reporting accessor from a +# correct one. So the tuple itself is compared with the published points, by +# identity and in order, rather than only the mean it feeds. +check("firm_points() returns EXACTLY the published points, in order - not the " + "carried ones as well, which its two consumers would have absorbed " + "without either of them changing its answer", + list(_mixed.firm_points()) == _firm_rest + and all(a is b for a, b in zip(_mixed.firm_points(), _firm_rest))) 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())