From 06f95e0a64d20dd9e41f6d1f5bd9d5cb33ed9b47 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 01:54:29 +0200 Subject: [PATCH 1/2] ARB-03: restore Claim.actuator and make resolve() three-stage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specs.md:467 declares `actuator: str` as the FIRST field of `Claim`. app/arbiter.py:55 had no such field. The specification designed per-actuator claims and the implementation dropped it, so every claim was implicitly about "the one and only actuator", resolve() returned one scalar Resolution.target_w, and main.py assigned it to one self.target. THERE WAS NO SEAM AN ALLOCATOR COULD OCCUPY - and ARCH-01 (specs.md §6.2, 2026-09-02) decided the site grid target is ALLOCATED across several inverters and never broadcast. This is a regression fix against the written design; creating that seam is the whole of the change. WHY IT MATTERS, IN THE UNITS THE SYSTEM IS BILLED IN. The control primitive is meter emulation: the master reports a fabricated grid reading and the inverter regulates itself to it, so one number means "the battery moves X W" to the add-on and "the grid is at X W" to the inverter - an identity that holds only at N=1. Broadcast one target to two inverters and each supplies it in full; the loop gain at the meter becomes N times the per-unit gain, which at the gain config.yaml:374 accepts is a permanent limit cycle with every watchdog in §11.2 reporting healthy. And a per-inverter reserve filed as a site `hi` against the peak guard's site `lo` is `lo > hi`, arbiter clause 3, 0 W IN THE BILLED QUARTER - the parked battery SAFETY-25's memo calls fatal. THE SPLIT, VERIFIED AT EACH CONSTRUCTION SITE RATHER THAN ACCEPTED FROM THE SURVEY. Four sources are about the GRID CONNECTION and are SITE claims: `loop` (compute() regulates grid_w, the P1 measurement, and §6.2's whole architecture is one integrator on P1 whose output is then split), `peak` (the 15-minute average offtake AT the grid connection, which is what the capacity tariff bills - one quarter-hour peak per site, not one per pack), `arbitrage` (buys and sells at the grid against a day-ahead price; the price is a property of the connection and the energy crosses the meter) and `safety` (both construction sites read: "stopped" is the operator switching control off for the site, "inputs missing" is the site's own meter gone - neither is about a pack, and both must stop every inverter). Four are about a pack, a device or one write and are PER-INVERTER: `device` (one inverter's own reported min/max - there is no site rating), `supervised` (max_w caps what may be WRITTEN to one setpoint entity, §11.3, so it is scoped like `device` for the same reason), `maintenance` (§12's recalibration is a pack procedure - drain THIS pack, charge THIS pack, recalibrate THIS pack's counter) and `failsafe`. `failsafe` is the entry that needed the reading rather than the survey: SAFETY-02's EVIDENCE is site-wide (it compares P1 against the standing command) but its CONCLUSION is "the unit I am driving is pushing power the wrong way" and its remedy is to park that unit. At N>=2 the same evidence cannot be attributed to a sibling this controller never commanded, so a site-wide stop would park an inverter this watchdog never graded. The reasoning for all eight is recorded AT THE LINE, in arbiter.CLAIM_SCOPE and again at each claims.append in main.py. WHAT WAS BUILT. resolve() is three stages, each a separate function so a test can construct a case at any one of them: pre_resolve() - one inverter's own claims -> that unit's interval. Clauses 1 and 2 transposed: the unit's own winning SET gates its own limits. The SET's value travels BESIDE the interval, not inside it - folding a -2500 W drain into [-2500, -2500] would turn "clamped to max_w" into "contradicts max_w", a different and wrong answer. site_resolve() - THE SHIPPED ARBITER, UNCHANGED. Same code, new name. allocate() - THE SEAM, AND DELIBERATELY NOT AN ALLOCATOR. ALLOC-01 builds it. At N<=1 it passes the target through (§6.2's identity). At N>=2 it DECLINES - no shares, allocated=False - and main.py refuses to write rather than broadcast, because the obvious placeholder (hand the target to each) is the fault ARCH-01 was filed about and no guard we have can tell it apart from the real thing. THE CONTRADICTION IS NOT GUARDED AGAINST, IT CANNOT BE WRITTEN DOWN. The partition is by `actuator` and it is a partition: a unit's `hi` and the site's `lo` are never both terms of one `lo > hi` test. Constructed and swept - 40 filings (every per-inverter source x unit x bound, including a unit contradicting ITSELF at lo 900 > hi 100) against a live site floor, and not one moves the site target or raises a contradiction. A unit's own impossible pair parks that unit and says so on its own bounds; it does not park the site. That is SAFETY-25 §0c's table made mechanical. AT N=1 THIS IS A PURE REFACTOR, AND THAT IS PROVEN RATHER THAN ASSERTED. With one inverter the fabricated grid reading and the battery setpoint are the same number, so the grid connection and the only unit ARE one actuator: actuator_for() routes every source to the site, no claim is filed on a unit, stage 2 sees the whole list and answers what the shipped arbiter answered. All 17 suites replay UNEDITED above the ARB-03 sections and are green on the same numbers; test_arbiter.py additionally runs 13 claim lists through both resolve() and site_resolve() and compares target, winner, binder, contradiction flag, the considered list and the operator line. EVIDENCE, BY BREAKING EACH THING (tools/mutation_run.sh, purged tree): * Claim's dataclass default emptied -> RED (test_arbiter) ...and it survived until the check was added: the two helpers pass their own default down, so the FIELD's default never ran. Now checked directly. * `peak` reclassified SCOPE_UNIT -> RED (arbiter x4, peakguard x2) * `maintenance` reclassified SCOPE_SITE -> RED (arbiter x4, peakguard) * the N=1 identity dropped in actuator_for() -> RED (arbiter, peakguard incl. the maintenance/peak suppression checks, freeze) - which is the measurement that says splitting at N=1 would be a behaviour change * resolve() stops partitioning -> RED (arbiter x4) * allocate() broadcasts at N>=2 -> RED (arbiter x2) * stage 1 drops clause-2 gating -> RED (arbiter) * a call site routes `device` through `loop` -> RED (peakguard 10g) * SAFETY-25 Option B built as a ninth source -> RED (peakguard 10f, both halves, plus 10g) - the tripwire still bites SAFETY-24's asymmetry proof is re-run and holds: 0 negative floors over the shipped sweep, `hi` still +inf on every claim the guard files, and it now also asserts every one of them is on the SITE actuator - a floor bound to one inverter's share would leave the site, the thing the capacity tariff meters, with no floor at all. test_peakguard.py part 10f is kept, not adapted around. `source` is still Claim.set()'s first positional argument precisely so its AST scan keeps reading it, which is why `actuator` is the LAST field of Claim and not the first - moving `source` off position 0 would silently blind the tripwire that guards SAFETY-25's deferred option. The one adaptation is `**kw` on its spy, so main.py's roster passes through unread rather than being silently dropped. New part 10g pins CLAIM_SCOPE to the same eight-source set and grades a REAL two-inverter cycle, which is the only place a mis-typed actuator_of(...) at a call site is observable - at N=1 every source legitimately routes to the site, so a swap there is invisible by construction. No config key, no option, no operator surface, no default moved. Inverter B does not exist yet and nothing here measures it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 73 ++++++ goodwe_controller/app/arbiter.py | 345 +++++++++++++++++++++++++++- goodwe_controller/app/arbitrage.py | 8 +- goodwe_controller/app/main.py | 125 +++++++++- goodwe_controller/app/peakguard.py | 11 +- goodwe_controller/test_arbiter.py | 292 ++++++++++++++++++++++- goodwe_controller/test_peakguard.py | 125 +++++++++- 7 files changed, 955 insertions(+), 24 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 43b3c86..e4068ed 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -2,6 +2,79 @@ ## Unreleased +**`Claim` carries the `actuator` specs.md always said it did, and `resolve()` +is three-stage (ARB-03).** `specs.md` §6.1 declares `actuator: str` as the first +field of `Claim`; the shipped `app/arbiter.py` had no such field, so every claim +was implicitly about "the one and only actuator", `resolve()` returned one +scalar, and `main.py` assigned it to one `self.target`. **There was no seam an +allocator could occupy** - and `ARCH-01` decided the site grid target is +ALLOCATED across several inverters and never broadcast. This is a regression fix +against the written design, and it creates that seam. **At N=1 it is a pure +refactor: every one of the 17 suites replays unedited and green, on the same +numbers.** + +* **The split, verified at each construction site rather than assumed.** Four + sources are about the **grid connection** and are SITE claims - `loop` + (regulates `grid_w`, the P1 measurement), `peak` (the quarter-hour average + offtake the capacity tariff bills), `arbitrage` (buys and sells at the grid + against a day-ahead price) and `safety` (`stopped` is the site's switch, + `inputs missing` is the site's own meter). Four are about **a pack, a device + or one write** and are PER-INVERTER - `device` (one inverter's own reported + rating), `supervised` (`max_w` caps what may be WRITTEN to one setpoint + entity), `failsafe` (**site-wide evidence, per-unit remedy**: SAFETY-02 grades + the command THIS controller put on THIS actuator, and at N>=2 that evidence + cannot be pinned on a sibling nobody here commands) and `maintenance` (§12's + recalibration is a pack procedure). The reasoning is recorded **at the line**, + in `arbiter.CLAIM_SCOPE` and again at each `claims.append` in `main.py`. +* **Getting it wrong is the parked-battery bug, so it is routed rather than + written.** A per-inverter reserve filed as a site `hi` against the peak guard's + site `lo` is `lo > hi`, arbiter clause 3, **0 W in the billed quarter** - + SAFETY-25 §0a's fatal case. `actuator_for()` is the one routing point and it + **raises `KeyError` on an unclassified source** rather than defaulting to the + site, because defaulting to the site is the failure itself. +* **Three stages, each a function a test can construct a case at.** + `pre_resolve()` turns one inverter's own claims into that unit's interval; + `site_resolve()` turns the site claims into one grid target and **is the + shipped arbiter, unchanged**; `allocate()` is **the seam, and deliberately not + an allocator** - `ALLOC-01` builds it. With at most one unit it passes the + target through (§6.2's N=1 identity); with two or more **it declines, no + shares, `allocated=False`**, because the obvious placeholder - hand the target + to each - IS the fault `ARCH-01` was filed about, and `main.py` refuses to + write rather than broadcast. +* **The contradiction is no longer guarded against, it CANNOT BE WRITTEN DOWN.** + The partition is by `actuator` and it is a partition: a unit's `hi` and the + site's `lo` are never both terms of one `lo > hi` test. Constructed and swept - + 40 filings (every per-inverter source x unit x bound, a unit contradicting + ITSELF included) against a live site floor, and not one moves the site target + or raises a contradiction. A unit's own impossible pair parks that unit and + says so on its own bounds; it does not park the site. That is SAFETY-25 §0c's + table made mechanical. +* **At N=1 nothing splits, and that is §6.2's identity rather than the + classification being ignored.** With one inverter the fabricated grid reading + and the battery setpoint are the same number, so the grid connection and the + only unit ARE one actuator and every source routes to the site. **Measured: + dropping that identity - splitting at N=1 - takes `test_peakguard.py`, + `test_freeze.py` and part 10g red**, because the `failsafe` and `maintenance` + SET claims would stop competing for the winner, `explain()` would name a + different source and `loop_is_following()` reads that name. +* **SAFETY-24's floor asymmetry survives, and now has a second half.** The + combination sweep is re-run and still holds - no negative floor, `hi` still + `+inf` on every claim the guard files - and it now also asserts every one of + them is on the **site** actuator, because a floor bound to one inverter's share + would leave the site, the thing the capacity tariff meters, with no floor. +* **The part 10f tripwire is kept and widened, not adapted around.** Its static + AST scan and its dynamic SoC sweep are untouched in substance - `source` is + still `Claim.set()`'s first positional argument precisely so the scan keeps + reading it, which is why `actuator` is the LAST field of `Claim` and not the + first. Its spy gained `**kw` so the roster passes through unread rather than + being silently dropped. New **part 10g** pins `CLAIM_SCOPE` to the same + eight-source set, so a ninth source now goes red twice; and it grades a **real + two-inverter cycle**, which is the only place a mis-typed `actuator_of(...)` + at a call site is observable - at N=1 every source legitimately routes to the + site, so a swap there is invisible by construction. Verified by breaking it: + building SAFETY-25's Option B as a ninth source still takes 10f red on the + line it is written. + **SAFETY-25's open question is ANSWERED: the BMS cut-out is the DELIBERATE and only backstop for demand-driven discharge, and a loop-side reserve floor is DEFERRED to `ALLOC-01` rather than rejected (SAFETY-25).** Decided by the diff --git a/goodwe_controller/app/arbiter.py b/goodwe_controller/app/arbiter.py index 7c99a1e..31fe144 100644 --- a/goodwe_controller/app/arbiter.py +++ b/goodwe_controller/app/arbiter.py @@ -25,6 +25,39 @@ Clause 2 is the interesting one. It is what makes "money outranks maintenance" a consequence of the priorities instead of a special case somebody can forget: the maintenance charge-only limit binds the loop, but a higher-priority peak shaving claim simply is not bound by it. + +⚠️ AND WHICH ACTUATOR IS A CLAIM ABOUT? THAT QUESTION HAS AN ANSWER AGAIN. +specs.md §6.1 declared `actuator: str` as the first field of `Claim` and the +implementation shipped without it, so every claim was implicitly about "the one +and only actuator" and `resolve()` returned one scalar. ARB-03 restores the +field, because §6.2 decided the site grid target is ALLOCATED across several +inverters and never broadcast to each of them - and an allocator needs a seam to +occupy that a single scalar does not have. + +So resolution is now THREE STAGES, and they are separate functions so that a +test can construct a case at any one of them: + + 1. `pre_resolve()` - each inverter's OWN claims become an interval for that + unit. One unit's claims, one unit's bounds. + 2. `site_resolve()` - the SITE claims become one grid target S. This is the + rule above, unchanged, and it is deliberately the same + code it was: at N=1 there are no unit claims, so this + stage sees the whole list and answers exactly what the + shipped arbiter answered. + 3. `allocate()` - THE SEAM. It turns S into per-inverter shares subject to + stage 1's intervals. ⚠️ IT IS NOT AN ALLOCATOR AND MUST + NOT BECOME ONE HERE - see its own docstring. ALLOC-01 + builds it. + +⚠️ THE STRUCTURAL PROPERTY, AND IT IS THE REASON FOR THE SPLIT. A per-inverter +constraint is consumed by the allocator as an INTERVAL; it is never promoted +into the site claim list. So a unit `hi` and a site `lo` are never terms of the +same `lo > hi` test, and the contradiction clause below - which resolves to 0 W, +a battery parked inside a billed quarter - cannot be reached across two +inverters. That is DECISIONS/SAFETY-25-loop-soc-floor.md §0c, made mechanical: +not "guarded against", NOT EXPRESSIBLE. Filing a unit constraint as a site claim +to "keep it simple" is the whole bug, and `actuator_for()` is what stops a +strategy doing it by hand. """ from dataclasses import dataclass, field @@ -50,6 +83,131 @@ P_LOOP = 10 # ordinary grid-following SET = "set" LIMIT = "limit" +# --- actuators, and the two scopes a claim can have ------------------------- +# ⚠️ §6.2: THE GRID CONNECTION IS AN ACTUATOR TOO. It is the one every inverter +# at the site shares, and two inverters regulating against one meter are two +# writers on it - which is the failure this module exists to prevent, one level +# up from the one it was built for. So it gets a name, and claims about it are +# filed on that name rather than on nothing in particular. +ACTUATOR_SITE = "site" + +SCOPE_SITE = "site" # about the grid connection: one of these per site +SCOPE_UNIT = "unit" # about one pack, one inverter, one device + +# ⚠️ THE CLASSIFICATION, AND IT IS THE REAL OUTPUT OF ARB-03. Each entry is read +# off what the claim is ABOUT at its construction site in main.py, not off which +# module builds it. Getting an entry wrong is not cosmetic: a per-inverter +# reserve filed as a site `hi` against the peak guard's site `lo` is `lo > hi`, +# clause 3, 0 W - the battery parked in the billed quarter that SAFETY-25's memo +# calls fatal. The reasoning is recorded here, at the line, because this table is +# where a ninth source will be classified and the reasoning is what a future +# reader needs in order to classify it correctly. +CLAIM_SCOPE = { + # --- SITE: about the grid connection --------------------------------- + # main.py: `Claim.set("loop", P_LOOP, decision.target_w, ...)`. compute() + # regulates `grid_w` - the P1 measurement - to zero. Its subject is the + # meter, and §6.2's whole architecture is ONE integrator on P1 whose output + # is then split. A site claim, and the one the split exists for. + "loop": SCOPE_SITE, + # main.py: `peakguard.claim(...)`. The quantity is the 15-minute average + # OFFTAKE AT THE GRID CONNECTION, which is what the Belgian capacity tariff + # bills. There is one quarter-hour peak per site, not one per pack. + "peak": SCOPE_SITE, + # main.py: `arbitrage.claim(...)`. It buys and sells at the grid against a + # day-ahead price. The price is a property of the connection, and the energy + # it trades crosses the meter. + "arbitrage": SCOPE_SITE, + # main.py: `Claim.set("safety", P_SAFETY, 0.0, "stopped" | "inputs missing")`. + # ⚠️ READ BOTH CONSTRUCTION SITES BEFORE MOVING THIS ONE. Neither is about a + # pack. "stopped" is the operator switching control off for the SITE, and + # "inputs missing" is the SITE's own telemetry gone - the meter this whole + # controller regulates against. Both must stop every inverter, and a site + # SET at P_SAFETY is how that is said once rather than N times. + "safety": SCOPE_SITE, + + # --- UNIT: about one pack, one inverter, one device ------------------- + # main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max, + # "rating")`. The numbers are read off ONE inverter's own reported min/max. + # Another inverter has another rating; there is no site rating. + "device": SCOPE_UNIT, + # main.py: `Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w")`. + # `max_w` is the supervised cap on what this controller may WRITE to a + # setpoint entity - §11.3's "the actuator is capped below the inverter + # rating". It bounds a write to one device, like `device` does and for the + # same reason, so it is scoped like `device`. + "supervised": SCOPE_UNIT, + # main.py: `Claim.set("failsafe", P_SAFETY, 0.0, "sign disagreement")`. + # ⚠️ THE EVIDENCE IS SITE-WIDE AND THE REMEDY IS NOT, and that is why this + # entry needs its reasoning written down. SAFETY-02 compares P1 - a site + # measurement - against `standing`, THE COMMAND THIS CONTROLLER PUT ON THIS + # ACTUATOR. What it concludes is "the unit I am driving is pushing power the + # wrong way", and what it does is park that unit. At N>=2 the same evidence + # cannot be attributed to a sibling nobody here commands, so a site-wide + # remedy would be this watchdog stopping an inverter it never graded. + "failsafe": SCOPE_UNIT, + # main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns + # the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when + # it only shapes. §12's monthly recalibration is a PACK procedure - drain + # this pack to its floor, charge this pack to full, recalibrate this pack's + # coulomb counter. Two packs recalibrate on their own schedules. + "maintenance": SCOPE_UNIT, +} + + +def actuator_for(source: str, unit: str, units=()) -> str: + """Which actuator a claim from `source` belongs on, given the site's roster. + + ⚠️ ROUTE THROUGH THIS, DO NOT PASS `actuator=` BY HAND. An unclassified + source raises `KeyError` here rather than defaulting to the site, which is + the one direction that is not safe to default in: a per-inverter constraint + that lands in the site claim list is exactly the `lo > hi` this card exists + to make unreachable. A ninth claim source has to be classified in + CLAIM_SCOPE before it can be filed, and test_peakguard.py part 10f is the + other half of that - it goes red on the line the ninth append is written. + + ⚠️ AND AT N=1 EVERY SOURCE ROUTES TO THE SITE. That is not the classification + being ignored, it is §6.2's identity: with one inverter the fabricated grid + reading and the battery setpoint are the same number, so the grid connection + and the only unit ARE the same actuator and there is nothing to allocate. + Splitting the list at N=1 would be a behaviour change on a live safety path - + the `failsafe` and `maintenance` SET claims would stop competing with the + site's for the winner, `explain()` would name a different source, and + `loop_is_following()` reads that name. The identity holds only at N=1, so + this function is the ONE place that stops relying on it, the day the roster + grows. + """ + if CLAIM_SCOPE[source] == SCOPE_SITE: + return ACTUATOR_SITE + return ACTUATOR_SITE if len(units) <= 1 else unit + + +def scope_violations(claims) -> list: + """Claims filed on an actuator their source's scope does not permit. + + A list of one-line complaints, empty when the routing is right. This is the + check `actuator_for()` cannot make on its own, because a caller can always + write `actuator=` by hand: it grades the claim list that actually reached the + arbiter, whatever built it. + """ + out = [] + # ⚠️ THE SECOND TEST IS CONDITIONAL ON THE SITE HAVING NAMED UNITS AT ALL, + # and that is not laxity. At N=1 §6.2's identity holds and a per-inverter + # source filed on the site is CORRECT - it is what `actuator_for()` returns. + # The direction that produces `lo > hi` only exists once there is a second + # actuator for the constraint to have belonged to. + split = any(c.actuator != ACTUATOR_SITE for c in claims) + for c in claims: + scope = CLAIM_SCOPE.get(c.source) + if scope is None: + out.append(f"{c.source}: not classified in CLAIM_SCOPE") + elif scope == SCOPE_SITE and c.actuator != ACTUATOR_SITE: + out.append(f"{c.source}: a SITE claim filed on unit {c.actuator!r} - " + f"it would bound one inverter's share instead of the grid") + elif scope == SCOPE_UNIT and c.actuator == ACTUATOR_SITE and split: + out.append(f"{c.source}: a UNIT claim filed on the site while the " + f"site has named units - it can cross a site limit") + return out + @dataclass(frozen=True) class Claim: @@ -60,15 +218,76 @@ class Claim: lo: float = -inf # for LIMIT hi: float = inf # for LIMIT reason: str = "" + # ⚠️ specs.md §6.1 DECLARES THIS FIELD FIRST AND IT IS LAST HERE, ON PURPOSE. + # The spec's point is that a claim NAMES its actuator; the tuple position is + # not the point, and it is load-bearing for two things that are. Every + # construction in this repository passes `source` first, and + # test_peakguard.py part 10f reads `Claim.set(...)`/`Claim.limit(...)`'s + # FIRST POSITIONAL ARGUMENT off main.py's AST to pin the claim-source set - + # the tripwire that guards SAFETY-25's deferred option. Moving `source` off + # position 0 would silently blind that scan, which is this repository's named + # failure mode. So the field is restored where it costs nothing. + # + # ⚠️ AND ITS DEFAULT IS THE SITE, EXPLICITLY. Not None, not "". A claim that + # does not name an actuator is a claim about the grid connection and says so; + # "unspecified" is what the shipped code already had, and it is what made + # every claim implicitly about the one and only actuator. + actuator: str = ACTUATOR_SITE @staticmethod - def set(source: str, priority: int, value: float, reason: str = "") -> "Claim": - return Claim(source, priority, SET, value=value, reason=reason) + def set(source: str, priority: int, value: float, reason: str = "", *, + actuator: str = ACTUATOR_SITE) -> "Claim": + return Claim(source, priority, SET, value=value, reason=reason, + actuator=actuator) @staticmethod def limit(source: str, priority: int, lo: float = -inf, hi: float = inf, - reason: str = "") -> "Claim": - return Claim(source, priority, LIMIT, lo=lo, hi=hi, reason=reason) + reason: str = "", *, actuator: str = ACTUATOR_SITE) -> "Claim": + return Claim(source, priority, LIMIT, lo=lo, hi=hi, reason=reason, + actuator=actuator) + + +@dataclass(frozen=True) +class UnitBounds: + """What one inverter's OWN claims say about that inverter's share. + + STAGE 1's output, one per unit, and the thing ALLOC-01 consumes. It is an + INTERVAL plus the two facts an allocator cannot reconstruct from an interval: + + * `pinned_w` - the value that unit's own winning SET asked for. A SET is not + a bound: `resolve()` CLAMPS a set value into the limits rather than + intersecting with it, and folding a 2500 W drain into `[2500, 2500]` would + turn "clamped to the rating" into "contradicts the rating". So it travels + beside the interval, not inside it. + * `gate` - the priority of that winning SET, or None. Clause 2 applies a + limit only at or above the winner's priority, and at N>=2 the winner may be + a SITE set chosen at stage 2, i.e. AFTER this. Stage 1 states the + constraint and records what it is gated on; deciding whether it applies is + stage 3's business. Guessing here is how a maintenance charge-only `hi` + would come to bind a peak discharge it does not outrank. + + `contradiction` is a unit contradicting ITSELF - its own `lo` above its own + `hi`. It parks that unit and says so; it does not park the site, which is the + entire difference this card is about. + """ + lo: float = -inf + hi: float = inf + lo_src: str | None = None + hi_src: str | None = None + gate: int | None = None + pinned_w: float | None = None + pinned_by: str | None = None + contradiction: bool = False + + def explain(self) -> str: + if self.contradiction: + return (f"CONTRADICTION within the unit ({self.lo_src} lo=" + f"{self.lo:.0f} > {self.hi_src} hi={self.hi:.0f})") + span = (f"[{'-inf' if self.lo == -inf else format(self.lo, '.0f')}, " + f"{'+inf' if self.hi == inf else format(self.hi, '.0f')}]") + if self.pinned_by is not None: + return f"{span} around {self.pinned_by} -> {self.pinned_w:.0f} W" + return span @dataclass @@ -78,6 +297,14 @@ class Resolution: bound_by: str | None = None # which limit actually changed it, if any contradiction: bool = False considered: list = field(default_factory=list) + # --- the three-stage fields. ⚠️ APPENDED, AND DEFAULTED, ON PURPOSE: every + # existing construction of this type is positional in its first five fields + # and `target_w` still means exactly what it meant - the SITE grid target. + # main.py assigns it to `self.target` unchanged. + unit_bounds: dict = field(default_factory=dict) # stage 1, per actuator + shares: dict = field(default_factory=dict) # stage 3, per actuator + allocated: bool = True # False when stage 3 declined to split + scope_errors: list = field(default_factory=list) # mis-routed claims def explain(self) -> str: """One line an operator can act on - this is the point of the module.""" @@ -88,7 +315,79 @@ class Resolution: return f"{self.winner} -> {self.target_w:.0f} W" -def resolve(claims: list) -> Resolution: +def pre_resolve(unit_claims: list) -> UnitBounds: + """STAGE 1. One inverter's own claims -> that inverter's interval. + + Clauses 1 and 2 of the rule, transposed: the unit's highest-priority SET + names what the unit wants and gates which of the unit's limits apply, and the + most restrictive of those limits gives the interval. Read UnitBounds on why + the SET value is not folded into the interval. + + ⚠️ THE INPUT IS ONE UNIT'S CLAIMS AND NOTHING ELSE. A site claim reaching + this function would become a bound on one inverter's share - the mirror image + of the fault this card removes, and the reason `resolve()` partitions rather + than filters twice. + """ + sets = sorted((c for c in unit_claims if c.kind == SET), + key=lambda c: c.priority, reverse=True) + gate = pinned_w = pinned_by = None + if sets: + gate, pinned_w, pinned_by = sets[0].priority, sets[0].value, sets[0].source + + lo, hi = -inf, inf + lo_src = hi_src = None + for c in unit_claims: + if c.kind != LIMIT or (gate is not None and c.priority < gate): + continue + if c.lo > lo: + lo, lo_src = c.lo, c.source + if c.hi < hi: + hi, hi_src = c.hi, c.source + return UnitBounds(lo, hi, lo_src, hi_src, gate, pinned_w, pinned_by, + contradiction=lo > hi) + + +def allocate(target_w: float, bounds: dict) -> tuple: + """STAGE 3. THE SEAM. Returns `(shares, allocated)`. + + ⚠️ THIS IS NOT THE ALLOCATOR AND MUST NOT BECOME ONE HERE. ALLOC-01 owns + that, and §6.2 already fixed its shape: SUM-INVARIANT SHARE ALLOCATION - the + shares sum to `target_w` exactly, each unit is told a function of its OWN + share, and the weights are a comfort choice (SoC, thermals, wear) and + explicitly not a tuning parameter, because the effective gain at the meter is + then a convex combination of the per-unit gains for any split. + + What it does today, and why each half is what it is: + + * WITH AT MOST ONE UNIT there is nothing to divide. The share IS the site + target, and that is not an approximation - it is §6.2's N=1 identity, the + one that makes "the battery moves X W" and "the grid is at X W" the same + sentence. Pass-through. + * WITH TWO OR MORE UNITS IT DECLINES: no shares, `allocated=False`. ⚠️ THE + OBVIOUS PLACEHOLDER - hand `target_w` to each unit - IS THE FAULT ARCH-01 + WAS FILED ABOUT. Each inverter would supply it in full, the loop gain at + the meter would be N times the per-unit gain, and at the gain config.yaml + accepts as its documented maximum that is a permanent +-2 kW limit cycle + with every watchdog in §11.2 reporting healthy. A placeholder that cannot + be told apart from the real thing by any guard we have is worse than no + placeholder, so this one refuses out loud instead. + """ + if len(bounds) <= 1: + only = next(iter(bounds), ACTUATOR_SITE) + return {only: float(target_w)}, True + return {}, False + + +def site_resolve(claims: list) -> Resolution: + """STAGE 2. The SITE claims -> one grid target. + + ⚠️ THIS FUNCTION IS THE SHIPPED ARBITER, UNCHANGED. It is a separate name and + the same code, deliberately: at N=1 nothing is filed on a unit, so `resolve()` + hands it the whole claim list and it answers what it has always answered. + Every check in test_arbiter.py, test_watchdog.py, test_validate.py and + test_peakguard.py replays through here unedited, which is the proof that + ARB-03 changed no behaviour on the live path. + """ sets = sorted((c for c in claims if c.kind == SET), key=lambda c: c.priority, reverse=True) @@ -123,3 +422,39 @@ def resolve(claims: list) -> Resolution: bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source return Resolution(float(target), winner.source, bound_by, False, considered) + + +def resolve(claims: list, *, units=None) -> Resolution: + """The three stages, in order, over one cycle's claims. + + `units` is the site's roster of inverters. Left None it is derived from the + claims themselves, which is right for a caller that files a claim for every + unit every cycle (main.py does - `supervised` is unconditional) and wrong for + one that does not, so ALLOC-01 will pass the real roster rather than let a + silent unit look like a smaller site. + + ⚠️ THE PARTITION IS BY `actuator`, AND IT IS A PARTITION, NOT A FILTER. Every + claim lands in exactly one stage. That is the whole structural argument: a + unit's `hi` and the site's `lo` are never both terms of the same `lo > hi` + test, so SAFETY-25 §0a's cross-inverter contradiction - 0 W inside the billed + quarter - is not something to guard against, it is something that cannot be + written down. + """ + site_claims = [c for c in claims if c.actuator == ACTUATOR_SITE] + if units is None: + units = {c.actuator for c in claims if c.actuator != ACTUATOR_SITE} + units = tuple(sorted(units)) + + # STAGE 1 - each unit's own claims become that unit's interval. + bounds = {u: pre_resolve([c for c in claims if c.actuator == u]) + for u in units} + + # STAGE 2 - the site claims become the grid target. At N=1 `site_claims` IS + # `claims`, so this is the shipped arbiter on the shipped list. + out = site_resolve(site_claims) + + # STAGE 3 - the seam. + out.shares, out.allocated = allocate(out.target_w, bounds) + out.unit_bounds = bounds + out.scope_errors = scope_violations(claims) + return out diff --git a/goodwe_controller/app/arbitrage.py b/goodwe_controller/app/arbitrage.py index 1e9376e..e29770a 100644 --- a/goodwe_controller/app/arbitrage.py +++ b/goodwe_controller/app/arbitrage.py @@ -146,7 +146,7 @@ import math from dataclasses import dataclass from datetime import datetime -from .arbiter import P_ARBITRAGE, Claim +from .arbiter import ACTUATOR_SITE, P_ARBITRAGE, Claim from .price import Absence, price_option _LOG = logging.getLogger("goodwe.arbitrage") @@ -425,5 +425,9 @@ def claim(decision: ArbitrageDecision) -> Claim | None: """ if not decision.trading or decision.power_w is None: return None + # ⚠️ ON THE SITE ACTUATOR, EXPLICITLY. A trade is bought and sold at the + # grid: the price is a property of the connection and the energy crosses the + # meter. So this is a claim on the SITE target, which the allocator then + # divides - not an instruction to any one pack. return Claim.set(SOURCE, P_ARBITRAGE, float(decision.power_w), - decision.action) + decision.action, actuator=ACTUATOR_SITE) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 1478dce..8739953 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -34,7 +34,8 @@ import aiohttp from . import arbitrage from .arbiter import ( - P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve, + P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, + Claim, Resolution, actuator_for, resolve, ) from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk from .clamp import ( @@ -59,6 +60,12 @@ from . import web OPTIONS_PATH = "/data/options.json" _LOG = logging.getLogger("goodwe") +# The one inverter this add-on drives. ⚠️ A NAME, NOT A CONFIGURATION. It exists +# so that a per-inverter claim has an actuator to be filed ON, which is what +# makes the site/unit split something a check can grade rather than a comment. +# ALLOC-01 replaces this with a real roster when inverter B arrives. +UNIT_A = "inverter-a" + # --------------------------------------------------------------------------- # # OPT-04: THE BACKGROUND TARIFF REFRESH, AND WHY IT IS NOT OPTIONAL # @@ -255,6 +262,23 @@ class Controller: # None when P1 is off, or under a transport that has no such counter. self.p1_source = None + # --- ARB-03: which actuators this site has --------------------------- + # ⚠️ ONE INVERTER, AND THE ROSTER IS WRITTEN DOWN RATHER THAN ASSUMED. + # Every claim below is filed on an actuator, and `arbiter.actuator_for()` + # needs to know how many there are: at N=1 §6.2's identity holds - the + # fabricated grid reading and the battery setpoint are the same number - + # so the grid connection and the only inverter ARE one actuator and every + # claim routes to the site. The day this tuple gains a second entry, the + # per-inverter claims stop being site claims on their own, which is + # exactly the seam ALLOC-01 occupies. + # + # ⚠️ INVERTER B DOES NOT EXIST YET. This is not a configuration option + # and must not be turned into one here; ALLOC-01 owns the roster, the + # weights and the split. Naming the unit is all ARB-03 needs, and it is + # what makes a mis-routed claim something a check can see. + self.unit = UNIT_A + self.units = (UNIT_A,) + # live state self.auto = bool(store.data.get("auto", opts.get("auto_start", False))) self.target = 0.0 @@ -415,6 +439,18 @@ class Controller: self.last_write_at = datetime.now(timezone.utc) # -- the cycle ----------------------------------------------------------- + def actuator_of(self, source: str) -> str: + """Which actuator a claim from `source` is filed on this cycle. + + ⚠️ THE CLASSIFICATION IS NOT HERE. `arbiter.CLAIM_SCOPE` decides whether + a source is about the grid connection or about one pack, and it carries + the reasoning for all eight; this method only supplies the roster, which + is the one thing the arbiter cannot know. Routing a claim by hand - + writing `actuator=` at a construction site - bypasses the KeyError that + stops a ninth, unclassified source being filed, so do not. + """ + return actuator_for(source, self.unit, self.units) + async def cycle(self) -> None: """Gather claims, resolve precedence, write the winner. @@ -473,9 +509,17 @@ class Controller: # duplication is deliberate - one of them is the policy, the other is the # last thing between a bug and the hardware. if self.dev_min is not None and self.dev_max is not None: - claims.append(Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max, "rating")) + # PER-INVERTER. The numbers are ONE inverter's own reported + # min/max; a second unit has a second rating and there is no such + # thing as a site rating. See arbiter.CLAIM_SCOPE. + claims.append(Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max, "rating", + actuator=self.actuator_of("device"))) max_w = float(self.o.get("max_w", 2000)) - claims.append(Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w")) + # PER-INVERTER, for the same reason as `device`: `max_w` is the + # supervised cap on what may be WRITTEN to one setpoint entity (§11.3), + # not a bound on the grid connection. + claims.append(Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w", + actuator=self.actuator_of("supervised"))) # --- safety stops ---------------------------------------------------- # ⚠️ SAFETY-02 is claimed FIRST and unconditionally while latched. It is @@ -484,16 +528,31 @@ class Controller: # whatever it thinks it is doing. Re-filed EVERY cycle: claims are not # sticky, and one filed once would lapse the next cycle and hand the # actuator silently back to the loop that was driving the wrong way. + # + # ⚠️ AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE. The + # watchdog compares P1 - one meter, one site - against `standing`, THE + # COMMAND THIS CONTROLLER PUT ON THIS ACTUATOR, and what it concludes is + # "the unit I am driving is pushing power the wrong way". At N>=2 that + # evidence cannot be pinned on a sibling this controller never commanded, + # so a site-wide stop would park an inverter this watchdog never graded. + # See arbiter.CLAIM_SCOPE. if self.sign_wd.tripped: claims.append(Claim.set("failsafe", P_SAFETY, 0.0, - "sign disagreement")) + "sign disagreement", + actuator=self.actuator_of("failsafe"))) + # SITE, both of them, and the two reasons are why. "stopped" is the + # operator switching control off for the whole site; "inputs missing" is + # the SITE's own telemetry gone - the meter this controller regulates + # against. Neither is about a pack, and both must stop every inverter. if not self.auto: - claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped")) + claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped", + actuator=self.actuator_of("safety"))) elif not self.inputs_ok: if self.target != 0.0: self.log_event("inputs missing - commanding 0 W") - claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing")) + claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing", + actuator=self.actuator_of("safety"))) # --- SAFETY-07: the quarter-peak projection guard -------------------- # ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument. @@ -567,6 +626,12 @@ class Controller: standing, max_w, self.freeze_wd.cfg, dev_min=self.dev_min, dev_max=self.dev_max), ) + # SITE, and peakguard.claim() says so on the object it builds. The + # quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION, + # which is what the capacity tariff bills; there is one quarter-hour + # peak per site, not one per pack. ⚠️ IT IS ALSO THE `lo` HALF OF + # SAFETY-25 §0c's table - a site `lo` at this stage, which is what the + # deferred loop reserve's per-inverter `hi` must never be able to cross. peak_claim = (peakguard.claim(self.peak_guard) if self.peak_guard is not None else None) if peak_claim is not None: @@ -630,6 +695,9 @@ class Controller: uncertainty=frozenset({Uncertainty.NO_WEATHER}), charge_w=max_w, discharge_w=max_w, soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct) + # SITE, and arbitrage.claim() says so on the object it builds. It + # buys and sells at the grid against a day-ahead price: the price is + # a property of the connection and the energy crosses the meter. arb_claim = arbitrage.claim(self.arb) if arb_claim is not None: claims.append(arb_claim) @@ -643,8 +711,13 @@ class Controller: # --- maintenance ----------------------------------------------------- if result.owns_setpoint: + # PER-INVERTER. §12's monthly recalibration is a PACK procedure - + # drain THIS pack to its floor, charge THIS pack full, recalibrate + # THIS pack's coulomb counter. Two packs recalibrate on two + # schedules. See arbiter.CLAIM_SCOPE. claims.append(Claim.set("maintenance", P_MAINTENANCE, - float(result.setpoint_w or 0.0), result.phase)) + float(result.setpoint_w or 0.0), result.phase, + actuator=self.actuator_of("maintenance"))) self.sat_count = 0 elif result.charge_only: # Money outranks the maintenance schedule: while the quarter-hour @@ -706,7 +779,10 @@ class Controller: self.peak_fc, float(self.o.get("peak_cap_w", 3500))) if floor > 0: hi, reason = -floor, "cheap-window charge" - claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason)) + # PER-INVERTER, like the SET above and for the same reason - + # the shaping is about the pack being charged. + claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason, + actuator=self.actuator_of("maintenance"))) # --- the grid-following loop ----------------------------------------- if self.auto and self.inputs_ok: @@ -753,7 +829,12 @@ class Controller: + (" (integrator bound)" if decision.i_clamped else "") + (" (output bound)" if decision.clamped else "")), ) - claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason)) + # SITE. compute() regulates `grid_w` - the P1 measurement - to + # zero. Its subject is the meter, and §6.2's architecture is ONE + # integrator on P1 whose output is then SPLIT. This is the claim the + # allocator exists to divide. + claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason, + actuator=self.actuator_of("loop"))) else: # Stopped or blind: no accumulation may survive the outage. self.i_w = 0.0 @@ -763,7 +844,31 @@ class Controller: # de-assert when control is stopped. self.loop_clamped = (False, "") - resolution = resolve(claims) + resolution = resolve(claims, units=self.units) + # ⚠️ N>=2 WITHOUT AN ALLOCATOR IS A REFUSAL, NOT A BROADCAST, and this + # branch is unreachable while the roster holds one unit. It is here + # because the alternative default is the fault ARCH-01 was filed about: + # hand the site target to each inverter and each supplies it IN FULL, so + # the loop gain at the meter becomes N times the per-unit gain - a + # permanent +-2 kW limit cycle at the gain config.yaml documents as its + # maximum, with every watchdog in §11.2 reporting healthy. `allocate()` + # declines rather than invent a split; this is what declining costs. + if not resolution.allocated: + self.log_event(f"ARBITER: no allocator for {len(self.units)} " + f"inverters - refusing to broadcast the site target") + resolution = Resolution(0.0, "failsafe", None, False, + resolution.considered, + unit_bounds=resolution.unit_bounds, + shares={}, allocated=False, + scope_errors=resolution.scope_errors) + if resolution.scope_errors: + # A claim reached the arbiter on an actuator its source's scope does + # not permit. It is a BUG in the filing site, not a state to survive: + # a per-inverter constraint in the site list is the `lo > hi` this + # card removes, and a site constraint on one unit is a bound the + # other unit never gets. Loud, every cycle, and it does not change + # the resolution - the arbiter already routed by `actuator`. + _LOG.error("ARBITER SCOPE: %s", "; ".join(resolution.scope_errors)) if resolution.contradiction: self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log") # --- SAFETY-02's arming gate ------------------------------------------ diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py index 2c48108..e8b75ed 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -372,7 +372,7 @@ not need re-testing. Nothing here indexes a phase. from dataclasses import dataclass -from .arbiter import P_PEAK, Claim +from .arbiter import ACTUATOR_SITE, P_PEAK, Claim from .control import peak_at_risk from .tariff import QUARTER_S @@ -744,7 +744,14 @@ def claim(decision: PeakDecision) -> Claim | None: # when the battery stops charging. Spelling them the same way would make the # SAFETY-24 case indistinguishable from the SAFETY-18 one on the one line # that gets read. + # ⚠️ ON THE SITE ACTUATOR, EXPLICITLY. The billed quantity is the quarter's + # average offtake AT THE GRID CONNECTION - one per site, whatever the site + # has behind it - so this floor bounds the site target and is divided by the + # allocator along with it. It is never a bound on one unit's share: SAFETY-25 + # §0c's table has this `lo` at the SITE stage precisely so the deferred loop + # reserve's per-inverter `hi` cannot become its counterpart in a `lo > hi`. return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w), reason=("blind" if decision.blind else "SoC floor" if decision.floor_limited - else "quarter peak")) + else "quarter peak"), + actuator=ACTUATOR_SITE) diff --git a/goodwe_controller/test_arbiter.py b/goodwe_controller/test_arbiter.py index 11401b7..d9d1810 100644 --- a/goodwe_controller/test_arbiter.py +++ b/goodwe_controller/test_arbiter.py @@ -13,10 +13,13 @@ Read MUTATION.md before you report a survivor. """ import sys +from math import inf from app.arbiter import ( - P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, - Claim, resolve, + ACTUATOR_SITE, CLAIM_SCOPE, P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, + P_MAINTENANCE, P_PEAK, P_SAFETY, SCOPE_SITE, SCOPE_UNIT, UnitBounds, + Claim, actuator_for, allocate, pre_resolve, resolve, scope_violations, + site_resolve, ) fails = [] @@ -28,6 +31,15 @@ def check(name, cond): fails.append(name) +def _raises(fn): + """True if `fn()` raises - a check that something is REFUSED, not defaulted.""" + try: + fn() + except Exception: + return True + return False + + print("basics") r = resolve([]) check("no claims at all means 0 W", r.target_w == 0.0 and r.winner == "failsafe") @@ -99,6 +111,282 @@ check("charging is clamped by the low limit", r.target_w == -1000) r = resolve([Claim.set("loop", P_LOOP, -200), charge_only]) check("charge-only permits charging", r.target_w == -200) +# ============================================================================= +# ARB-03: the actuator specs.md declared, and the three stages +# ============================================================================= +# ⚠️ EVERY CHECK ABOVE THIS LINE IS UNEDITED, AND THAT IS ITSELF THE FIRST +# ACCEPTANCE CRITERION. Not one of them names an actuator, so every claim they +# build is a SITE claim - which is exactly the state main.py is in at N=1 - and +# they still answer what they answered before the field existed. If a line above +# had to be changed to accommodate `actuator`, stage 2 would not be the shipped +# arbiter and the refactor would not be pure. + +print() +print("ARB-03 1. the field, and what its absence means") + +lone = Claim.set("loop", P_LOOP, 900) +check("a claim built without naming an actuator carries one anyway", + lone.actuator == ACTUATOR_SITE) +# ⚠️ THE TYPE ITSELF, NOT ONLY THE TWO HELPERS. Measured: with only the helper +# checked, emptying the DATACLASS default survives this suite untouched, because +# `Claim.set()` and `Claim.limit()` pass their own default down and the field's +# never runs. Anything that builds a `Claim(...)` directly - a future strategy, +# a `dataclasses.replace()` - would have got the unspecified actuator that this +# card exists to remove. +check("...and so does one built through the dataclass directly, which is the " + "constructor the two helpers do not cover", + Claim("loop", P_LOOP).actuator == ACTUATOR_SITE) +check("...and it is an explicit SITE, not None and not an empty string. " + "'unspecified' is what the shipped code had, and 'unspecified' is what " + "made every claim implicitly about the one and only actuator", + isinstance(lone.actuator, str) and lone.actuator != "" + and lone.actuator is not None) +check("...and `source` is still the first positional argument, which is what " + "test_peakguard.py part 10f's AST scan reads off main.py to pin the " + "claim-source set. specs.md declares `actuator` first; the tuple position " + "is not the part of that declaration that carries meaning, and moving " + "`source` off zero would blind the tripwire that guards SAFETY-25", + Claim.limit("device", P_SAFETY, -1, 1).source == "device") + +print() +print("ARB-03 2. the split: eight sources, each classified where it is BUILT") +# The classification is graded against what each claim is ABOUT, restated here +# so that the table in arbiter.py and the expectation in a test are not the same +# sentence read twice. test_peakguard.py part 10f pins this set against main.py's +# own AST, so a ninth source cannot arrive without both files noticing. +SITE_SOURCES = { + "loop": "regulates grid_w, the P1 measurement - one meter, one site", + "peak": "the quarter-hour average offtake AT the grid connection", + "arbitrage": "buys and sells at the grid against a day-ahead price", + "safety": "'stopped' is the site's switch, 'inputs missing' is the " + "site's own meter - neither is about a pack", +} +UNIT_SOURCES = { + "device": "one inverter's own reported min/max rating", + "supervised": "max_w caps what may be WRITTEN to one setpoint entity", + "failsafe": "site-wide evidence, per-unit remedy: SAFETY-02 grades the " + "command THIS controller put on THIS actuator", + "maintenance": "the monthly recalibration is a pack procedure", +} +for _src, _why in sorted(SITE_SOURCES.items()): + print(f" site {_src:<11} {_why}") +for _src, _why in sorted(UNIT_SOURCES.items()): + print(f" unit {_src:<11} {_why}") +check("all eight claim sources are classified, and only those eight", + set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES)) +check("the four that are about the GRID CONNECTION are site claims", + {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES)) +check("the four that are about a PACK, a DEVICE or one write are per-inverter", + {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES)) +check("a source nobody classified cannot be routed at all - actuator_for() " + "raises rather than defaulting to the site, because defaulting to the " + "site is the failure this card removes", + _raises(lambda: actuator_for("reserve", "inverter-a", ("inverter-a",)))) + +print() +print("ARB-03 3. at N=1 the site IS the unit, so nothing moves") +# §6.2's identity: with one inverter the fabricated grid reading and the battery +# setpoint are the same number, so there is one actuator and it is both. +ONE = ("inverter-a",) +TWO = ("inverter-a", "inverter-b") +check("with one inverter every source routes to the site, per-inverter ones " + "included - that is the identity, not the classification being ignored", + all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE)) +check("...and with none configured at all, likewise", + all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE)) +check("with TWO the identity breaks and the split becomes real: the four " + "per-inverter sources route to the unit this controller drives, the four " + "site sources stay on the grid connection", + {s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE} + == {**{s: "inverter-a" for s in UNIT_SOURCES}, + **{s: ACTUATOR_SITE for s in SITE_SOURCES}}) + +print() +print("ARB-03 4. stage 2 IS the shipped arbiter (AC 3)") +# ⚠️ NOT ASSERTED, REPLAYED. Every claim list this file resolves above is run +# through BOTH entry points and every field the shipped code reads off a +# Resolution is compared. `resolve()` at N=1 is `site_resolve()` on the same +# list, so a single edit inside stage 2 would show up in every check above as +# well as here - and one that only affects the wrapper shows up only here. +REPLAY = [ + [], + [Claim.set("loop", P_LOOP, 900)], + [Claim.set("loop", P_LOOP, 900), Claim.set("maintenance", P_MAINTENANCE, 2500)], + [Claim.set("loop", P_LOOP, 4000), + Claim.limit("supervised", P_SAFETY, lo=-2000, hi=2000, reason="max_w")], + [Claim.set("loop", P_LOOP, 4000), Claim.limit("a", P_SAFETY, hi=3000), + Claim.limit("b", P_SAFETY, hi=1500)], + [Claim.set("loop", P_LOOP, 900), charge_only], + [Claim.set("loop", P_LOOP, 900), charge_only, Claim.set("peak", P_PEAK, 2500)], + [Claim.set("peak", P_PEAK, 9000), charge_only, + Claim.limit("device", P_SAFETY, lo=-5000, hi=5000, reason="rating")], + [Claim.set("loop", P_LOOP, 900), Claim.set("safety", P_SAFETY, 0, "inputs missing")], + [Claim.set("loop", P_LOOP, 500), Claim.limit("x", P_SAFETY, lo=1000), + Claim.limit("y", P_SAFETY, hi=200)], + [Claim.set("loop", P_LOOP, -3000), + Claim.limit("supervised", P_SAFETY, lo=-1000, hi=1000)], + [Claim.set("loop", P_LOOP, -200), charge_only], + [Claim.set("arbitrage", P_ARBITRAGE, -2000, "buy"), + Claim.limit("peak", P_PEAK, lo=800.0, reason="quarter peak"), + Claim.limit("supervised", P_SAFETY, lo=-2000, hi=2000, reason="max_w")], +] + + +def _shape(r): + return (r.target_w, r.winner, r.bound_by, r.contradiction, + list(r.considered), r.explain()) + + +_diff = [i for i, cl in enumerate(REPLAY) + if _shape(resolve(cl)) != _shape(site_resolve(cl))] +print(f" replayed {len(REPLAY)} claim lists through both entry points") +check("NOT ONE of them resolves differently - target, winner, binder, " + "contradiction flag, the considered list and the operator line are equal " + "on every one. At N=1 the three stages are the shipped arbiter with " + "bookkeeping either side of it", _diff == [] and len(REPLAY) >= 12) +check("...and stage 3 hands the whole target to the one actuator there is, " + "which is the N=1 identity rather than an allocation", + resolve([Claim.set("loop", P_LOOP, 900)]).shares == {ACTUATOR_SITE: 900.0}) +check("...and says it allocated, because at N<=1 there is nothing to divide", + resolve([Claim.set("loop", P_LOOP, 900)]).allocated) + +print() +print("ARB-03 5. THE CONTRADICTION THAT CANNOT BE WRITTEN DOWN (AC 4)") +# SAFETY-25 §0a, constructed. A per-inverter reserve says "this pack must not +# discharge" (`hi = 0`); the peak guard says "the site must deliver at least +# 1500 W" (`lo = 1500`). At N=1 those are two bounds on ONE number and they +# genuinely cross - which is today's behaviour, and today's hazard, and is left +# exactly as it is. Filed on TWO actuators they are bounds on two different +# numbers, and there is no expression in which one is the other's counterpart. +SITE_SIDE = [Claim.set("loop", P_LOOP, -1500.0, "grid"), + Claim.limit("peak", P_PEAK, lo=1500.0, reason="quarter peak")] +CROSSED = SITE_SIDE + [Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, + reason="charge-only")] +SPLIT = SITE_SIDE + [Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, + reason="charge-only", actuator="inverter-b")] +r_crossed, r_split = resolve(CROSSED), resolve(SPLIT) +print(f" both on one actuator: {r_crossed.explain()}") +print(f" the `hi` on inverter-b: {r_split.explain()} " + f"inverter-b bounds {r_split.unit_bounds['inverter-b'].explain()}") +check("filed on ONE actuator the pair still contradicts and still parks the " + "battery at 0 W in the billed quarter. That is the shipped behaviour and " + "this card does not change it - at N=1 the two really are bounds on one " + "number", r_crossed.contradiction and r_crossed.target_w == 0.0) +check("filed on TWO the site keeps its floor: the unit `hi` is not a term of " + "the site's `lo > hi` test, so there is no contradiction to flag", + not r_split.contradiction and r_split.target_w == 1500.0) +check("...and the constraint is not lost, it is CARRIED - inverter-b's own " + "interval still says 'do not discharge', for the allocator to honour when " + "it divides the 1500 W", r_split.unit_bounds["inverter-b"].hi == 0.0 + and r_split.unit_bounds["inverter-b"].hi_src == "maintenance") +check("...and the site target is bit-for-bit what the site claims alone " + "resolve to, so the unit claim contributed NOTHING to it - which is the " + "'cannot be expressed' rather than 'is guarded against'", + _shape(site_resolve(SITE_SIDE))[:4] == _shape(r_split)[:4]) + +# Swept rather than argued: no per-inverter source, at any bound, on any unit, +# can reach the site's limits. +_SWEEP_N = _SWEEP_BAD = 0 +for _src in sorted(UNIT_SOURCES): + for _unit in ("inverter-a", "inverter-b"): + for _lo, _hi in ((0.0, inf), (-inf, 0.0), (2500.0, inf), (-inf, -2500.0), + (900.0, 100.0)): + _r = resolve(SITE_SIDE + [Claim.limit(_src, P_MAINT_SHAPE, lo=_lo, + hi=_hi, actuator=_unit)]) + _SWEEP_N += 1 + if _r.contradiction or _r.target_w != 1500.0: + _SWEEP_BAD += 1 +print(f" swept {_SWEEP_N} (per-inverter source x unit x bound) filings " + f"against a live site floor: {_SWEEP_BAD} disturbed the site target") +check(f"NOT ONE of the {_SWEEP_N} filings contradicts the site floor or moves " + f"the site target - including the last bound of each row, which is a unit " + f"contradicting ITSELF (lo 900 > hi 100). A unit's own impossible pair " + f"parks that unit; it does not park the site", + _SWEEP_BAD == 0 and _SWEEP_N >= 40) +_self_bad = resolve(SITE_SIDE + [Claim.limit("maintenance", P_MAINT_SHAPE, + lo=900.0, hi=100.0, + actuator="inverter-b")]) +check("...and that unit says so on its own bounds, so the fault is reported " + "where it was made rather than swallowed", + _self_bad.unit_bounds["inverter-b"].contradiction + and "CONTRADICTION" in _self_bad.unit_bounds["inverter-b"].explain()) + +print() +print("ARB-03 6. stage 1: one unit's claims, one unit's interval") +mb = pre_resolve([Claim.limit("device", P_SAFETY, -5000, 5000, "rating"), + Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w"), + Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only")]) +print(f" a unit filing rating, max_w and charge-only: {mb.explain()}") +check("the most restrictive of the unit's own limits is the interval", + mb.lo == -2000 and mb.hi == 0.0 and mb.hi_src == "maintenance" + and mb.lo_src == "supervised") +owned = pre_resolve([Claim.set("maintenance", P_MAINTENANCE, -2500, "charge"), + Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w"), + Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only")]) +print(f" a unit whose maintenance owns the setpoint: {owned.explain()}") +check("a unit's own winning SET gates its own limits exactly as clause 2 does " + "at the site - the P_MAINT_SHAPE ceiling is not applied to a " + "P_MAINTENANCE drain that outranks it", owned.hi == 2000.0) +check("...and the SET's value travels BESIDE the interval, not inside it. " + "Folding -2500 into [-2500, -2500] would turn 'clamped to max_w' into " + "'contradicts max_w', which is a different answer and a wrong one", + owned.pinned_w == -2500 and owned.pinned_by == "maintenance" + and owned.gate == P_MAINTENANCE and not owned.contradiction) + +print() +print("ARB-03 7. the seam is a seam, and NOT an allocator") +check("with no units the whole target goes to the site actuator", + allocate(1500.0, {}) == ({ACTUATOR_SITE: 1500.0}, True)) +check("with one unit the share IS the site target - §6.2's N=1 identity, " + "pass-through", allocate(1500.0, {"inverter-a": UnitBounds()}) + == ({"inverter-a": 1500.0}, True)) +_shares, _ok = allocate(1500.0, {"inverter-a": UnitBounds(), + "inverter-b": UnitBounds()}) +print(f" two units, no allocator: shares={_shares} allocated={_ok}") +check("with two it DECLINES rather than inventing a split. ⚠️ THE OBVIOUS " + "PLACEHOLDER - hand the target to each - IS THE FAULT ARCH-01 WAS FILED " + "ABOUT: each inverter supplies it in full, the gain at the meter becomes " + "N times the per-unit gain, and no watchdog in the table fires", + _shares == {} and _ok is False) +check("...and specifically it does not broadcast", 1500.0 not in _shares.values()) + +print() +print("ARB-03 8. verified by breaking it: a claim routed to the wrong stage (AC 6)") +# The `peak` floor, misfiled on a unit. It is the site's floor - if it lands on +# one inverter's interval the SITE keeps no floor at all, and the loop is free to +# go on charging through the quarter that sets the month's bill. +mis_peak = SITE_SIDE[:1] + [Claim.limit("peak", P_PEAK, lo=1500.0, + reason="quarter peak", + actuator="inverter-b")] +r_mis = resolve(mis_peak) +print(f" peak floor misfiled on inverter-b: {r_mis.explain()} " + f"scope_errors={r_mis.scope_errors}") +check("the mis-routing is CAUGHT: scope_violations names the source and the " + "actuator it does not belong on", len(r_mis.scope_errors) == 1 + and "peak" in r_mis.scope_errors[0]) +check("...and the reason it must be caught, measured rather than argued: with " + "the floor on a unit the SITE has no floor, so the loop charges 1500 W " + "into the quarter the guard was trying to shave", + r_mis.target_w == -1500.0 and resolve(SITE_SIDE).target_w == 1500.0) +check("a UNIT source filed on the site at a site that HAS named units is " + "caught too - that is the direction that produces `lo > hi`", + scope_violations(SITE_SIDE + [ + Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0), + Claim.limit("device", P_SAFETY, -1, 1, actuator="inverter-b")]) != []) +check("...and at N=1, where every source legitimately routes to the site, the " + "same list is clean. The check grades ROUTING, not tidiness", + scope_violations(SITE_SIDE + [ + Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0)]) == []) +check("an unclassified ninth source is a violation wherever it is filed", + scope_violations([Claim.limit("reserve", P_LOOP, hi=0.0)]) != []) +check("and the eight, routed by actuator_for() at a two-inverter site, are " + "clean - which is what says the router and the checker agree", + scope_violations( + [Claim.limit(s, P_SAFETY, -1, 1, + actuator=actuator_for(s, "inverter-a", TWO)) + for s in CLAIM_SCOPE]) == []) + + print() if fails: print(f"{len(fails)} FAILED: {', '.join(fails)}") diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 3ce702a..cf250f8 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -115,7 +115,8 @@ 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, + ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, + P_SAFETY, SCOPE_SITE, SCOPE_UNIT, Claim, resolve, scope_violations, ) from app.control import Tuning, compute, peak_at_risk from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w @@ -1519,6 +1520,14 @@ check("...and its arbiter reason names the SoC floor rather than reusing " # forbidden to cap. Swept rather than argued. SWEEP_NEG = SWEEP_BOTH = SWEEP_N = 0 SWEEP_HI = True +# ⚠️ ARB-03 ADDS ONE DIMENSION TO THIS PROOF RATHER THAN RE-RUNNING IT UNCHANGED. +# `Claim` gained an `actuator`, and the asymmetry that makes this guard safe is +# now TWO facts, not one: `hi` is +inf on every claim it files, AND every claim +# it files is on the SITE actuator. A `lo` that landed on one inverter's interval +# would still have `hi = +inf` and would still never cap a discharge - and would +# leave the SITE, the thing the capacity tariff actually bills, with no floor at +# all. So the same combinations are swept for both. +SWEEP_SITE = True for load in (0.0, CEIL - 500.0, CEIL, CEIL + 300.0, CEIL + 3000.0, LIVE_MAX_W * 3): for elapsed in (30.0, 450.0, 870.0): @@ -1538,6 +1547,8 @@ for load in (0.0, CEIL - 500.0, CEIL, CEIL + 300.0, CEIL + 3000.0, _c = peakguard.claim(_d) if _c is not None and not (isinf(_c.hi) and _c.hi > 0): SWEEP_HI = False + if _c is not None and _c.actuator != ACTUATOR_SITE: + SWEEP_SITE = False print(f" swept {SWEEP_N} (load x elapsed x battery x SoC x floor) " f"combinations, SoC included: {SWEEP_NEG} negative floors, " f"{SWEEP_BOTH} decisions both floor-limited and saturated") @@ -1548,6 +1559,12 @@ check(f"NOT ONE of the {SWEEP_N} combinations produces a negative floor. That " SWEEP_NEG == 0 and SWEEP_N > 600) check("...and `hi` stays +inf on every claim any of them files", SWEEP_HI) +check("...and every one of them is filed on the SITE actuator, which is the " + "other half of the same asymmetry now that a claim has an actuator: the " + "billed quantity is the quarter's offtake AT THE GRID CONNECTION, so a " + "floor that bound one inverter's share would leave the site - the thing " + "the capacity tariff meters - with no floor at all", + SWEEP_SITE) check("...and no combination is both floor-limited and saturated, so the two " "notifications are exclusive on measured inputs and not merely by reading " "the code", SWEEP_BOTH == 0) @@ -2376,9 +2393,17 @@ def cycle_claims(run): seen = [] real = app_main.resolve - def _spy(claims): + def _spy(claims, **kw): + # ⚠️ `**kw` PASSES THE ROSTER THROUGH UNREAD, AND THAT IS THE WHOLE + # ADAPTATION ARB-03 MADE HERE. main.py now calls + # `resolve(claims, units=self.units)`; a spy with a fixed signature would + # raise a TypeError on every real cycle, which is loud, but a spy that + # DROPPED the roster would be silently grading a different arbitration + # from the one main.py performs. What is observed - `claims`, the list + # itself - is unchanged, so the two halves below still see exactly what + # they saw before, and a ninth source still goes red. seen.append(list(claims)) - return real(claims) + return real(claims, **kw) app_main.resolve = _spy try: @@ -2467,6 +2492,100 @@ check("...and replay_loop() folding main.py's TWO P_SAFETY rating limits into " and near(LOOP_MAX_W, read_option("max_w"))) +# --- 10g. ARB-03: AND THE ACTUATOR EACH OF THEM IS FILED ON ----------------- +# ⚠️ THE SAME PIN, ONE DIMENSION WIDER, AND FOR THE SAME REASON. 10f pins WHICH +# sources main.py can file. ARB-03 gave every claim an `actuator`, and a claim +# routed to the wrong stage is a defect 10f cannot see: the source set is +# unchanged when the peak floor is misfiled onto one inverter, and the site is +# then left with no floor at all in the quarter that sets the month's bill. +# +# It is also what keeps SAFETY-25 §0c honest from the other side. That table +# says the deferred loop reserve becomes a PER-INVERTER `hi` at stage A while +# the peak floor stays a SITE `lo` at stage B, and calls the crossing +# structurally impossible. The structure is `CLAIM_SCOPE`, so `CLAIM_SCOPE` is +# pinned to the AST-scanned set rather than trusted to agree with it. +check("every source main.py can file is CLASSIFIED site or per-inverter, and " + "nothing else is. A ninth source now goes red twice - once here for " + "having no classification, once above for being a ninth - and it takes an " + "argument about what the claim is ABOUT to make either line green", + set(CLAIM_SCOPE) == CLAIM_SOURCES) +print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() + if v == SCOPE_SITE)) + + " | per-inverter: " + + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT))) +check("...and the split is the one ARB-03 argued: the four about the GRID " + "CONNECTION are site claims and the four about a pack, a device or one " + "write are per-inverter", + {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} + == {"loop", "peak", "arbitrage", "safety"} + and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} + == {"device", "supervised", "failsafe", "maintenance"}) + +# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a +# real cycle files is a site claim. Observed on the claims main.py's own resolve +# was handed, not assumed from the routing table. +_real_acts = {(c.source, c.actuator) for c in _cl} +print(f" the real FLOOR-LIMITED cycle files: " + + ", ".join(f"{s}@{a}" for s, a in sorted(_real_acts))) +check("at the SHIPPED one-inverter roster every claim a real cycle files is on " + "the SITE actuator, per-inverter sources included. That is §6.2's " + "identity - one inverter means the fabricated grid reading and the " + "battery setpoint are the same number, so there is one actuator and it is " + "both - and it is why ARB-03 is a pure refactor here", + all(a == ACTUATOR_SITE for _, a in _real_acts)) +check("...and the arbiter agrees the routing is legal, on the list main.py " + "actually handed it", scope_violations(_cl) == []) + +# And the day the roster grows, the SAME cycle must partition. Graded through +# main.py's own `actuator_of()` on a real Controller - the method every +# `claims.append` above routes through - rather than through a copy of it. +c_src.units = ("inverter-a", "inverter-b") +_routed = {s: c_src.actuator_of(s) for s in CLAIM_SOURCES} +c_src.units = (app_main.UNIT_A,) +print(" with a second inverter on the roster, main.py routes: " + + ", ".join(f"{s}->{a}" for s, a in sorted(_routed.items()))) +check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second " + "inverter and its OWN routing method splits the eight: `device`, " + "`supervised`, `failsafe` and `maintenance` bind the unit this controller " + "drives, `loop`, `peak`, `arbitrage` and `safety` stay on the grid " + "connection. Swap any one of them - route `peak` to the unit and the site " + "keeps no floor; route `maintenance` to the site and it can cross that " + "floor into `lo > hi` - and this line goes red", + _routed == {"device": "inverter-a", "supervised": "inverter-a", + "failsafe": "inverter-a", "maintenance": "inverter-a", + "loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE, + "arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE}) +check("...and the roster it was restored to is the shipped one, so nothing " + "below inherits a two-inverter Controller", + c_src.units == (app_main.UNIT_A,) and len(c_src.units) == 1) + +# ⚠️ AND THE ROUTING TABLE BEING RIGHT IS NOT THE SAME AS THE CALL SITES USING +# IT RIGHT. `actuator_of("maintenance")` written at the `device` append routes a +# rating limit to whatever `maintenance` routes to, and at the shipped +# one-inverter roster BOTH answers are the site, so nothing above can see it. +# The only place it becomes observable is a cycle at a roster of two - so take +# one, on real claims, spied at main.py's own resolve. +c_src.units = ("inverter-a", "inverter-b") +try: + _, _cl2 = cycle_claims(lambda: run_cycle(c_src)) +finally: + c_src.units = (app_main.UNIT_A,) +print(" a REAL two-inverter cycle files: " + + ", ".join(f"{c.source}@{c.actuator}" for c in _cl2)) +check("a real cycle at a roster of TWO partitions its own claims correctly - " + "every claim main.py filed is on an actuator its source's scope permits. " + "This is the line a mis-typed `actuator_of(...)` at a call site goes red " + "on, and it is the only place one can be seen: at N=1 every source " + "legitimately routes to the site, so a swap there is invisible by " + "construction", len(_cl2) >= 3 and scope_violations(_cl2) == []) +check("...and the partition is the expected one on the sources this state " + "reaches: the two rating limits bind the unit, the loop claim stays on " + "the grid connection", + {c.source: c.actuator for c in _cl2} + == {"device": "inverter-a", "supervised": "inverter-a", + "loop": ACTUATOR_SITE}) + + # ============================================================================== print() print("11. mutation: what defeats these guards, and which check bites") -- 2.54.0 From 38f6680639da60a6371ea8367b588d89a0887d6b Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Wed, 2 Sep 2026 03:46:40 +0200 Subject: [PATCH 2/2] ARB-03: pin all eight actuator call sites statically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The independent review of PR #40 found AC 6 met at three of the eight claim construction sites while the check claimed to cover the class - this repository's named failure mode, a check observing something other than what it claimed, reproduced inside the check written to prevent it. WHAT WAS WRONG. Part 10g's only call-site check was a real two-inverter cycle. That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign watchdog, the guard idle and arbitrage off, so it reaches `device`, `supervised` and `loop` and nothing else - three of the eight direct appends in `Controller.cycle()`. Its own check text nevertheless read "the line a mis-typed `actuator_of(...)` at a call site goes red on", and the PR body's mutation table presented it as covering the class. The reviewer proved the gap on the shipped tree: `actuator_of("maintenance")` -> `actuator_of("peak")` at the maintenance-shaping append left test_arbiter.py, test_peakguard.py and test_arbitrage.py ALL GREEN, while at a roster of two it built a site loop of -1500 W against the guard's site `lo` of 1500 W and the now-misrouted charge-only `hi = 0` - `lo > hi`, arbiter clause 3, 0 W IN THE BILLED QUARTER, SAFETY-25 §0a's fatal case from a one-word typo. The same mutation at the `failsafe` append survived too. The runtime detector does fire - `scope_violations()` names it - but it only logs; nothing graded it. THE FIX, over machinery already present. Part 10f's AST scan already reads each `claims.append(...)` in `Controller.cycle()`. All eight direct appends expose their actuator statically as `actuator=self.actuator_of('')`, so 10g now requires that literal to EQUAL the source the claim is filed under, read as SOURCE TEXT rather than evaluated - `actuator_of("peak")` at the maintenance append is the same VALUE at the shipped roster of one and a different string here. That pins all eight routing sites, on the line the typo is written, in every configuration and at every roster, exactly the way 10f pins the source set. Two further lines keep the scan from going vacuous: every direct append must be readable in that shape (a computed or hand-written actuator fails there), and the ten appends must still account for 10f's eight sources. The two INDIRECT appends carry no `actuator=` at the call site to mis-type. `peak_claim` and `arb_claim` are routed where their claim is built, and are pinned there - `peakguard.claim()` by the SWEEP_SITE sweep over every load x elapsed x battery x SoC x floor combination, `arbitrage.claim()` by test_arbitrage.py. 10g now says so at the line, and says which three of the eight its dynamic cycle actually sees, rather than rounding up. MEASURED, NOT ARGUED. Each of the eight direct appends mutated in turn to a wrong actuator - device->loop, supervised->peak, failsafe->safety, safety(stopped)->device, safety(inputs missing)->maintenance, maintenance(SET)->loop, maintenance(shaping)->peak, loop->supervised - each on a purged tree via tools/mutation_run.sh over all 17 files. All eight RED, every one of them on the new line. TWO SURVIVING MUTATIONS OF THE SAME CLASS, ALSO CLOSED. * `resolve()` did not have to honour the roster it was given: `if units is None:` -> `if True:` left the whole suite green. Ignoring the argument makes a unit that filed nothing this cycle cease to exist - a site of two reads as a site of one and stage 3 passes the whole grid target through to the inverter that spoke, which is ARCH-01's fault with its own evidence removed. test_arbiter.py part 7 now grades it on the one case that separates the two, a SILENT unit. Mutation RED in test_arbiter.py and test_peakguard.py. * main.py's N>=2 refusal was unreachable and ungraded: `if not resolution.allocated:` -> `if False:` left the whole suite green, so the right instinct was one refactor away from being deleted as dead code. The guard is KEPT - the alternative default is the ARCH-01 fault - and new part 10h grades it on the two-inverter cycle that already existed: the 0 W, the `failsafe` winner, the log line naming the roster size, and the write that follows. Mutation RED. AND THE WORDING CORRECTED. main.py does not "refuse to write" on `allocated=False`. It SUBSTITUTES a 0 W failsafe and then calls `write_setpoint(0.0)` like any other cycle. 0 W is the correct fail-toward-inaction direction and the log text is accurate about what it refuses - to BROADCAST the site target - but "refuses to write" overstates the code. Said as what it does, in the CHANGELOG, at the branch in main.py, and in 10h's own check text. `resolve()`'s docstring now says the roster argument has to be HONOURED, which is not the same as existing. 17/17 green on a purged tree. Co-Authored-By: Claude Opus 5 (1M context) --- goodwe_controller/CHANGELOG.md | 50 +++++++-- goodwe_controller/app/arbiter.py | 7 ++ goodwe_controller/app/main.py | 17 ++- goodwe_controller/test_arbiter.py | 38 +++++++ goodwe_controller/test_peakguard.py | 156 ++++++++++++++++++++++++++-- 5 files changed, 252 insertions(+), 16 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index e4068ed..a9b8084 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -39,8 +39,22 @@ numbers.** an allocator** - `ALLOC-01` builds it. With at most one unit it passes the target through (§6.2's N=1 identity); with two or more **it declines, no shares, `allocated=False`**, because the obvious placeholder - hand the target - to each - IS the fault `ARCH-01` was filed about, and `main.py` refuses to - write rather than broadcast. + to each - IS the fault `ARCH-01` was filed about. **What `main.py` then does + is SUBSTITUTE, not abstain**: it replaces the resolution with a 0 W `failsafe`, + logs "refusing to broadcast the site target", and writes that 0 W through + `write_setpoint()` like any other cycle. The refusal is to BROADCAST; the + write still happens, in the fail-toward-inaction direction. Graded end to end + on a real two-inverter cycle by new **part 10h** - the branch is unreachable + on the shipped roster, and replacing it with `if False:` had left all 17 + suites green. +* **`resolve()` HONOURS the roster it is handed, and that is now graded too.** + Left `None` the roster is derived from the claims, which is the documented + default; passed explicitly it is used. Deriving it *always* - ignoring the + argument - makes a unit that filed nothing this cycle cease to exist, so a + site of two reads as a site of one and the seam passes the whole grid target + through to the inverter that spoke: `ARCH-01`'s fault with its own evidence + removed. `test_arbiter.py` part 7 grades it on the one case that separates the + two, a **silent unit**. * **The contradiction is no longer guarded against, it CANNOT BE WRITTEN DOWN.** The partition is by `actuator` and it is a partition: a unit's `hi` and the site's `lo` are never both terms of one `lo > hi` test. Constructed and swept - @@ -68,12 +82,34 @@ numbers.** reading it, which is why `actuator` is the LAST field of `Claim` and not the first. Its spy gained `**kw` so the roster passes through unread rather than being silently dropped. New **part 10g** pins `CLAIM_SCOPE` to the same - eight-source set, so a ninth source now goes red twice; and it grades a **real - two-inverter cycle**, which is the only place a mis-typed `actuator_of(...)` - at a call site is observable - at N=1 every source legitimately routes to the - site, so a swap there is invisible by construction. Verified by breaking it: - building SAFETY-25's Option B as a ninth source still takes 10f red on the + eight-source set, so a ninth source now goes red twice. Verified by breaking + it: building SAFETY-25's Option B as a ninth source still takes 10f red on the line it is written. +* **And the CALL SITES are pinned statically, at all eight of them.** 10g's + first cut graded routing only through a **real two-inverter cycle** and called + that "the line a mis-typed `actuator_of(...)` at a call site goes red on". + **It was not.** That cycle reaches `device`, `supervised` and `loop` and + nothing else - three of the eight direct appends - so a mis-type at the other + five survived every suite in this repository. Proved on the shipped tree by PR + #40's reviewer: `actuator_of("maintenance")` -> `actuator_of("peak")` at the + maintenance-shaping append left `test_arbiter.py`, `test_peakguard.py` and + `test_arbitrage.py` all green while, at a roster of two, it built a site loop + of -1500 W against the guard's site `lo` of 1500 W and the now-misrouted + charge-only `hi = 0` - `lo > hi`, arbiter clause 3, **0 W in the billed + quarter**, SAFETY-25 §0a's fatal case from a one-word typo. That is this + repository's named failure mode - **a check observing something other than + what it claimed** - reproduced inside the check written to prevent it, so it + is closed the way 10f closes it: over the **same AST scan**. All eight direct + appends expose their actuator as a literal `actuator=self.actuator_of('...')`, + so the literal is required to EQUAL the source the claim is filed under - all + eight, on the line the typo is written, in every configuration and at every + roster, whether or not any fixture reaches the state that files them. + **Measured: each of the eight mutated to a wrong actuator in turn, each one + red.** The two INDIRECT appends (`peak_claim`, `arb_claim`) carry no + `actuator=` at the call site to mis-type, and are pinned where their routing + is actually chosen - `peakguard.claim()` by the `SWEEP_SITE` sweep, + `arbitrage.claim()` by `test_arbitrage.py`. 10g's dynamic cycle is kept and + now says which three of the eight it sees instead of claiming the class. **SAFETY-25's open question is ANSWERED: the BMS cut-out is the DELIBERATE and only backstop for demand-driven discharge, and a loop-side reserve floor is diff --git a/goodwe_controller/app/arbiter.py b/goodwe_controller/app/arbiter.py index 31fe144..6cd826a 100644 --- a/goodwe_controller/app/arbiter.py +++ b/goodwe_controller/app/arbiter.py @@ -433,6 +433,13 @@ def resolve(claims: list, *, units=None) -> Resolution: one that does not, so ALLOC-01 will pass the real roster rather than let a silent unit look like a smaller site. + ⚠️ AND THE ARGUMENT HAS TO BE HONOURED, WHICH IS NOT THE SAME AS EXISTING. + Ignore it - derive always - and a unit that filed nothing this cycle ceases + to exist: a site of two reads as a site of one and stage 3 passes the whole + grid target through to the inverter that spoke, which is ARCH-01's fault + with its own evidence removed. That mutation survived the whole suite until + test_arbiter.py part 7 graded it on a SILENT unit. + ⚠️ THE PARTITION IS BY `actuator`, AND IT IS A PARTITION, NOT A FILTER. Every claim lands in exactly one stage. That is the whole structural argument: a unit's `hi` and the site's `lo` are never both terms of the same `lo > hi` diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 8739953..06ab844 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -845,14 +845,27 @@ class Controller: self.loop_clamped = (False, "") resolution = resolve(claims, units=self.units) - # ⚠️ N>=2 WITHOUT AN ALLOCATOR IS A REFUSAL, NOT A BROADCAST, and this - # branch is unreachable while the roster holds one unit. It is here + # ⚠️ N>=2 WITHOUT AN ALLOCATOR IS A REFUSAL TO BROADCAST, NOT A REFUSAL + # TO WRITE, and the difference is the next four lines: the resolution is + # REPLACED by a 0 W failsafe and then written like any other cycle at + # `write_setpoint(self.target)` below. Nothing is skipped. 0 W is the + # fail-toward-inaction direction - the setpoint is commanded to zero + # rather than left wherever the last cycle put it. + # + # This branch is unreachable while the roster holds one unit. It is here # because the alternative default is the fault ARCH-01 was filed about: # hand the site target to each inverter and each supplies it IN FULL, so # the loop gain at the meter becomes N times the per-unit gain - a # permanent +-2 kW limit cycle at the gain config.yaml documents as its # maximum, with every watchdog in §11.2 reporting healthy. `allocate()` # declines rather than invent a split; this is what declining costs. + # + # ⚠️ AND BEING UNREACHABLE IS NOT A REASON TO LEAVE IT UNGRADED. It was: + # PR #40's reviewer replaced this `if` with `if False:` and the whole + # 17-file suite stayed green, so the branch was one refactor away from + # being deleted as dead code. test_peakguard.py part 10h now drives a + # real two-inverter cycle and grades all four halves - the 0 W, the + # `failsafe` winner, the log line, and the write that follows it. if not resolution.allocated: self.log_event(f"ARBITER: no allocator for {len(self.units)} " f"inverters - refusing to broadcast the site target") diff --git a/goodwe_controller/test_arbiter.py b/goodwe_controller/test_arbiter.py index d9d1810..6cd426b 100644 --- a/goodwe_controller/test_arbiter.py +++ b/goodwe_controller/test_arbiter.py @@ -350,6 +350,44 @@ check("with two it DECLINES rather than inventing a split. ⚠️ THE OBVIOUS " _shares == {} and _ok is False) check("...and specifically it does not broadcast", 1500.0 not in _shares.values()) +# ⚠️ AND `resolve()` HAS TO HONOUR THE ROSTER IT IS GIVEN, WHICH IS NOT THE SAME +# THING AS HAVING A `units` PARAMETER. Left None the roster is DERIVED from the +# claims, and that derivation is right only for a caller that files a claim for +# every unit every cycle. Ignore the argument and derive it always - `if units +# is None:` -> `if True:` - and a unit that filed nothing this cycle simply +# CEASES TO EXIST: the site of two looks like a site of one, the seam passes the +# whole grid target through to the single inverter that spoke, and ARCH-01's +# fault is built with the evidence for it removed. That mutation left this whole +# suite green until this check was written (PR #40 review), so it is graded on +# the one observation that distinguishes the two - a SILENT unit. +_silent = resolve([Claim.set("loop", P_LOOP, 900.0), + Claim.limit("device", P_SAFETY, -2000, 2000, "rating", + actuator="inverter-a")], + units=TWO) +print(f" roster of two, only inverter-a files: bounds=" + f"{sorted(_silent.unit_bounds)} allocated={_silent.allocated} " + f"shares={_silent.shares}") +check("resolve() HONOURS the roster it is handed rather than inferring one from " + "who happened to speak. inverter-b filed nothing this cycle and is still " + "a unit: it gets its own interval, and the site is still a site of TWO, " + "so the seam still DECLINES", + sorted(_silent.unit_bounds) == ["inverter-a", "inverter-b"] + and _silent.allocated is False and _silent.shares == {}) +check("...and that is the whole cost of ignoring the argument, stated as the " + "number it would produce: derive the roster from the claims instead and " + "the site is a site of ONE, so the seam passes 900 W through to " + "inverter-a - the silent inverter's absence read as a smaller site", + resolve([Claim.set("loop", P_LOOP, 900.0), + Claim.limit("device", P_SAFETY, -2000, 2000, "rating", + actuator="inverter-a")]).shares + == {"inverter-a": 900.0}) +check("...and left None the roster IS derived from the claims, which is the " + "documented default every N=1 replay above depends on - so the parameter " + "changes the answer only where the two disagree", + resolve([Claim.set("loop", P_LOOP, 900.0), + Claim.limit("device", P_SAFETY, -2000, 2000, "rating", + actuator="inverter-a")], units=None).allocated is True) + print() print("ARB-03 8. verified by breaking it: a claim routed to the wrong stage (AC 6)") # The `peak` floor, misfiled on a unit. It is the site's floor - if it lands on diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index cf250f8..5c13c4a 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -2563,27 +2563,169 @@ check("...and the roster it was restored to is the shipped one, so nothing " # IT RIGHT. `actuator_of("maintenance")` written at the `device` append routes a # rating limit to whatever `maintenance` routes to, and at the shipped # one-inverter roster BOTH answers are the site, so nothing above can see it. -# The only place it becomes observable is a cycle at a roster of two - so take -# one, on real claims, spied at main.py's own resolve. +# ⚠️ AND THE DYNAMIC CYCLE BELOW SEES ONLY THREE OF THE EIGHT DIRECT APPENDS. +# That was THIS PART committing this repository's own named failure - a check +# observing something other than what it claimed - inside the check written to +# prevent it. The first cut of 10g called the two-inverter cycle "the line a +# mis-typed `actuator_of(...)` at a call site goes red on", full stop. IT IS +# NOT. That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign +# watchdog, the guard idle and arbitrage off, so it reaches `device`, +# `supervised` and `loop` AND NOTHING ELSE. PR #40's reviewer mutated the other +# five appends one at a time and every suite in this repository stayed GREEN, +# this part included. At the maintenance-shaping append, +# `actuator_of("maintenance")` -> `actuator_of("peak")` then produced, at a +# roster of two, a site loop of -1500 W against the guard's site `lo` of 1500 W +# and the now-misrouted charge-only `hi = 0`: `lo > hi`, arbiter clause 3, 0 W +# IN THE BILLED QUARTER - SAFETY-25 §0a's fatal case, built by a one-word typo +# under a green suite. The same mutation at the `failsafe` append survived too. +# +# So THE CALL SITES ARE PINNED STATICALLY, over 10f's own AST scan of +# `Controller.cycle()` and for 10f's reason: a static pin sees every append on +# the line it is written, in every configuration, whether or not any fixture +# here reaches the state that files it. All eight direct appends expose their +# actuator as a LITERAL - `actuator=self.actuator_of('')` - so the +# literal is simply required to EQUAL the source the claim is filed under. That +# is what makes "a mis-typed `actuator_of(...)` goes red" a true sentence about +# all eight rather than about the three a fixture happens to reach. + + +def append_actuator(node): + """The actuator expression a DIRECT append routes its claim through. + + ⚠️ RETURNED AS SOURCE TEXT, NOT EVALUATED, and that is the whole point. + `self.actuator_of('device')` and `self.actuator_of('maintenance')` are the + SAME VALUE at the shipped roster and two different routings; only the text + written at the call site tells them apart, which is exactly why every + dynamic check in this file is blind to the swap at N=1. + """ + kw = [k for k in node.keywords if k.arg == "actuator"] + if len(kw) != 1: + return None + return ast.unparse(kw[0].value) + + +# The appends that BUILD their claim in place - the eight whose routing is +# chosen by hand, and the only ones where a typo is possible - against the two +# that append a claim built elsewhere in the method and carry no `actuator=` +# here at all. +_direct = [n.args[0] for n in _appends if isinstance(n.args[0], ast.Call)] +_indirect = [n.args[0].id for n in _appends if isinstance(n.args[0], ast.Name)] +_routing = sorted(((c.args[0].value, append_actuator(c), c.lineno) + for c in _direct), key=lambda r: r[2]) +_misrouted = [r for r in _routing if r[1] != f"self.actuator_of({r[0]!r})"] +print(f" {len(_direct)} direct appends, each read at its own line: " + + ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing)) +check("all eight of `Controller.cycle()`'s DIRECT appends route through " + "`self.actuator_of()`, so this scan can read the routing " + "of EVERY one of them. An append that computed its actuator, or omitted " + "the argument, or hand-wrote a string, fails HERE rather than being " + "silently skipped - which is how a scan like this goes quietly vacuous, " + "and 10f is in this file because that already happened once", + len(_direct) == 8 + and all(a is not None and a.startswith("self.actuator_of('") + for _s, a, _ln in _routing)) +check("⚠️ AND AT EVERY ONE OF THE EIGHT THE LITERAL IT ROUTES THROUGH IS THE " + "SOURCE THE CLAIM IS FILED UNDER. THIS is the line a mis-typed " + "`actuator_of(...)` at a call site goes red on - all eight of them, on " + "the line the typo is written, at every roster, INCLUDING the five " + "appends no fixture in this repository reaches. " + "`actuator_of(\"peak\")` at the maintenance append is a different string " + "here even though it is the same VALUE at the shipped roster of one", + _misrouted == []) +check("...and the only appends this pin does NOT reach are the two that file a " + "claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no " + "`actuator=` at the call site to mis-type. Their routing is pinned where " + "it is CHOSEN instead: peakguard.claim() by the SWEEP_SITE sweep above, " + "over every load x elapsed x battery x SoC x floor combination, and " + "arbitrage.claim() by test_arbitrage.py", + sorted(_indirect) == sorted(CLAIM_BUILDERS) + and len(_direct) + len(_indirect) == len(_appends)) +check("...and between them the ten appends account for exactly the eight " + "sources 10f pins, so the routing pin and the source set are one scan " + "and neither can drift from the other", + {s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values()) + == CLAIM_SOURCES) + +# THE DYNAMIC HALF, kept, and now stated for what it actually observes: a real +# cycle proving the routing reaches the arbiter THROUGH main.py rather than only +# through this file's reading of main.py. It grades three of the eight appends. +# The static pin above is what covers the other five. c_src.units = ("inverter-a", "inverter-b") +_n2_writes_before = len(c_src.hass.writes) try: _, _cl2 = cycle_claims(lambda: run_cycle(c_src)) + _n2_target, _n2_reason = c_src.target, c_src.reason + _n2_events = list(c_src.events) + _n2_writes = c_src.hass.writes[_n2_writes_before:] + _n2_last = c_src.last_write finally: c_src.units = (app_main.UNIT_A,) print(" a REAL two-inverter cycle files: " + ", ".join(f"{c.source}@{c.actuator}" for c in _cl2)) check("a real cycle at a roster of TWO partitions its own claims correctly - " - "every claim main.py filed is on an actuator its source's scope permits. " - "This is the line a mis-typed `actuator_of(...)` at a call site goes red " - "on, and it is the only place one can be seen: at N=1 every source " - "legitimately routes to the site, so a swap there is invisible by " - "construction", len(_cl2) >= 3 and scope_violations(_cl2) == []) + "every claim main.py filed is on an actuator its source's scope permits, " + "observed on the list main.py's own `resolve` was handed. ⚠️ AND THIS " + "SEES THREE OF THE EIGHT APPENDS, NOT THE CLASS: this state files " + "`device`, `supervised` and `loop`, so it is the static pin above and not " + "this line that covers `failsafe`, `safety` and the two `maintenance` " + "appends", len(_cl2) >= 3 and scope_violations(_cl2) == []) check("...and the partition is the expected one on the sources this state " "reaches: the two rating limits bind the unit, the loop claim stays on " "the grid connection", {c.source: c.actuator for c in _cl2} == {"device": "inverter-a", "supervised": "inverter-a", "loop": ACTUATOR_SITE}) +check("...and naming those three is not an accident of this fixture but the " + "reason the static pin exists - five of the eight sources need a state " + "no check in this file reaches", + {c.source for c in _cl2} + == CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak", + "arbitrage"}) + + +# --- 10h. ARB-03: AND WHAT MAIN.PY DOES WITH `allocated=False` -------------- +# ⚠️ AN UNREACHABLE BRANCH THAT NOTHING GRADED. The roster ships as one unit, so +# `allocate()` never declines in production and no fixture went looking: PR +# #40's reviewer replaced main.py's `if not resolution.allocated:` with +# `if False:` and the whole 17-file suite stayed GREEN. The branch is the right +# instinct - the alternative default is the fault ARCH-01 was filed about, N +# inverters each supplying the site target IN FULL, a permanent limit cycle at +# the meter with every §11.2 watchdog reporting healthy - and an ungraded right +# instinct is one refactor away from being deleted as dead code. The +# two-inverter cycle above IS that state, so it is graded on that cycle rather +# than with a fixture of its own. +# +# ⚠️ AND WHAT IT DOES IS SUBSTITUTE, NOT ABSTAIN. main.py does not skip the +# write. It replaces the resolution with a 0 W `failsafe` and then calls +# `write_setpoint(0.0)` like any other cycle. 0 W is the correct +# fail-toward-inaction direction and the log line is accurate about what it +# refuses ("refusing to broadcast the site target"), but "main.py refuses to +# write" would be the same overclaim this part just took out of 10g, so it is +# graded as what it is: a substitution followed by a write. +print(f" at N=2 main.py resolved to {_n2_target:+.0f} W, reason " + f"{_n2_reason!r}, wrote {_n2_writes}") +print(" and logged: " + + next((e for e in _n2_events if "ARBITER" in e), "(nothing)")) +check("with two inverters and no allocator main.py does NOT hand the site " + "target to the roster. The resolution it acts on is the 0 W failsafe it " + "substitutes, and `explain()` says so - this is the branch a `if False:` " + "deleted with the suite still green", + _n2_target == 0.0 and "failsafe" in _n2_reason) +check("...and it says WHY, in the action log, in the operator's own words - " + "the refusal is to BROADCAST, and it names the roster size that caused it", + any("refusing to broadcast the site target" in e and "2 inverters" in e + for e in _n2_events)) +check("...and then it WRITES that 0 W, on the same line as every other cycle. " + "It is a substitution, not an abstention: the setpoint is commanded to " + "zero rather than left wherever the last cycle put it, which is the " + "fail-toward-inaction direction and the opposite of leaving a stale " + "command standing", _n2_writes == [0.0] and _n2_last == 0.0) +check("...and the 0 W is NOT what the site target happened to be - resolve the " + "same claims at a roster of ONE, where the seam passes through, and a " + "real number comes back. That is what says the branch does work rather " + "than agreeing with the resolution by luck", + resolve(_cl2, units=("inverter-a",)).allocated is True + and resolve(_cl2, units=("inverter-a",)).target_w != 0.0) # ============================================================================== -- 2.54.0