SAFETY-38: pin site_standing with shares that cannot answer for the site

`app/main.py:1598`'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.
Coverage only: not one byte of `app/` moves - the shipped `sum` is correct.

WHY THE SUITE COULD NOT SEE IT. `_sign_site`, the only N>=2 site fixture in the
repository, runs shares of about -2500 W EACH - every one individually over its
320 W gate - so sum, mean and max(key=abs) 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 card that names the rule in its own text. At N=1 it is arithmetic
rather than a fixture defect: sum == mean == max == the one share, identically.

THE FIXTURE (test_watchdog.py part 9). 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 shipped
300 W gate. Four candidate reductions, four distinct answers, one over the gate
- sum -500, mean -250, max(key=abs) -280, [0] -220 - asserted as a precondition.
The asymmetry is load-bearing, and the smaller unit is first so [0] stays
distinct from max(key=abs). Gate shipped; only the hold is shortened.

BOTH CONSUMERS. 9b drives the same discrimination at SAFETY-18's threshold: the
site rail is 0.95 x 500 = 475 W, so the sum rails it while 250, 280 and 220 do
not. Shipped: BLIND_FROZEN, floor 0 W. Under both surviving mutations: not blind
at all, no floor filed - the battery may keep importing through a quarter whose
billed average is unobservable. The open quarter is backfilled with the stuck
reading so the mutant cannot reach the same action by the coverage route and
mask the difference. 9c asserts the N=1 identity on a real one-inverter tree.

SAFETY-32 already closes `list(standing.values())[0]` structurally and this card
does not claim it. Its disclosed blind spot - semantics are invisible to a shape
pin, permanently - is why the mean and max(key=abs) needed a behavioural set.

AND THE SEARCH FOR THE NEXT SURVIVOR FOUND ONE. `float(self.target)` - the
arbiter's site target instead of the sum of what stands on the wire - is -11 B
and survives all 20 files on untouched release/1.0 @ f72c48d, because every
fixture that existed was built where the two are the same number. They separate
when a device's reported range is narrower than its max_w: the `device` claim is
filed per actuator and never bounds the site target. Closed here by a
counterfactual that asks for -500 W while -250 W stands.

10 mutations of that one line, each on a fresh full-repo copy mounted :ro,
purged by tools/mutation_run.sh, the edit re-read from disk and the byte delta
printed. Ledger and honest gaps in DECISIONS/SAFETY-38-site-standing-pin.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-05 14:10:30 +02:00
co-authored by Claude Opus 5
parent f72c48dbfd
commit f4229ee022
4 changed files with 849 additions and 3 deletions
+63
View File
@@ -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
@@ -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.
+31 -3
View File
@@ -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
+518
View File
@@ -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)}")