From 616b62d3e91633f7fc016623fab87fd386709714 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 30 Aug 2026 23:05:05 +0200 Subject: [PATCH 1/2] SAFETY-07: cap the loop before it sets the month's billed capacity peak TARIFF-01 measured the quarter and said the consumer would be this card. It is one new module, app/peakguard.py, and one new claim. THE MECHANISM. Every cycle the guard projects the open 900 s block 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 arbiter 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 and pre-emptive discharge, and it cannot cap discharge or export, because a floor cannot. 9.1's asymmetry becomes a property of the object instead of a rule to remember. excess = 900 x (projected - ceiling) / seconds remaining floor = MEASURED battery power + excess The 900/remaining gain is 1.0 at the top of the quarter and grows, so the guard is gentlest where its projection rests on the least evidence. The floor is sized from delivered battery power, not the standing command: a floor built on intent re-adds its own unmet ask every cycle and winds to the rail whenever the inverter fails to follow, and test_peakguard.py part 1 shows the two diverging. IT TRIPS, 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 - the cost SAFETY-18 could not bound and this one can. 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 and AC 3 remains TARIFF-01's open item. BLINDNESS IS CHECKED BEFORE THE PROJECTION IS GRADED, and it is a different action. 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; a frozen meter reading export projects 0 W at full coverage. All three become a floor of 0 W - no importing while the billed quantity is unobservable - 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, on this cycle's standing command. 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 and never looser. The gate is the reading's STATE, not whether a number happens to be attached: today a `peak_w is not None` test behaves identically, and depending on that coincidence is how a future last-known-peak field would raise the ceiling off a read that failed. headroom_w() is deliberately not consumed - cap minus peak turns a zero peak into FULL freedom. TWO DEFECTS FOUND BUILDING IT, both in the interaction rather than the arithmetic, and both caught by running the thing rather than by reading it: * 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 - the battery parked in the exact quarter it should shave. Fixed by suppression, not 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 empty on a default install, so until now the suspension could not fire on the site that needed it. * Thresholding `excess > 0` released the claim the instant it began working, producing seventeen engage/release cycles in one 900 s quarter and leaving that quarter 49 W over the limit it had spent fifteen minutes holding. That is 10.2's oscillation. 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 disarming - a rename credits 0 W held, the projection reads low, the money guard does not engage. QuarterAverager.held_net_w is a documented public property now, 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. 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 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. test_peakguard.py: eight parts, all figures derived from the shipped config.yaml and the committed captures. Part 8 runs seven one-line defeats of the guard and reports which check bites; six do, and the seventh is reported as NOT caught, because max(cap, peak) makes a zero peak the strictest ceiling there is and there is nothing left to catch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC --- goodwe_controller/CHANGELOG.md | 67 ++ goodwe_controller/DOCS.md | 75 +- goodwe_controller/app/main.py | 219 +++++- goodwe_controller/app/p1.py | 20 + goodwe_controller/app/peakguard.py | 467 ++++++++++++ goodwe_controller/app/tariff.py | 26 +- goodwe_controller/test_peakguard.py | 1080 +++++++++++++++++++++++++++ 7 files changed, 1940 insertions(+), 14 deletions(-) create mode 100644 goodwe_controller/app/peakguard.py create mode 100644 goodwe_controller/test_peakguard.py diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 5ea10a8..ab2fbc8 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -2,6 +2,73 @@ ## Unreleased +**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. + **The capacity-tariff peak is now measured, banked and survives a restart (TARIFF-01).** New `app/tariff.py`. The 15-minute block average itself was already built and unwired - `p1.QuarterAverager` computes clock-aligned 900 s diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 30b4566..fa18739 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 d7fd7ef..006e651 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 .tariff import PeakStore, PeakTracker from .validate import ConfigRefused, refuse, validate_startup_config @@ -188,6 +191,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. @@ -397,6 +416,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, @@ -408,9 +498,38 @@ 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. + suspend = peak_at_risk(self.peak_fc, + float(self.o.get("peak_cap_w", 3500))) + if self.peak_guard is not None and self.peak_guard.engaged: + suspend = True + if suspend: + # 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: @@ -525,6 +644,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 @@ -616,6 +740,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. @@ -891,6 +1068,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..2a3c454 --- /dev/null +++ b/goodwe_controller/app/peakguard.py @@ -0,0 +1,467 @@ +"""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. + +------------------------------------------------------------------------------ +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 .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 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..95636ab --- /dev/null +++ b/goodwe_controller/test_peakguard.py @@ -0,0 +1,1080 @@ +"""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.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: + data = {} + + def set(self, *a): + pass + + def get_time(self, *a): + return None + + +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): + 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(), _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): + """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) + ctl.auto, ctl.soc, ctl.batt = True, 55.0, 0.0 + ctl.dev_min, ctl.dev_max = -LIVE_MAX_W, LIVE_MAX_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) + +# 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. +_mainsrc = (HERE / "app" / "main.py").read_text(encoding="utf-8") +_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") -- 2.54.0 From 2d570d84ceb419c3b6aa0cf81099f5a946a72283 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 30 Aug 2026 23:40:39 +0200 Subject: [PATCH 2/2] SAFETY-07 review: defend the three wiring mutations that survived The reviewer's finding was correct and the shape of it matters: everything the first cut graded was in peakguard.py, and grading the module is not the same 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. B2 was the serious one. My own comment described that defect - "that entity is optional and empty on a default install, so until now the suspension could not fire on the site that needed it" - and nothing could regress it. A comment that documents a fix nobody can test is the same shape as a corrected comment nobody tests, which this project has already been caught by once. WHAT CHANGED The suppression conjunction moved out of main.py and into peakguard.shaping_suspended(), for the reason watchdog.loop_is_following() and freeze.looks_frozen() live where they do: the check that grades a safety predicate must assert on THE SHIPPED EXPRESSION, not on a transcription that can drift apart from it in silence. Its docstring carries both terms and why neither is sufficient alone. main.py's call site now says READ THAT FUNCTION FIRST, and records that a reviewer mutated it back to its historical broken form with every suite staying green. test_peakguard.py part 7 gains a section that EXECUTES the three mutations rather than describing them. Each one is put in place on the shipped module and the REAL Controller.cycle() is driven with it, on the dangerous fixture: a default install (no peak_forecast_entity, so peak_fc is None and peak_at_risk is False forever) with maintenance in its CHARGE phase, so a P_MAINT_SHAPE `hi <= 0` limit is live, and the guard genuinely engaged on the measurement. That the mutations are monkeypatched onto the shipped module is itself load-bearing: if main.py inlined either expression again the patch would do nothing and these checks would pass vacuously. SHIPPED suspended=True loop -> 1498 W, limited by peak +1497.8 W B1 suspended=False CONTRADICTION between limits 0.0 W B2 suspended=False CONTRADICTION between limits 0.0 W B2b (forecast term dropped instead) - caught by its own check B3 is graded three ways, because the single-cycle form is only INFLATED and the failure is what happens on the next cycle: * the value main.py actually hands decide() is recorded off the real call, with the measured battery at 0 W and the command standing at +3000 W; * the one-cycle difference is asserted to be exactly the standing command - the whole defect in one line, watts that were asked for and never arrived counted as if they had; * both forms are then run forward against the REAL controller's own projection with an inverter that never follows: intent-sized floor 4482 5000 5000 5000 ... SATURATED at cycle 2 delivered-power floor 1482 1482 1482 1482 ... stationary * and the call site is graded on parsed syntax, so a rename cannot quietly reintroduce it: `decide(batt_w=...)` must unparse to `self.batt`. SAFETY-24 is now named in peakguard.py, in its own section, with the number that makes it real - 878 Wh of forced discharge on the 2026-08-20 replay - and the statement that this must not go to a client install before that ticket lands. A ticket nobody can find from the code is a ticket nobody reads before shipping. RED AND GREEN Fifteen mutations run against the shipped modules, each reverted after: the six new wiring ones (B1, B2, B2b, B3, B3b, and deleting the SAFETY-24 pointer) and the eight from the first round, re-run to confirm they still bite. All fifteen turn test_peakguard.py red. Full suite green, test_price.py and TARIFF-03's included. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC --- goodwe_controller/app/main.py | 16 ++- goodwe_controller/app/peakguard.py | 50 +++++++ goodwe_controller/test_peakguard.py | 210 ++++++++++++++++++++++++++-- 3 files changed, 262 insertions(+), 14 deletions(-) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 006e651..bdabc32 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -515,11 +515,17 @@ class Controller: # 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. - suspend = peak_at_risk(self.peak_fc, - float(self.o.get("peak_cap_w", 3500))) - if self.peak_guard is not None and self.peak_guard.engaged: - suspend = True - if suspend: + # + # ⚠️ 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. diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py index 2a3c454..c8373f3 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -245,6 +245,21 @@ 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 @@ -258,6 +273,7 @@ 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 @@ -454,6 +470,40 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, 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. diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 95636ab..793e2ba 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -80,6 +80,7 @@ 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 ( @@ -763,14 +764,21 @@ print("7. the wiring: the real Controller, the real cycle, the real status page" class _Store: - data = {} + # ⚠️ 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, *a): - pass + 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 @@ -802,7 +810,7 @@ class _Hass: return True -def built(db_path, opts=None): +def built(db_path, opts=None, store=None): hass = _Hass() c = Controller(dict( {"setpoint_entity": "number.sp", "meter_source": "ha_signed", @@ -810,7 +818,8 @@ def built(db_path, opts=None): "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(), _Pub(), stop=asyncio.Event(), peak_db_path=str(db_path)) + 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 @@ -862,7 +871,8 @@ check("...and the status page says NOT COVERED rather than 'idle' or a green " # 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): +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 @@ -874,9 +884,14 @@ def wired_over_limit(db, want_floor_w, opts=None): 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) - ctl.auto, ctl.soc, ctl.batt = True, 55.0, 0.0 + 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) @@ -951,6 +966,184 @@ 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") @@ -977,7 +1170,6 @@ check("peakguard.py reads no option of its own: it makes no `.get()` call at " # 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. -_mainsrc = (HERE / "app" / "main.py").read_text(encoding="utf-8") _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 " -- 2.54.0