diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index fb56a9f..ad4885a 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -2,6 +2,69 @@ ## Unreleased +**`site_standing` — the one quantity BOTH site guards are asked about — is now +pinned by a fixture whose per-unit shares cannot answer for it (SAFETY-38).** +`app/main.py`'s `site_standing = float(sum(standing.values()))` is the first +argument of [[SAFETY-30]]'s **latching, site-stopping** sign detector *and* the +`commanded_w` handed to [[SAFETY-18]]'s `looks_frozen()`. It was pinned by +**nothing**, and three mutations of it disarmed both guards with the whole suite +green. **This card changes coverage only: not one byte of `app/` moves — the +shipped `sum` is correct and must not be "fixed".** + +* **⭐ THE FIXTURE THE SUITE COULD NOT BUILD: per-unit shares EACH BELOW the + site gate whose SUM clears it.** Two simulated units rated 220 W and 280 W, + both obeying exactly, inverted gain, house importing 2000 W — the site + commands **−500 W against a +2500 W meter** and no single share clears the + 300 W gate. That is the case the site detector exists for, and it is the only + arrangement that can separate `sum` from the mean and from the largest share. + `test_watchdog.py` part 9a. +* **WHY THE EXISTING FIXTURE COULD NOT.** `_sign_site`, the suite's only N≥2 + site fixture, runs shares of about −2500 W **each** — every one individually + over the gate — so sum, mean and largest-share all agree on it. *Its values + happen to be equal, and a check comparing two things that happen to be equal + cannot see the difference it names.* `MUTATION.md` habit 4, ninth instance, + inside the site half of the very card that names it. +* **FOUR CANDIDATE REDUCTIONS, FOUR DISTINCT ANSWERS, ONE OVER THE GATE** — + `sum` −500, mean −250, `max(key=abs)` −280, `[0]` −220 — and that is asserted + as a precondition, not described. The asymmetric ratings are load-bearing: + equal shares collapse three of the four onto one number, and the **smaller** + unit is first so `[0]` stays distinct from `max(key=abs)`. The **gate is the + shipped 300 W**; only the hold is shortened, because the hold is a wall clock. +* **⭐ THE SECOND CONSUMER IS COVERED, NOT SCOPED OUT.** The same value is + `looks_frozen()`'s `commanded_w` at `main.py:1926`. At the same roster the + site rail is 0.95 × 500 = **475 W**, so `sum` = 500 W rails it while 250, 280 + and 220 do not. Shipped: the peak guard goes blind on SAFETY-18's conjunction + and files `floor_w = 0.0`. Under both surviving mutations it is **not blind at + all and files no floor**, so the battery may keep importing through a quarter + whose billed average is unobservable. `test_watchdog.py` part 9b. The open + quarter is deliberately backfilled with the stuck reading so the mutant cannot + reach the same action by the *coverage* route and mask the difference. +* **AT N=1 NOTHING CHANGES, AND IT IS ASSERTED.** On a real one-inverter + controller `standing` has one entry, so `sum`, the mean, `max(key=abs)` and + `[0]` are **the same number** — all three mutations are genuinely *equivalent* + on a shipped install and no N=1 fixture could ever separate them. Printed and + checked, beside the site detector still being absent there. Part 9c. +* **ONE OF THE THREE WAS ALREADY CLOSED, AND THIS CARD DOES NOT RE-COVER IT.** + [[SAFETY-32]]'s package-wide structural pin catches + `list(standing.values())[0]` on the line it is written, at every roster + length, with no fixture. Its own disclosed blind spot — *"SEMANTICS. `min` → + `max` → the mean are edits invisible to any shape pin, permanently"* — is why + the other two needed this card, and it is now confirmed by measurement rather + than taken on trust. +* **⭐ A SURVIVOR THIS CARD WENT LOOKING FOR AND CLOSED.** + `site_standing = float(self.target)` — the arbiter's site *target* instead of + the sum of what actually **stands on the wire** — is **−11 bytes** and + survived the full suite, because in every fixture that existed the two are the + same number. They are not when a device's reported range is narrower than its + `max_w`: the `device` claim is filed **per actuator**, so it bounds each + unit's share and never the site target. A site asking for −500 W while −250 W + stands would then take a **latching site-wide false trip** under its own + 300 W gate. Closed by part 9a's device-clamped counterfactual. +* **10 mutations of that one line, each on a fresh full-repo copy mounted `:ro`, + bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the + byte delta printed. All 10 red.** Ledger and the honest gaps in + `DECISIONS/SAFETY-38-site-standing-pin.md`. + **The add-on configuration screen now speaks English and Dutch instead of raw option keys (UX-01).** There was no `translations/` directory at all, so Home Assistant fell back to rendering the option KEYS — an operator opening diff --git a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md new file mode 100644 index 0000000..443277a --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md @@ -0,0 +1,237 @@ +# SAFETY-38 — pinning `site_standing`, the one quantity both site guards are asked about + +**Status: DECIDED 2026-09-05.** `app/main.py`'s +`site_standing = float(sum(standing.values()))` is **correct and is not +changed**. What this card adds is the thing it never had: a **behavioural** +fixture whose per-unit shares **cannot** answer for the site, so that a change +of *meaning* in that reduction goes red instead of green. + +Filed with SAFETY-38, card `6a9ac95f2b78646e5a76d2e9`. It is a **coverage** +card: not one byte of `app/` moves. + +--- + +## 0. The line, and its two consumers + +```python +site_standing = float(sum(standing.values())) # app/main.py:1598 +``` + +`standing` is `{unit name -> the value THIS controller last put on that unit's +setpoint entity}` — `inv.last_write`, i.e. the number **after** +`clamp_to_rating()` has applied `max_w` and the device's own +`[dev_min, dev_max]`. Two safety inputs are derived from it and nothing else in +`cycle()` is: + +| consumer | where | what it does with it | +|---|---|---| +| **[[SAFETY-30]]**'s site sign detector | `self.site_sign_wd.evaluate(site_standing, self.grid, …)` (main.py:1619) | **latches and stops the whole site** when the total command opposes the meter for `site_sign_disagree_s` | +| **[[SAFETY-18]]**'s freeze conjunction | `looks_frozen(unchanged_s, site_standing, max_w, …)` inside `peakguard.decide(frozen=…)` (main.py:1926) | takes the peak guard **blind** and files `floor_w = 0.0` — the battery may not import while the billed quarter is unmeasurable | + +Both are P0 paths. **Neither was pinned to the reduction.** + +## 1. What survived, and what the rescope measured + +The card opened with three demonstrated live mutants, all green across the whole +suite. Re-measured on 2026-09-05 against `release/1.0` @ `f72c48d`, after +[[SAFETY-32]] merged: + +| mutation of `site_standing` | before SAFETY-32 | after SAFETY-32 | +|---|---|---| +| `list(standing.values())[0]` — one unit's share | survives | **CAUGHT** by SAFETY-32's package-wide structural pin | +| `sum(...) / max(1, len(standing))` — the mean | survives | **still survives** | +| `max(standing.values(), key=abs)` — the largest share | survives | **still survives** | + +SAFETY-32's pin catches *a slice or index standing between a roster-derived +value and its reduction*, on the line it is written, at every roster length, +with no fixture — which is exactly `[0]`. Its own disclosed blind spot says why +the other two are out of reach: + +> *SEMANTICS. `min` → `max` → the mean are edits invisible to any shape pin, +> permanently. Only the behavioural sets can see them.* + +`sum` → mean and `sum` → `max(key=abs)` are **semantic**: identical shape, +different meaning. **No shape pin will ever close them.** That disclosure was +accurate, and is now confirmed by measurement rather than taken on trust. + +## 2. Why the suite could not see it — habit 4, inside the site half of the very card that names it + +`test_watchdog.py`'s `_sign_site` is the **only** N≥2 site fixture in the +repository. It runs at `site_sign_disagree_w: 320` with shares of roughly +−2500 W **each**. Every one of them is individually far over the gate, so on +those numbers + +``` +sum = −5000 W over mean = −2500 W over max = −2500 W over +``` + +— **all three reductions agree**, and a check whose two candidate answers happen +to be equal cannot observe a change that swaps them. That is `MUTATION.md`'s +third failure mode, and this is its ninth instance. + +At a roster of **one** it is not a fixture defect but arithmetic: +`sum == mean == max == the one share`, identically, so all three mutations are +genuinely **equivalent** on a shipped install. No N=1 fixture can ever separate +them; §3c asserts that rather than leaving it to the reader. + +## 3. The fixture, and the numbers it is built on + +Two **simulated** units rated 220 W and 280 W, both obeying exactly, inverted +gain, house importing 2000 W. Both stand at their own rails, so `standing` is +`{-220, -280}` in roster order and the meter reads +2500 W. + +| reduction | value | vs the SHIPPED 300 W gate | +|---|---|---| +| `sum` | **−500 W** | **OVER** → trips | +| `sum / len` (the mean) | −250 W | under → silent | +| `max(…, key=abs)` | −280 W | under → silent | +| `list(…)[0]` | −220 W | under → silent | + +**Four distinct magnitudes — 500, 250, 280, 220 — of which exactly one clears +the gate.** The asymmetry is load-bearing twice: equal shares would collapse +mean, max and first onto one number, and putting the **smaller** unit first is +what keeps `[0]` distinct from `max(key=abs)`. The gate is left at its shipped +default so the fixture is graded against the magnitude an install runs; only the +**hold** is shortened (325 s → 2 s), because the hold is a wall clock and the +campaign behind 325 s is measured on the rig, not here. + +⚠️ **220 W and 280 W are SIMULATED ratings chosen for that arithmetic and for +nothing else.** They are not measurements. In particular they are **not** +inverter B, which does not physically exist; B is a GW5048D-ES, serial +`55048ESU22AW0992`, **4600 W from its datasheet** (`goodwe/NOTES.md` §1.1b). + +### 3a. The site sign detector + +Shipped tree: the site latches, commands 0 W on every inverter, and the sentence +an operator reads is +`SIGN_DISAGREEMENT: commanded -500 W against a meter reading +2500 W …`. No +per-unit watchdog trips — every unit is faithfully executing its share, which is +the asymmetry the site detector exists for. + +Under the mean and under `max(key=abs)`: **nothing trips, ever.** The reviewer's +tree was still commanding −500 W of charge into a +2500 W import at t = 595 s +with `following=True`. + +Two counterfactuals sit beside it so the trip is attributable: + +* the **same** inverted-gain site whose shares sum to −250 W — under the gate — + does **not** trip, so the trip above is the sum crossing 300 W and not the + mere presence of a second inverter; +* a site whose **device range is narrower than `max_w`** asks for −500 W while + only −250 W stands, and does **not** trip — see §5. + +### 3b. The second consumer, covered rather than scoped out + +`site_standing` is also `looks_frozen()`'s `commanded_w`. The same +discrimination applies at a different threshold: the site rail is +`RAIL_FRAC * site_max_w` = 0.95 × 500 = **475 W**. + +| reduction | value | vs the 475 W rail | +|---|---|---| +| `sum` | **500 W** | **RAILED** → `BLIND_FROZEN`, `floor_w = 0.0` | +| the mean | 250 W | under → silent | +| `max(…, key=abs)` | 280 W | under → silent | +| `list(…)[0]` | 220 W | under → silent | + +The sign is flipped from §3a on purpose: the gain is the **shipped positive** +one and the frozen meter reads import, so the site *discharges* +500 W against a ++2500 W import — the two agree in sign, SAFETY-30 has nothing to say, and this +half grades SAFETY-18 alone. Reusing §3a's inverted gain would have the site +sign latch park everything at 0 W, and the freeze conjunction could then never +be true: a check measuring the *other* guard's remedy. + +⚠️ **The open quarter is backfilled with the same stuck reading, and that is +not decoration.** Without it the block is mostly uncovered, `blindness()` +answers `BLIND_COVERAGE`, and the mutant reaches *the same action by a different +route* — a check on the action alone could not then see the difference it names. +Seeded, the measurement is honest: + +* shipped: `blind = BLIND_FROZEN`, `floor_w = 0.0` — no charging while the + billed quarter is unobservable; +* both mutations: **not blind at all**, `floor_w = None` — the site may go on + importing through a quarter whose billed average cannot be measured. That is + the capacity-tariff hazard SAFETY-18 exists for, silently disarmed. + +The counterfactual comes from the **same run**: on the earlier cycles the +command had not yet reached the rail and the guard did not report SAFETY-18, +with the meter equally frozen throughout — so the verdict is produced by the +command magnitude `site_standing` carries and not by `unchanged_s` alone. A low +gain (0.05, the schema floor) is what buys those below-the-rail cycles. + +### 3c. At N=1, nothing changes — asserted + +A real one-inverter controller is driven through the same frozen-meter +scenario. Its `standing` has a single entry, so `sum`, the mean, +`max(key=abs)` and `[0]` are printed and asserted to be **one number**; the +site sign detector is still absent (part 7 counts the calls on the object); and +SAFETY-18 still reaches `BLIND_FROZEN` on that same value. The claim "the +shipped install is unaffected" now rests on the roster rather than on the +reader. + +## 4. Mutation ledger + +Every run: a **fresh full-repo copy**, `__pycache__` and `*.pyc` excluded, +mounted `:ro`, graded by `tools/mutation_run.sh` (which purges, prints the tree +fingerprint, and verifies no cache appears during the run). The mutated line was +**re-read from disk** after the edit and the byte delta printed. Shipped +`app/main.py` is 232 520 bytes. + +| # | mutation of `site_standing` | Δ bytes | verdict | +|---|---|---|---| +| M1 | `sum(...) / max(1, len(standing))` — the mean | +24 | **RED** | +| M2 | `max(standing.values(), key=abs)` — largest share | +9 | **RED** | +| M3 | `list(standing.values())[0]` — one share | +4 | **RED** | +| M4 | `min(standing.values(), key=abs)` — smallest share | +9 | **RED** | +| M5 | `sum(abs(v) for v in ...)` — magnitude, sign lost | +16 | **RED** | +| M6 | `-sum(standing.values())` — sign flipped | +1 | **RED** | +| M7 | `float(self.target)` — the arbiter's site target | **−11** | **was a SURVIVOR — now RED**, see §5 | +| M8 | `sum(standing.values()) / 2.0` | +6 | **RED** | +| M9 | `sum(standing.values()) * 2.0` | +6 | **RED** | +| M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** | + +M3 is listed for completeness only. SAFETY-32 already closes it **structurally** +and this card does not claim it; that the behavioural fixture also catches it is +a free consequence of the same discriminating numbers, not a second pin. + +## 5. The survivor this card went looking for, and closed + +M7 is not one of the card's three. It was found by asking the question the +recurring defect demands — *what mutation would my new checks NOT see?* — and it +is the answer: **−11 bytes**, and it survived every check above **and the whole +20-file suite**, because in every fixture built so far the arbiter's site target +and the sum of what actually stands on the wire are **the same number**. + +They are not the same number when a setpoint device's own reported range is +narrower than its `max_w`. The `device` P_SAFETY claim is filed **per actuator** +(`actuator_of("device", inv)`), so it bounds each unit's **share** and never the +site target, and `clamp_to_rating()` clips again on the way out. On two units +rated 220/280 W whose devices report ±120/±130 W, the site asks for **−500 W** +and **−250 W** stands. + +Which one the guards mean is already written down, in both of them: SAFETY-02 +grades *"the command standing on the wire"*, and `looks_frozen()`'s docstring +says `commanded_w` is *"the EMITTED setpoint — what the arbiter resolved and +`write_setpoint()` put on the wire — not the loop's raw wish"*. [[SAFETY-20]] is +the whole card about the difference. Those sentences are now made to bite: on +the site target the detector fires on a site standing at −250 W against a ++2250 W meter — **under its own 300 W gate** — which is a latching, site-wide +false trip on an install whose inverters simply report a narrower range than the +operator typed. + +## 6. Honest gaps + +* **A mutation that changes the SIGN CONVENTION of `standing` itself** — i.e. in + `main.py`'s `stood = inv.last_write if inv.last_write is not None else + inv.target`, not on the reduction line — is **not** in this ledger. It is a + different line and a different card; §3a's obedience precondition would see + some of it, but that is not a measured claim and is not made here. +* **The freeze half is graded at ONE roster length (2) and one rail.** The + `RAIL_FRAC` constant, `rail_ceiling_w()`'s device-bound conjunction and + `rail_unreachable()` are `test_freeze.py`'s subject and are untouched here. +* **The site hold is 2 s, not the shipped 325 s.** The window's derivation is a + rig campaign (`DECISIONS/SAFETY-30-per-unit-sign.md` §3B) and this file does + not re-grade it. The **gate** is the shipped 300 W, which is the coordinate + this card is about. +* **Everything about inverter B here is simulated and labelled so.** No number + in this document or in the fixture is a measurement of a device that does not + yet exist. diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index c95bbed..36e863d 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -135,12 +135,13 @@ manufactures them from a **fixture**: the check runs, the assertion is evaluated, the sentence above it is true — and the numbers it compares are arranged so that the failure it describes could not have changed the answer. -It is not hypothetical and it is not rare. EIGHT instances in two weeks; four +It is not hypothetical and it is not rare. NINE instances in two weeks; four were found by review, two - CFG-04's fixture pair - by an author's own mutation battery, which is the cheaper place, one by review again, in the one place nobody had thought to point the rule at: not a check in the suite, but a -COMMITTED EVIDENCE ARTIFACT four documents cite - and the eighth INSIDE THE FIX -WRITTEN TO CLOSE THE SEVENTH. +COMMITTED EVIDENCE ARTIFACT four documents cite - the eighth INSIDE THE FIX +WRITTEN TO CLOSE THE SEVENTH, and the ninth inside the SITE HALF of the card +that names the rule in its own text. ⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance, MAINT-05's, is the one that says why: the defect is not carelessness about @@ -234,6 +235,33 @@ same place twice, the check is decoration. assertion cannot fail is exactly the thing this section is about. Two habits follow, and both are habit 5 and 6 below. +* **SAFETY-38** — `main.py`'s + `site_standing = float(sum(standing.values()))`, the first argument of + SAFETY-30's latching site sign detector *and* SAFETY-18's `looks_frozen()` + input. `test_watchdog.py`'s `_sign_site` is the suite's **only** N≥2 site + fixture, and it runs shares of about **−2500 W each** — every one + individually over the 320 W gate it is configured with. On numbers like those + `sum`, the mean and `max(key=abs)` **all agree**, so the reduction could be + swapped for either of the other two and the site guard went **blind with all + 19 files green** — still commanding −500 W of charge into a +2500 W import at + t = 595 s with `following=True`. At a roster of **one** it is worse and it is + not a fixture defect at all: `sum == mean == max == the one share` + *identically*, so every such mutation is genuinely **equivalent** there and no + N=1 fixture could ever separate them. The fixture that separates them is the + one the detector exists for — **per-unit shares EACH BELOW the gate whose SUM + clears it** — and it is now `test_watchdog.py` part 9, at asymmetric ratings + so that all four candidate reductions give four **distinct** answers. + + ⚠️ **AND THE CARD'S OWN SEARCH FOR ITS NEXT SURVIVOR FOUND ONE, WHICH IS THE + POINT OF ASKING.** Having built the fixture, the question *what mutation would + this NOT see?* produced `float(self.target)` — the arbiter's site target + instead of the sum of what stands on the wire — at **−11 B**, green across the + whole suite, because every fixture in the repository was built where the two + are the same number. They separate only when a device's reported range is + narrower than its `max_w`, since the `device` claim is filed **per actuator** + and never bounds the site target. Closed in the same commit, by a + counterfactual that asks for −500 W while −250 W stands. + Note what these have in common with a masked rung: the mutation *runs*, and the suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly can see it, because the code under test really did behave identically on the diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index af55acc..930564a 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -1993,6 +1993,524 @@ check("...and the counterfactual again: the same site with nothing latched " not _bn_n["banner"].startswith("FAILSAFE") and _bn_n["banner"] != _bn_b["banner"]) + +print() +print("9. SAFETY-38: `site_standing` - the ONE quantity BOTH site guards are") +print(" asked about - pinned by shares that CANNOT answer for it") +# ⚠️ WHAT THIS SECTION PINS, AND WHY NOTHING ABOVE IT COULD. +# `main.py`'s `site_standing = float(sum(standing.values()))` is the first +# argument of SAFETY-30's site sign detector AND the `commanded_w` handed to +# SAFETY-18's `looks_frozen()`. It was pinned by NOTHING. Three mutations of it +# were demonstrated live against a full green suite; SAFETY-32's package-wide +# structural pin has since closed `list(standing.values())[0]` - a slice +# standing between a roster-derived value and its reduction, which it sees on +# the line it is written, at every roster length, with no fixture. DO NOT +# RE-COVER THAT ONE HERE. The two that remain are SEMANTIC: +# +# sum(...) / max(1, len(standing)) the MEAN +# max(standing.values(), key=abs) the LARGEST SHARE +# +# Identical shape, different meaning - which is precisely the blind spot +# SAFETY-32 disclosed in its own words ("SEMANTICS. `min` -> `max` -> the mean +# are edits invisible to any shape pin, permanently"). No shape pin will ever +# close them. Only a behavioural fixture can, and this is it. +# +# ⚠️ WHY PART 5 ABOVE CANNOT SEE THEM, AND IT IS THIS REPOSITORY'S RECURRING +# DEFECT INSIDE THE SITE HALF OF THE VERY CARD THAT NAMES IT. `_sign_site` is +# the only N>=2 site fixture in the suite and it runs at +# `site_sign_disagree_w: 320` with shares of about -2500 W EACH - every one of +# them individually far OVER the gate. On values like those the sum, the mean +# and the largest share all clear it, so all three reductions agree, and a +# check whose two candidate answers happen to be EQUAL cannot observe a change +# that swaps them. MUTATION.md habit 4. At a roster of ONE it is worse: there +# `sum == mean == max == the one share` identically, so every mutation is +# EQUIVALENT there - see 9c, where that identity is asserted rather than +# assumed. +# +# ⚠️ SO THE NUMBERS BELOW ARE CHOSEN TO DISCRIMINATE, AND HERE IS THE +# ARITHMETIC THEY ARE CHOSEN FOR. Two simulated units rated 220 W and 280 W +# stand at their own rails, so `standing` is {-220, -280} in roster order: +# +# sum -500 W |500| > 300 OVER the gate -> TRIPS +# mean -250 W |250| < 300 under it -> silent +# max(..., key=abs) -280 W |280| < 300 under it -> silent +# list(...)[0] -220 W |220| < 300 under it -> silent +# +# FOUR DISTINCT MAGNITUDES - 500, 250, 280, 220 - of which exactly ONE clears +# the SHIPPED 300 W gate. The asymmetry is load-bearing twice over: equal +# shares would collapse mean, max and first onto one number and the fixture +# could not tell them apart, and putting the SMALLER unit first is what keeps +# `[0]` distinct from `max(key=abs)`. +# +# ⚠️ THE RATINGS ARE SIMULATED AND ARE NOT ANY REAL INVERTER'S. 220 W and 280 W +# are chosen for the arithmetic in that table and for nothing else. In +# particular they are NOT inverter B, which does not physically exist; B's +# rating is 4600 W FROM ITS DATASHEET (goodwe/NOTES.md 1.1b) and no number in +# this section is a measurement of anything. +# +# ⚠️ THE GATE IS THE SHIPPED ONE AND THE HOLD IS NOT. `site_sign_disagree_w` is +# left at its default so this fixture is graded against the magnitude an +# install actually runs; `site_sign_disagree_s` is shortened from 325 s to 2 s +# because the hold is a wall clock and the campaign behind 325 s is measured on +# the rig, not here. The gate is the coordinate this section is about. +from app import peakguard as _pg38 # noqa: E402 +from app.freeze import rail_w as _rail_w38 # noqa: E402 +from app.p1 import P1Sample as _P1Sample38 # noqa: E402 +import time as _time38 # noqa: E402 + +_S38_A_W, _S38_B_W = 220.0, 280.0 # SIMULATED. See the note above. +_S38_LOAD_W = 2000.0 +_S38_FROZEN_METER_W = 2500.0 + + +def _s38_roster(*ratings): + """A CFG-04 `inverters:` roster, which is the shape an N>=2 install runs. + + ⚠️ NOT `_sign_site`'s "build one from the flat keys and append a second". + `Controller.__init__` builds `Tuning` FROM the roster - `max_w=site_max_w` - + so a unit appended afterwards leaves the site loop bounded at ONE unit's + rating. That is harmless where part 3 uses it and fatal here: the whole + fixture is a site command standing at the ROSTER's rail. + """ + return [{"name": name, "setpoint_entity": f"number.sp_{tag}", + "soc_entity": f"sensor.soc_{tag}", "batt_entity": f"sensor.batt_{tag}", + "max_w": w, "bms_soc_floor": 10} + for name, tag, w in zip(("inverter-a", "inverter-b", "inverter-c"), + ("a", "b", "c"), ratings)] + + +class _S38Plant: + """Units that obey INSTANTLY and a meter that is `load - sum(measured)`. + + The same modelling choice part 3 makes and for the same reason: an instant + plant removes the one mechanism that could make an obedient unit look + disobedient, so what remains to be graded is the ATTRIBUTION. + """ + + def __init__(self, load_w=_S38_LOAD_W): + self.load_w = float(load_w) + self.writes = [] + self.calls = [] + self.ctrl = None + + async def call(self, domain, service, data): + self.calls.append((domain, service, data)) + + async def limits(self, entity): + return None + + def measured(self): + if self.ctrl is None: + return [] + return [float((i.last_write if i.last_write is not None else i.target) or 0.0) + for i in self.ctrl.inverters] + + async def number(self, entity, invert=False): + m = self.measured() + if entity == "sensor.grid": + return self.load_w - sum(m) + for i, tag in enumerate(("a", "b", "c")): + if entity == f"sensor.soc_{tag}": + return 50.0 + if entity == f"sensor.batt_{tag}": + return m[i] if i < len(m) else None + return None + + async def set_number(self, entity, value): + self.writes.append((entity, float(value))) + return True + + +def _s38_ctrl(hass, ratings, store=None, **over): + opts = {"meter_entity": "sensor.grid", "meter_source": "off", + "inverters": _s38_roster(*ratings), + "gain": -0.6, + # The per-unit pair, distinct from the site pair in BOTH + # coordinates, for part 3's reason: an equality below must be able + # to fail on the magnitude alone or on the hold alone. + "unit_sign_disagree_w": 250, "unit_sign_disagree_s": 1, + # ⚠️ `site_sign_disagree_w` DELIBERATELY ABSENT - the shipped 300 W + # default is the gate this whole section is graded against. + "site_sign_disagree_s": 2, + # SAFETY-31 is given a long window so the SITE is not stopped by + # circulation while this section grades the sign attribution. It is + # isolation, not a claim that SAFETY-31 is silent here. + "circulation_hold_s": 3600, "auto_start": True} + opts.update(over) + c = Controller(opts, hass, store if store is not None else _MemStore(), + _P(), stop=asyncio.Event()) + for inv in c.inverters: + inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w + c.auto = True + hass.ctrl = c + return c + + +def _s38_stood(c): + return [inv.last_write if inv.last_write is not None else inv.target + for inv in c.inverters] + + +print() +print("9a. the SITE sign detector: per-unit shares EACH BELOW the gate whose") +print(" SUM clears it - the case the site detector exists for") + + +async def _s38_site(): + hass = _S38Plant() + c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W)) + seen = [] + for _ in range(20): + await c.read_inputs() + await c.cycle() + seen.append(dict(grid=c.grid, stood=_s38_stood(c), + meas=[i.batt for i in c.inverters], + site=c.site_sign_wd.tripped, + units=[i.sign_wd.tripped for i in c.inverters], + following=c.following)) + await asyncio.sleep(0.2) + return c, seen + + +_c38, _s38seen = asyncio.run(_s38_site()) +# The last sample BEFORE the trip, named rather than indexed - part 5's lesson. +# After the trip every command is 0 W, so a fixed index reads the REMEDY. +_pre38 = [x for x in _s38seen if not x["site"]] +_mid38 = _pre38[-1] +_GATE38 = _c38.site_sign_wd.cfg.min_w +_share38 = list(_mid38["stood"]) +_red38 = {"sum": sum(_share38), + "mean": sum(_share38) / max(1, len(_share38)), + "max(key=abs)": max(_share38, key=abs), + "[0]": _share38[0]} +print(f" last cycle before the trip: stood " + f"{[round(x) for x in _share38]} measured " + f"{[round(x) for x in _mid38['meas']]} meter {_mid38['grid']:+.0f} W") +print(f" site gate {_GATE38:.0f} W (SHIPPED default), hold " + f"{_c38.site_sign_wd.cfg.hold_s:.0f} s") +for _k, _v in _red38.items(): + print(f" {_k:>13} = {_v:+7.1f} W " + f"{'OVER' if abs(_v) > _GATE38 else 'under'} the gate") +check("PRECONDITION: the gate really is the SHIPPED 300 W, so this fixture is " + "graded against the magnitude an install runs and not against a number " + "chosen to make it pass", + _GATE38 == float(SIGN_OPTION_DEFAULTS["site_sign_disagree_w"]) == 300.0) +check("⚠️ PRECONDITION, AND IT IS THE WHOLE OF SAFETY-38. EVERY per-unit share " + "is BELOW the site gate and their SUM is OVER it. `_sign_site` above " + "cannot make this statement - its shares are each individually over the " + "gate, so sum, mean and largest-share all agree there and its values " + "cannot observe a change that swaps them", + len(_share38) >= 2 + and all(abs(s) < _GATE38 for s in _share38) + and abs(sum(_share38)) > _GATE38) +check("...and the FOUR candidate reductions give FOUR DISTINCT answers on it, " + "of which exactly ONE clears the gate. That is what makes this fixture " + "able to tell `sum` from the mean, from the largest share and from the " + "first share - and it is the property the existing site fixture lacks", + len({round(v, 6) for v in _red38.values()}) == 4 + and [k for k, v in _red38.items() if abs(v) > _GATE38] == ["sum"]) +check("PRECONDITION: EVERY inverter is obeying - each unit's measured power " + "agrees in sign with its own share - and the site is charging into an " + "importing house. Without this the trip below could be a per-unit fault " + "in disguise rather than the site fault this grades", + all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"])) + and sum(_mid38["stood"]) * _mid38["grid"] < 0 + and _mid38["following"] is True) +check("⚠️ THE SITE WATCHDOG TRIPS ON THE SUM, and no per-unit watchdog does. " + "Under `sum -> mean` and `sum -> max(key=abs)` NOTHING trips, ever: the " + "reviewer's tree was still commanding -500 W of charge into a +2500 W " + "import at t = 595 s with following=True. This is the check both of " + "those mutations go red on", + _c38.site_sign_wd.tripped + and not any(i.sign_wd.tripped for i in _c38.inverters)) +print(f" {_c38.site_sign_wd.reason!r}") +check("...and the sentence an operator reads quotes THE SUM, digit for digit - " + "-500 W, not -250, not -280, not -220. A mutation that still tripped but " + "on some other reduction would put a different number in the store, the " + "action log and the banner, and this is where that shows", + f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason + and "against a meter reading" in _c38.site_sign_wd.reason) +check("...and it stops the WHOLE site, through the `sign_site` claim by name " + "rather than by some other guard happening to stop it", + _c38.target == 0.0 and "sign_site" in _c38.reason + and all(i.target == 0.0 for i in _c38.inverters) + and _c38.store.data.get("site_sign_tripped") is True) +# ⚠️ THE COUNTERFACTUAL, and it is what says the check above is produced by the +# SUM crossing the gate rather than by two inverters being present. Same plant, +# same roster shape, same everything - except the units are rated so that the +# sum lands UNDER the gate. Nothing may trip. +_S38_SMALL = (120.0, 130.0) # sum 250 W: under the 300 W gate + + +async def _s38_under(): + hass = _S38Plant() + c = _s38_ctrl(hass, _S38_SMALL) + for _ in range(20): + await c.read_inputs() + await c.cycle() + await asyncio.sleep(0.2) + return c + + +_c38u = asyncio.run(_s38_under()) +print(f" counterfactual: stood {[round(x) for x in _s38_stood(_c38u)]} " + f"sum {sum(_s38_stood(_c38u)):+.0f} W meter {_c38u.grid:+.0f} W") +check("...and the counterfactual: the SAME inverted-gain site whose shares sum " + "to 250 W - under the gate - does NOT trip, over the same 20 cycles. So " + "the trip above is the sum crossing 300 W and not the mere presence of a " + "second inverter", + abs(sum(_s38_stood(_c38u))) < _GATE38 + and not _c38u.site_sign_wd.tripped + and not any(i.sign_wd.tripped for i in _c38u.inverters)) +# ⚠️ AND A SECOND COUNTERFACTUAL, WHICH EXISTS BECAUSE OF A MUTATION THE CHECKS +# ABOVE COULD NOT SEE AND THIS SEARCH WENT LOOKING FOR. +# `site_standing = float(self.target)` - the arbiter's SITE TARGET instead of +# the sum of what actually stands on the wire - is -11 bytes, and it survived +# every check above AND the whole 20-file suite, because in every fixture so +# far the two are the same number. THEY ARE NOT THE SAME NUMBER when a setpoint +# device's own reported range is narrower than its `max_w`: the `device` +# P_SAFETY claim is filed PER ACTUATOR (main.py, `actuator_of("device", inv)`), +# so it bounds each unit's SHARE and never the site target, and +# `clamp_to_rating()` clips again on the way out. Here the site asks for -500 W +# and -250 W stands. +# +# WHICH ONE THE GUARDS MEAN IS ALREADY WRITTEN DOWN, IN BOTH OF THEM: SAFETY-02 +# grades "the command standing on the wire", and `looks_frozen()`'s docstring +# says `commanded_w` is "the EMITTED setpoint - what the arbiter resolved and +# `write_setpoint()` put on the wire - not the loop's raw wish". SAFETY-20 is +# the whole card about the difference. This check is those sentences made to +# bite: on the site target the detector fires on a site that is standing at +# -250 W against a +2250 W meter - under its own 300 W gate - which is a +# LATCHING, site-stopping false trip on an install whose inverters simply +# report a narrower range than the operator typed. +_S38_DEV = (120.0, 130.0) # narrower than the 220/280 W ratings + + +async def _s38_devclamped(): + hass = _S38Plant() + c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W)) + for inv, d in zip(c.inverters, _S38_DEV): + inv.dev_min, inv.dev_max = -d, d + for _ in range(20): + await c.read_inputs() + await c.cycle() + await asyncio.sleep(0.2) + return c + + +_c38d = asyncio.run(_s38_devclamped()) +_stood38d = _s38_stood(_c38d) +print(f" device-clamped: site target {_c38d.target:+.0f} W but only " + f"{[round(x) for x in _stood38d]} = {sum(_stood38d):+.0f} W stands, " + f"meter {_c38d.grid:+.0f} W") +check("PRECONDITION: the site TARGET and the EMITTED total are DIFFERENT " + "numbers here, on opposite sides of the gate - -500 W asked, -250 W " + "standing. Without that separation this check and the one above compare " + "two values that happen to be equal, which is the defect this card is " + "the sixth instance of", + abs(_c38d.target) > _GATE38 and abs(sum(_stood38d)) < _GATE38) +check("⚠️ ...and NOTHING TRIPS, because the quantity graded is what STANDS ON " + "THE WIRE and not what the arbiter asked for. `float(self.target)` is " + "-11 bytes, survives the other 20 files, and turns this ordinary " + "narrow-rating install into a latching site-wide false trip", + not _c38d.site_sign_wd.tripped + and not any(i.sign_wd.tripped for i in _c38d.inverters) + and _c38d.target != 0.0) + + +print() +print("9b. THE SECOND CONSUMER: the same value is SAFETY-18's `commanded_w`") +print(" inside `looks_frozen()`, and both mutations blind it too") +# ⚠️ THIS IS THE AC THE CARD REFUSES TO LET BE SCOPED OUT SILENTLY. +# `site_standing` is handed to `looks_frozen()` at main.py's `peakguard.decide( +# ..., frozen=...)` call, where it is the EMITTED command graded against the +# site rail. Both surviving mutations shrink it there too, and nothing went red. +# +# THE RAIL ARITHMETIC, and it is the same discrimination as 9a at a different +# threshold: the site rail is `RAIL_FRAC * site_max_w` = 0.95 * 500 = 475 W. +# +# sum 500 W >= 475 RAILED -> BLIND_FROZEN, floor 0 W +# mean 250 W < 475 silent +# max(..., key=abs) 280 W < 475 silent +# list(...)[0] 220 W < 475 silent +# +# ⚠️ THE SIGN IS FLIPPED FROM 9a ON PURPOSE. Here the gain is the SHIPPED +# POSITIVE one and the frozen meter reads IMPORT, so the site discharges +500 W +# against a +2500 W import - the two agree in sign, SAFETY-30 has nothing to +# say, and this section grades SAFETY-18 alone. Reusing 9a's inverted gain +# would have the site sign latch park everything at 0 W and the freeze +# conjunction could then never be true: a check measuring the other guard's +# remedy. +# +# WHAT THE MUTATIONS COST HERE, measured rather than asserted: on the shipped +# tree the guard goes blind and files `floor_w = 0.0` - "the battery may not +# import while the billed quarter cannot be measured". Under both mutations it +# is NOT BLIND AT ALL and files `floor_w = None`, so the site may go on +# charging through a quarter whose billed average is unobservable. That is the +# capacity-tariff hazard SAFETY-18 exists for, silently disarmed. + + +class _S38Src: + """The transport's own `unchanged_s`, which only homewizard_local has.""" + + def __init__(self, unchanged_s): + self.unchanged_s = float(unchanged_s) + + +def _s38_feed(c, w, ts=None, mono=None): + now = ts if ts is not None else datetime.now(timezone.utc) + c.p1.submit(_P1Sample38( + ingest_ts=now, + ingest_mono=mono if mono is not None else _time38.monotonic(), + telegram_ts=now, source="homewizard_local", + import_w=max(w, 0.0), export_w=max(-w, 0.0), net_w=w, + per_phase_w=(w,), per_phase_import_w=(max(w, 0.0),))) + + +def _s38_seed_quarter(c, w): + """Backfill the OPEN quarter with the same stuck value, at 10 s spacing. + + ⚠️ THIS IS NOT DECORATION. Without it the open block is mostly uncovered and + `blindness()` answers BLIND_COVERAGE - which is still blind, still floor + 0 W, and would therefore MASK the mutations: the mutant would reach the + same action by a different route and a check on the action alone could not + see the difference it names. Seeded, the mutant is not blind at ALL, which + is the honest measurement of what the mutation costs. A meter that has been + stuck at the same reading all quarter is also what SAFETY-18 is about. + """ + wall = _time38.time() + q0 = (int(wall) // 900) * 900 + mono0 = _time38.monotonic() - (wall - q0) + t = float(q0) + while t < wall: + _s38_feed(c, w, datetime.fromtimestamp(t, timezone.utc), mono0 + (t - q0)) + t += 10.0 + + +def _s38_frozen_ctrl(hass, ratings, unchanged_s=600.0, gain=0.05): + # ⚠️ `gain` 0.05 IS THE SCHEMA FLOOR AND IT IS HERE TO BUY A RAMP. At the + # shipped 0.6 the loop is at the rail on cycle 0 and the fixture has no + # BELOW-the-rail cycles to use as its counterfactual. At 0.05 the site + # climbs 125 W a cycle and crosses 475 W part way through the run, so the + # SAME run carries both answers. + c = _s38_ctrl(hass, ratings, meter_source="homewizard_local", + p1_host="meter.local", gain=gain) + c.p1_source = _S38Src(unchanged_s) + return c + + +async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=8): + hass = _S38Plant() + c = _s38_frozen_ctrl(hass, ratings) + _s38_seed_quarter(c, meter_w) + seen = [] + for _ in range(n): + _s38_feed(c, meter_w) + await c.read_inputs() + await c.cycle() + pg = c.peak_guard + seen.append(dict(stood=_s38_stood(c), + blind=None if pg is None else pg.blind, + floor=None if pg is None else pg.floor_w)) + await asyncio.sleep(0.05) + return c, seen + + +_c38f, _s38f = asyncio.run(_s38_frozen((_S38_A_W, _S38_B_W))) +_RAIL38 = _rail_w38(_c38f.site_max_w, + dev_min=_c38f.site_dev_bound("dev_min"), + dev_max=_c38f.site_dev_bound("dev_max")) +_last38 = _s38f[-1] +_below38 = [x for x in _s38f if abs(sum(x["stood"])) < _RAIL38] +print(f" site rail {_RAIL38:.0f} W of site_max_w {_c38f.site_max_w:.0f} W; " + f"meter stuck at {_S38_FROZEN_METER_W:+.0f} W for " + f"{_c38f.p1_source.unchanged_s:.0f} s (freeze hold " + f"{_c38f.freeze_wd.cfg.hold_s:.0f} s)") +for _x in _s38f: + print(" stood %-16s sum %+7.1f W floor=%s blind=%r" + % ([round(v) for v in _x["stood"]], sum(_x["stood"]), + _x["floor"], _x["blind"])) +check("PRECONDITION: at the rail EVERY per-unit share is BELOW the site rail " + "and their SUM is at or over it - the same discrimination as 9a, at " + "SAFETY-18's threshold instead of SAFETY-30's. `[0]`, the mean and the " + "largest share are 220, 250 and 280 W against a 475 W rail", + len(_last38["stood"]) >= 2 + and all(abs(s) < _RAIL38 for s in _last38["stood"]) + and abs(sum(_last38["stood"])) >= _RAIL38 + and len({round(v, 6) for v in + (sum(_last38["stood"]), + sum(_last38["stood"]) / len(_last38["stood"]), + max(_last38["stood"], key=abs), + _last38["stood"][0])}) == 4) +check("⚠️ THE PEAK GUARD GOES BLIND ON SAFETY-18's CONJUNCTION and forbids " + "charging - floor 0 W - because the SITE's total command is at the rail " + "while the meter has stopped moving. Under `sum -> mean` and " + "`sum -> max(key=abs)` it is not blind at all and files no floor, so the " + "battery may keep importing through an unmeasurable billed quarter. This " + "is the second consumer, and it is covered rather than scoped out", + _last38["blind"] == _pg38.BLIND_FROZEN and _last38["floor"] == 0.0) +check("...and the counterfactual, from the SAME run: on the earlier cycles the " + "site command had not yet reached the rail, and the guard did NOT report " + "SAFETY-18 - with the meter equally frozen the whole time. So the " + "verdict is produced by the COMMAND MAGNITUDE `site_standing` carries, " + "not by `unchanged_s` alone", + len(_below38) >= 2 + and all(x["blind"] != _pg38.BLIND_FROZEN for x in _below38)) + + +print() +print("9c. AT ONE INVERTER NOTHING CHANGES, and 'nothing' is asserted") +# ⚠️ THE REASON THIS SUBSECTION EXISTS. At a roster of one `standing` has a +# single entry, so `sum`, the mean, `max(key=abs)` and `[0]` are THE SAME +# NUMBER: all three mutations are genuinely EQUIVALENT there and no fixture at +# N=1 can ever separate them. That is a fact about the arithmetic, and leaving +# it implicit is how "the shipped install is covered" gets believed. It is +# asserted here, on a real one-inverter controller's own standing command, so +# the claim rests on the roster rather than on the reader. +_S38_ONE = (500.0,) + + +async def _s38_one(): + hass = _S38Plant() + c = _s38_frozen_ctrl(hass, _S38_ONE) + _s38_seed_quarter(c, _S38_FROZEN_METER_W) + for _ in range(8): + _s38_feed(c, _S38_FROZEN_METER_W) + await c.read_inputs() + await c.cycle() + await asyncio.sleep(0.05) + return c + + +_c38one = asyncio.run(_s38_one()) +_one38 = _s38_stood(_c38one) +_onered38 = (sum(_one38), sum(_one38) / max(1, len(_one38)), + max(_one38, key=abs), _one38[0]) +print(f" roster of {len(_c38one.inverters)}: stood " + f"{[round(v) for v in _one38]} -> sum/mean/max/first = " + f"{tuple(round(v, 1) for v in _onered38)}") +check("at a roster of ONE the four reductions are ONE NUMBER - so all three " + "mutations of `site_standing` are EQUIVALENT on a shipped install and no " + "N=1 fixture could ever tell them apart. That is why this card needed a " + "roster of two, stated as an assertion rather than left to the reader", + len(_c38one.inverters) == 1 + and len({round(v, 6) for v in _onered38}) == 1) +check("...and the one-inverter site still reaches SAFETY-18's verdict on that " + "same number, unchanged: the command is at the rail, the meter has " + "stopped moving, the guard is blind and forbids charging", + _c38one.peak_guard is not None + and _c38one.peak_guard.blind == _pg38.BLIND_FROZEN + and _c38one.peak_guard.floor_w == 0.0 + and abs(sum(_one38)) >= _rail_w38( + _c38one.site_max_w, + dev_min=_c38one.site_dev_bound("dev_min"), + dev_max=_c38one.site_dev_bound("dev_max"))) +check("...and the SITE sign detector is still absent at a roster of one - part " + "7 counts the calls; this only records that a fixture built the other " + "way round, from an `inverters:` roster, reaches the same state", + not _c38one.site_sign_wd.tripped + and _c38one.site_sign_wd.disagree_since is None) + print() if fails: print(f"{len(fails)} FAILED: {', '.join(fails)}")