diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 43b3c86..a9b8084 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -2,6 +2,115 @@ ## 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. **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 - + 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. 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 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..6cd826a 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,46 @@ 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. + + ⚠️ 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` + 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..06ab844 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,44 @@ 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 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") + 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..6cd426b 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,320 @@ 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()) + +# ⚠️ 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 +# 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..5c13c4a 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,242 @@ 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. +# ⚠️ 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, " + "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) + + # ============================================================================== print() print("11. mutation: what defeats these guards, and which check bites")