SAFETY-30: grade each inverter against its own power, and the site's total against the meter
checks / unit (pull_request) Successful in 4m38s
checks / unit (pull_request) Successful in 4m38s
THE DEFECT, in one line. `inv.sign_wd.evaluate(stood, self.grid, ...)` compared
the command THIS controller put on ONE actuator against what the WHOLE SITE is
exchanging with the network. At a roster of one those are two views of the same
thing - specs.md §6.2's identity - and that is what makes the check sound and
what earned it the best record in the safety table (NOTES.md: 93 % agreement
while healthy against 4 % during runaway, four observed episodes, zero false
positives). At two inverters they are not, and it fails in BOTH directions:
FALSE TRIP inverter A commanded -1500 W while the meter reads +1500 W is
evidence of nothing when inverter B is discharging 3000 W exactly
as told. Nothing is faulty and the highest-value check fires.
MISSED the converse, and it is worse. A unit commanded +1500 W and
MEASURED at -1500 W, beside a sibling at +1500 W, leaves P1
exactly where a healthy site leaves it. The summation destroys the
evidence and no threshold recovers it.
DEV-02 had already shipped the per-INSTANCE half - one SignWatchdog per
inverter, namespaced latch, a trip on B no longer latching A. What was left was
the COMPARISON, and it is two questions rather than one:
PER UNIT this unit's standing command against ITS OWN measured power
(`Inverter.batt`, holding register 0x0518, per-instance since
DEV-02). Conclusion: "the actuator I am driving is not doing what I
told it" - a fact about one device, so the remedy stays the per-unit
`failsafe` SET that arbiter.CLAIM_SCOPE has classified SCOPE_UNIT
from the day it was written. That entry read "the evidence is
site-wide and the remedy is not"; the first half is now simply true.
SITE the SUM of the standing commands against the meter, as a new
`sign_site` SET at P_SAFETY on the site actuator, latched under flat
`site_sign_tripped` / `site_sign_reason`. This is the 2026-08-20
fault day and NO per-unit check can see it, because every inverter
may be faithfully executing its share of a number that is itself
wrong. It stops every inverter and the banner names the site.
WHAT IT IS NOT, said here because two guards that overlap without saying so is
how a false-alarm generator gets built. It is not SAFETY-31: circulation.py
compares the units AGAINST EACH OTHER and catches them pushing power THROUGH
each other, which nets to zero at the meter and about which no single command is
evidence. This compares each unit against ITS OWN COMMAND. They overlap in
exactly one scenario - a unit reversing while its sibling pulls the other way -
and test_watchdog.py asserts on that fixture that both fire and what each
concludes. It is also not the saturation freeze, which is a MAGNITUDE test
(|command - measured| > 500 W) that shapes the loop and fires on a merely
saturated unit; this is a DIRECTION test that latches and parks one.
THE FOUR NUMBERS, RE-DERIVED AT TWO INVERTERS - one carried with a justification
and one refused. 1890 closed-loop runs on admin/goodwe's TEST-19 two-master rig
in which NOTHING is faulty, swept over the whole range config.yaml accepts for
`gain` and `slew_w`, across load reversals with write staggers and flapping
loads, and RUN AT BOTH ROSTERS so the N=1 arm is the control:
unit_sign_disagree_w 250 `saturation_w / 2`. The check gates on both sides,
opposed, so the smallest gap it can see is 500 W -
exactly where compute()'s own anti-windup test
already says the inverter is not following. It
cannot fire on a divergence the control law calls
ordinary tracking. Worst legitimate opposition at
250 W: 10.0 s; the curve is nearly flat, because a
unit's own power chases its own command.
unit_sign_disagree_s 85 8.5x that 10.0 s, 2.8x the firmware's own 30 s
link veto (inverter.LINK_STALE_S), under half the
180 s site hold.
site_sign_disagree_w 300 CARRIED. Its argument is the meter's noise floor
and the 15 W deadband; the roster touches neither,
and the campaign agrees - the gate opens in 994 of
1050 runs at two inverters and 955 of 1050 at one.
site_sign_disagree_s 325 NOT CARRIED, and this is the number the card
exists to not assume. The worst legitimate
crossing at 300 W is 110 s with one inverter and
215 s with TWO, because the command must travel a
rail that is the roster sum at a `slew_w` that is
not - reachable at gain 0.05, slew_w 50 and a
2.2 kW load reversal, all schema-legal. 180 s
would have shipped a false trip. 325 s is 1.51x
the measured worst, inside the 900 s billing
quarter and inside every episode of the fault day.
AT ONE INVERTER NOTHING CHANGES, and "nothing" is asserted rather than assumed.
The two questions are the same sentence there, so only the detector that shipped
is armed: same comparison against the meter, same 300 W / 180 s, same reason
string byte for byte, same namespaced latch and flat rollback mirror, same
`failsafe` claim, same banner, same single status row - and the site detector is
NEVER CALLED, counted on the object over twelve cycles.
VERIFIED BY BREAKING IT. Thirteen mutations on fresh per-mutation trees with no
__pycache__, mounted :ro, with the battery refusing to report at all unless the
baseline is green. Twelve red on the first pass. M4 - the site half armed at N=1
as well - SURVIVED, because the check read `disagree_since is None` and the unit
detector trips first, parks the inverter and leaves the site standing at 0 W:
the assertion ran, the sentence was true, and the data could not reach the
failure it named. Replaced with a call counter on the object; M4 is red now.
Recorded as a survivor in DECISIONS/SAFETY-30-per-unit-sign.md §5, with the
first battery's own RED BASELINE - a shared mutation directory read mid-copy -
discarded rather than reported.
RECORDED, NOT FIXED: the site comparison is defeated by a square-wave load whose
half period is about one control cycle, which holds it opposed for ~1000 s. The
N=1 control arm is what makes that interpretable - it is identical at the roster
this add-on ships, so it is a property of comparing a lagged command against a
meter and has nothing to do with the second inverter. It wants an alternation
test rather than a number, and a card of its own.
SAFETY-24's lo-only asymmetry proof re-run unchanged: 1296 combinations, 0
negative floors, `hi` +inf on every claim, every claim on the SITE actuator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
# SAFETY-30 — what the sign watchdog compares once there is more than one inverter, and the numbers it compares with
|
||||
|
||||
**Status: DECIDED 2026-09-04.** The one comparison becomes **two**, asked of the
|
||||
evidence that can answer each: **each unit's standing command against that
|
||||
unit's own measured power** (register 0x0518, per-instance since [[DEV-02]]),
|
||||
and **the sum of the standing commands against the meter**. At a roster of one
|
||||
nothing changes at all — the two questions are the same sentence there, and the
|
||||
detector that ships is the only one armed.
|
||||
|
||||
This document records the argument for that split, the campaign the four numbers
|
||||
were read off, **a number that was carried and a number that refused to be**, and
|
||||
the mutation ledger. Filed with SAFETY-30, card `6a9a1828e844f22fabf7fe9c`.
|
||||
|
||||
---
|
||||
|
||||
## 0. The half that was already built, and the half this card is
|
||||
|
||||
[[DEV-02]] shipped the **per-instance** half and nothing here re-tests it: each
|
||||
`Inverter` owns its own `SignWatchdog`, the latch is namespaced under
|
||||
`unit/<name>/sign_tripped`, the flat pre-DEV-02 pair is maintained as a rollback
|
||||
view, and a trip on B no longer latches A. `test_inverter.py` part 3 grades that.
|
||||
|
||||
What was left is **the comparison itself**, and it was one line:
|
||||
|
||||
```python
|
||||
inv.sign_wd.evaluate(stood, self.grid, self.following, now) # every unit
|
||||
```
|
||||
|
||||
`stood` is the command **this controller put on THIS actuator**. `self.grid` is
|
||||
what the **whole site** is exchanging with the network. At one inverter those are
|
||||
two views of one thing — §6.2's identity — which is what makes the check sound
|
||||
and what earned its record. At two they are not, and it fails in **both**
|
||||
directions:
|
||||
|
||||
| | what happens | why no threshold fixes it |
|
||||
|---|---|---|
|
||||
| **false trip** | A commanded −1500 W while the meter reads +1500 W, because B is discharging 3000 W exactly as told | the meter is answering for a unit A's command says nothing about |
|
||||
| **missed fault** | B commanded +1500 W and **measured** at −1500 W, beside A at +1500 W — the meter sits where a healthy site leaves it | the summation **destroys** the evidence |
|
||||
|
||||
NOTES.md calls SAFETY-02 the highest-value check in the safety table: 93 %
|
||||
agreement while healthy against 4 % during runaway, four observed episodes, zero
|
||||
false positives. **That record was earned at N=1 on the site comparison.** The
|
||||
first row above spends it; the second row is the guard being absent when it
|
||||
matters.
|
||||
|
||||
## 1. The two questions, and the two remedies
|
||||
|
||||
> **PER UNIT** — this unit's standing command against **its own** measured power.
|
||||
> Conclusion: *the actuator I am driving is not doing what I told it.* Evidence
|
||||
> about one device, so the remedy is that device: the `failsafe` SET, which
|
||||
> `arbiter.CLAIM_SCOPE` has classified `SCOPE_UNIT` since it was written.
|
||||
>
|
||||
> **SITE** — the **sum** of the standing commands against the meter. Conclusion:
|
||||
> *the site as a whole is driving the wrong way* — an inverted gain, an
|
||||
> integrator wound past the rail, a strategy on the wrong side of the meter.
|
||||
> That is the 2026-08-20 fault day, it is a property of the **connection**, and
|
||||
> the remedy is a new SITE SET, `sign_site`, that stops every inverter.
|
||||
|
||||
### Why `failsafe` stays per-unit and `sign_site` is new
|
||||
|
||||
`CLAIM_SCOPE`'s entry for `failsafe` used to read *"the evidence is site-wide and
|
||||
the remedy is not"* — a site measurement attributed to one actuator on the
|
||||
strength of the N=1 identity. After this card the first half of that sentence is
|
||||
simply true: the evidence **is** per-unit. The site-wide half did not disappear;
|
||||
it moved to a claim that is site-scoped for the reason `circulation` is, one
|
||||
entry above it: **the offending number is the site target, which no single
|
||||
inverter owns.** Parking one unit on it would leave the rest of the roster
|
||||
pushing the same way with the guard satisfied.
|
||||
|
||||
### At N=1, exactly one detector is armed
|
||||
|
||||
`Controller._arm_sign_watchdogs()` is the only place the roster decides anything
|
||||
here, and it is a pure function of `len(self.inverters)`:
|
||||
|
||||
* **one inverter** — the unit detector is armed against **the meter**, at the
|
||||
shipped `sign_disagree_w` / `sign_disagree_s`. The site detector is
|
||||
**constructed and never evaluated**. Same comparison, same thresholds, same
|
||||
reason string byte for byte, same namespaced keys and flat mirror, same
|
||||
`failsafe` claim, same banner, same single status row. `test_watchdog.py`
|
||||
part 7 asserts each of those, including that the site detector's window was
|
||||
never so much as opened.
|
||||
* **two or more** — each unit against its own 0x0518 power under the per-unit
|
||||
pair, and the site detector against the meter under the site pair.
|
||||
|
||||
Arming both at N=1 would be two latches and two banners for one fault, and a
|
||||
false-positive record measured on **one** comparison spent twice.
|
||||
|
||||
## 2. What this is NOT — the two neighbours, named
|
||||
|
||||
Two guards that overlap without saying so is how a false-alarm generator gets
|
||||
built, so both are written down.
|
||||
|
||||
### It is not [[SAFETY-31]]
|
||||
|
||||
| | **SAFETY-31 — `circulation.py`** | **SAFETY-30 — the per-unit half** |
|
||||
|---|---|---|
|
||||
| compares | the units **against each other**: `Σ\|Bᵢ\| − \|ΣBᵢ\|` over measured power | each unit against **its own command** |
|
||||
| catches | two inverters pushing **through** each other; nets to zero at the meter | a unit **not doing what it was told**; that unit is faulty or wedged |
|
||||
| attributes | nothing — the quantity is defined over the roster and is identically zero for one unit | **the unit**, by name, and parks it |
|
||||
| scope | SITE claim, flat latch | UNIT claim, namespaced latch |
|
||||
|
||||
They genuinely overlap in **one** scenario — a unit reversing while its sibling
|
||||
pulls the other way — and `test_watchdog.py` part 4 asserts exactly that, on the
|
||||
same fixture, with both readings printed: circulating power is over its threshold
|
||||
**and** SAFETY-30 names B. Neither is the other with a different threshold. Take
|
||||
two units faithfully executing an allocation with two signs: circulating power is
|
||||
the whole loop and every per-unit sign check reads perfect agreement. Take one
|
||||
unit doing the opposite of its share while its sibling covers: this reads the
|
||||
whole disagreement and circulating power can be zero.
|
||||
|
||||
### It is not the saturation freeze
|
||||
|
||||
`compute()`'s anti-windup test is `|prev_w − actual_w| > saturation_w` (500 W). It
|
||||
concludes *the inverter cannot follow*, it fires on a merely **saturated** unit as
|
||||
readily as on a reversed one, it **stops the integrator winding** — an ordinary
|
||||
control action taken every day — and it neither latches nor parks anything.
|
||||
|
||||
This asks for **opposition**: a sign, on both sides of a threshold, held
|
||||
continuously. A saturated-but-obedient unit cannot produce it. And the two meet
|
||||
at a number rather than overlapping in a band nobody drew — see §3.
|
||||
|
||||
## 3. The numbers, and where they come from
|
||||
|
||||
Read off `admin/goodwe`'s `sim/` two-master rig (TEST-19), used **read-only** and
|
||||
run container-free. Real and imported, never transcribed: the control law
|
||||
(`control.compute`, `Tuning`), the allocator (`arbiter.allocate_record` →
|
||||
`allocator.plan`), the sign predicate (`watchdog.disagrees`), the wire
|
||||
(`frame_check.setpoint_atomic`, the firmware's own encoder), the plant
|
||||
(`plantsim.Plant`), and the per-unit reading, taken from **holding register
|
||||
0x0518** — the register the live `batt_entity` binds.
|
||||
|
||||
**1890 closed-loop runs in which NOTHING is faulty** — every unit executes the
|
||||
share the allocator gave it — over **6 gains × 5 slews**, the whole range
|
||||
`config.yaml` accepts for `gain` `float(0.05,1.0)` and `slew_w` `int(50,5000)`,
|
||||
across both families of stimulus that can produce legitimate opposition:
|
||||
|
||||
* **1050 load reversals** — 7 sizes (±300 W … ±4000 W) × 5 write staggers, the
|
||||
serialised write path landing unit B's number up to four cycles after A's;
|
||||
* **840 flapping loads** — 7 periods (10 s … 600 s) × 4 amplitudes.
|
||||
|
||||
Every run is executed **twice**: once at a roster of two and once at a roster of
|
||||
one on the same stimulus. **The N=1 arm is the control**, and it is the reason
|
||||
this document can separate *"a property of the check that shipped"* from *"a
|
||||
property of the second inverter"* instead of asserting one and hoping.
|
||||
|
||||
Supervised caps are the shipped `max_w` (2000 W per unit), so the site rail is
|
||||
2000 W at N=1 and 4000 W at N=2 — `Controller.site_max_w`, the roster sum.
|
||||
|
||||
### Reproducing it
|
||||
|
||||
The harness is **not committed here**, for the reason SAFETY-31's was not: it
|
||||
imports `plantsim` and `frame_check`, which live in the other repository and are
|
||||
not present in CI. What it does is small enough to restate: build two
|
||||
`plantsim.Inverter`s from `INVERTER_A` / `INVERTER_B`, close the loop with the
|
||||
add-on's own `compute()` and `arbiter.allocate_record()`, put each share on its
|
||||
own wire through `Inverter.command()`, advance `Site.settle()` one control cycle,
|
||||
and record `(standing command, inv.reg(0x0518))` per unit and
|
||||
`(Σ standing, site.grid)` for the site. Then, for each candidate threshold, the
|
||||
**longest run of consecutive cycles** on which the shipped `disagrees()` is true —
|
||||
not a count of true samples, because the watchdog's window is cleared by any
|
||||
disagreeing sample. Point it at a checkout with `GOODWE_ADDON_REPO`.
|
||||
⚠️ MUTATION.md's note applies: those harnesses write `__pycache__` into **both**
|
||||
trees.
|
||||
|
||||
### A. PER-UNIT — a unit's standing command vs its own 0x0518 power, N=2
|
||||
|
||||
| threshold | worst continuous, reversals | worst continuous, flapping | runs crossing (of 1890) |
|
||||
|---:|---:|---:|---:|
|
||||
| 50 W | 25.0 s | 20.0 s | 962 |
|
||||
| 100 W | 20.0 s | 15.0 s | 511 |
|
||||
| 150 W | 15.0 s | 15.0 s | 379 |
|
||||
| 200 W | 10.0 s | 15.0 s | 258 |
|
||||
| **250 W** | **10.0 s** | **10.0 s** | **194** |
|
||||
| 300 W | 10.0 s | 10.0 s | 124 |
|
||||
| 500 W | 5.0 s | 5.0 s | 37 |
|
||||
| 700 W | 5.0 s | 5.0 s | 16 |
|
||||
| 1000 W | 0.0 s | 0.0 s | 0 |
|
||||
|
||||
Largest opposed magnitude anywhere in the 1890 runs: **875 W**.
|
||||
|
||||
**The curve is flat, and that is the finding that decided the shape of this
|
||||
pair.** Unlike the site comparison — where the meter is driven by the LOAD and a
|
||||
flapping load can hold it opposed for minutes — a unit's own power **chases its
|
||||
own command** through a bounded ramp and a first-order lag, so the opposition can
|
||||
only last as long as the crossing does. Dropping the threshold from 300 W to
|
||||
50 W buys 15 s of extra transient and nothing else. **So the work is done by the
|
||||
window, and the threshold is put where the control law already draws its line.**
|
||||
|
||||
#### `unit_sign_disagree_w: 250` — schema `int(50,5000)`
|
||||
|
||||
`saturation_w / 2`. The check gates on **both** sides being over `min_w`, and the
|
||||
two are **opposed**, so the smallest command-versus-power gap it can ever see is
|
||||
`2 × min_w`. At 250 W that is exactly the 500 W at which `compute()`'s own
|
||||
anti-windup test already concludes the inverter is not following. **This watchdog
|
||||
therefore cannot fire on a divergence the shipped control law itself calls
|
||||
ordinary tracking**, and the two guards meet at a number.
|
||||
|
||||
Rejected: a threshold above the observed 875 W maximum, which would gate nothing
|
||||
legitimate in — and would also be blind to a unit reversing a 900 W share.
|
||||
|
||||
#### `unit_sign_disagree_s: 85` — schema `int(30,1800)`
|
||||
|
||||
Three bounds, and the value clears all three.
|
||||
|
||||
* **lower, from the rig — 10.0 s.** Two control cycles, the worst continuous
|
||||
legitimate opposition above 250 W across all 1890 runs. It is a reversing site
|
||||
whose second write lands a cycle after the first, both units then slewing
|
||||
through zero behind a 10 s lag. Nothing is wrong.
|
||||
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
|
||||
executing its share for less than the firmware's own veto window is a *link*
|
||||
fault the add-on already answers by dropping the unit from the split. Firing
|
||||
inside that window would raise a second, louder alarm about a state already
|
||||
being handled — the SAFETY-15/SAFETY-19 failure exactly.
|
||||
* **upper, from SAFETY-02 itself — 180 s, the site hold.** A unit that is not
|
||||
doing what it was told must not be parked *later* than the site would be
|
||||
parked for the same watts, and this conclusion needs nothing to net out: it is
|
||||
complete on the first cycle it appears.
|
||||
|
||||
**85 s** is seventeen control cycles: **8.5× the worst legitimate episode**,
|
||||
**2.8× the firmware veto**, and **under half** the site hold. Deliberately not
|
||||
60, 75, 90, 120 or 180.
|
||||
|
||||
### B / C. SITE — Σ standing commands vs the meter, at BOTH rosters
|
||||
|
||||
**Load-reversal family** (1050 runs each), worst continuous legitimate opposition:
|
||||
|
||||
| threshold | **N=2** | **N=1 (control)** | ratio |
|
||||
|---:|---:|---:|---:|
|
||||
| 100 W | 335.0 s | 175.0 s | 1.91× |
|
||||
| 200 W | 260.0 s | 120.0 s | 2.17× |
|
||||
| **300 W** | **215.0 s** | **110.0 s** | **1.95×** |
|
||||
| 500 W | 155.0 s | 90.0 s | 1.72× |
|
||||
| 700 W | 130.0 s | 70.0 s | 1.86× |
|
||||
| 1000 W | 85.0 s | 35.0 s | 2.43× |
|
||||
|
||||
**The second inverter roughly DOUBLES the worst legitimate crossing, and at the
|
||||
shipped 300 W threshold it takes it from 110 s — comfortably inside the 180 s
|
||||
hold — to 215 s, which is outside it.**
|
||||
|
||||
The mechanism is not mysterious and is worth stating because it predicts the
|
||||
ratio: the transient the 180 s hold exists to reject is the loop crossing zero
|
||||
after a load reversal, and the command has to travel the whole **site rail** at
|
||||
`slew_w` per cycle. At N≥2 that rail is `Controller.site_max_w`, the roster sum,
|
||||
while `slew_w` is unchanged. The worst case is the corner of the config space
|
||||
where that takes longest — `gain: 0.05`, `slew_w: 50`, both **schema floors** —
|
||||
against a ±2200 W load reversal, which is an oven and a heat pump.
|
||||
|
||||
#### `site_sign_disagree_w: 300` — CARRIED, and here is the justification
|
||||
|
||||
The argument behind 300 W is about **noise**: *"near zero the sign is noise, and
|
||||
near zero is where a healthy loop LIVES"* — the deadband holds the command still
|
||||
while the meter wanders either side of `target_grid_w`. `deadband_w` is 15 W and
|
||||
there is one P1 whatever is behind it. **Nothing in that argument depends on the
|
||||
roster**, and the campaign agrees: the fraction of samples gated in at 300 W is
|
||||
within a few percent at both rosters. Carried, with a measurement behind the
|
||||
carry rather than an assumption.
|
||||
|
||||
#### `site_sign_disagree_s: 325` — NOT carried, and that is the point of the card
|
||||
|
||||
180 s is **inside** the 215 s the two-inverter campaign measures, at a
|
||||
configuration the schema accepts. Carrying it would have shipped a false trip on
|
||||
the check with the best record in the safety table — which is the exact failure
|
||||
this card was opened to prevent, arriving from the other direction.
|
||||
|
||||
**325 s** is 1.51× the measured worst. It is well inside the **900 s** quarter
|
||||
that bills the capacity tariff, and it is inside **every** episode the fault day
|
||||
contains: 2026-08-20's four clusters run 16, 30, 77 and 77 minutes, and its
|
||||
opposed runs reach 1750 s. What it costs is stated rather than hidden — about two
|
||||
and a half extra minutes of wrong-direction **site** power before the stop —
|
||||
and it is bought to remove a false trip a legal configuration produces.
|
||||
Deliberately not 300 or 360.
|
||||
|
||||
**Rejected alternative:** keep 180 s and raise the site threshold to 1000 W,
|
||||
where the worst is 85 s. It gives a better margin and a faster stop, and it is
|
||||
wrong: it would be raising a **noise** gate to reject a **transient**, which is
|
||||
what the window is for, and it would leave the site check blind to a 900 W
|
||||
whole-site reversal at a 4 kW site.
|
||||
|
||||
### A limitation this campaign found, which SAFETY-30 does not introduce and does not fix
|
||||
|
||||
The **flapping-load** family behaves completely differently for the SITE
|
||||
comparison, and the control arm is what makes it interpretable:
|
||||
|
||||
| threshold | **N=2** | **N=1 (control)** |
|
||||
|---:|---:|---:|
|
||||
| 300 W | 980.0 s | 1000.0 s |
|
||||
| 700 W | 975.0 s | 1000.0 s |
|
||||
| 1000 W | 950.0 s | 1000.0 s |
|
||||
|
||||
A square wave whose half period is about one control cycle opposes the command on
|
||||
**every** cycle: the command chases the previous half-cycle's meter while the
|
||||
meter has already reversed. **No window inside the billing quarter survives it and
|
||||
no threshold up to 1000 W does either — at EITHER roster.** The N=1 column is
|
||||
this document's whole reason for existing: this is a property of comparing a
|
||||
lagged command against a meter, and it has nothing to do with the second
|
||||
inverter.
|
||||
|
||||
It is not acted on here, in order of weight: it is **identical at the roster this
|
||||
add-on ships**, so acting on it would be changing a live safety path on evidence
|
||||
that has nothing to do with this card; a ~6 kW square wave at 0.1 Hz sustained
|
||||
for a quarter of an hour is not a house (the captured healthy day's longest
|
||||
opposed run is **30 s**); and the fix is not a number — it wants the detector to
|
||||
notice that the disagreement is **alternating**, which is a different check.
|
||||
**It is a card, and this section is its evidence.**
|
||||
|
||||
### D. The fault arm — because a survey of healthy data is not a derivation
|
||||
|
||||
210 runs with inverter B executing the **negation** of its share throughout,
|
||||
sibling faithful, meter left to net:
|
||||
|
||||
| threshold | longest run the per-unit check detects | runs the SITE check also sees |
|
||||
|---:|---:|---:|
|
||||
| 250 W | 890.0 s | 210 / 210 |
|
||||
| 300 W | 890.0 s | 180 / 210 |
|
||||
| 700 W | 885.0 s | 112 / 210 |
|
||||
| 1000 W | 885.0 s | 60 / 210 |
|
||||
| 1500 W | 880.0 s | 45 / 210 |
|
||||
| 2000 W | 0.0 s | 39 / 210 |
|
||||
|
||||
At the shipped 250 W the fault is detected continuously for the whole run in
|
||||
every one of the 210 — an 85 s hold has ten times the evidence it needs. The
|
||||
right-hand column is the honest half: on a *reversing* stimulus the site
|
||||
comparison often does see something too, because the reversal is large enough to
|
||||
move the meter. **The case it cannot see is the steady one**, and that is the
|
||||
fixture `test_watchdog.py` part 4 runs: the shipped comparison is false on
|
||||
**0 of 14** cycles while the per-unit check convicts B on every one.
|
||||
|
||||
## 4. Inverter B does not exist
|
||||
|
||||
Every two-inverter figure in §3 was produced with **inverter A's ramp and lag
|
||||
copied onto B** (`plantsim.INVERTER_B`, `measured=False`); its 3000 W rating,
|
||||
where this repository's fixtures use one, is a **hypothesis marked as one**.
|
||||
[[SPIKE-04]] measures the real ones and is the revisit trigger for all four
|
||||
numbers — not a reason to move any of them now.
|
||||
|
||||
Which figures depend on it, stated so nobody has to work it out:
|
||||
|
||||
* **`unit_sign_disagree_s: 85`** — most. Its lower bound is the 10.0 s crossing,
|
||||
which is a direct function of B's ramp and lag. A slower second unit lengthens
|
||||
it; the 8.5× is what buys headroom for that.
|
||||
* **`site_sign_disagree_s: 325`** — yes, but less directly. Its 215 s is
|
||||
dominated by the **rail** and by `slew_w`, which are configuration, not
|
||||
hardware; B's dynamics move it at the margin.
|
||||
* **`unit_sign_disagree_w: 250`** — **no.** It is `saturation_w / 2`, read off
|
||||
the add-on's own control law. The rig only confirms that nothing legitimate
|
||||
sits over it for long.
|
||||
* **`site_sign_disagree_w: 300`** — **no.** It is the meter's noise floor and the
|
||||
deadband, neither of which is a property of any inverter.
|
||||
|
||||
## 5. Mutation ledger
|
||||
|
||||
Every run on a **freshly copied tree of its own, no `__pycache__`, mounted
|
||||
`:ro`** into the CI image — the structural immunity MUTATION.md records. ⚠️ The
|
||||
first battery used ONE shared directory rebuilt between mutations and reported a
|
||||
**RED baseline**; a container mounted it mid-copy and read a missing
|
||||
`config.yaml` as a failing check. That whole run was discarded: MUTATION.md's
|
||||
rule is that a run which could have been racing is not a result, and a battery
|
||||
whose baseline is not green establishes nothing. The harness now copies a
|
||||
directory per mutation and **refuses to report anything if the baseline is not
|
||||
green**.
|
||||
|
||||
Byte deltas measured, not estimated; the `+0 B` ones are marked, since a
|
||||
size-preserving edit is what TEST-15 turned into a false survivor once already.
|
||||
|
||||
| # | file | mutation | Δ | result |
|
||||
|---|---|---|---|---|
|
||||
| M1 | `main.py` | the per-unit comparison put back to `self.grid` at every roster | −30 B | **RED** — `test_watchdog.py`. This is the card |
|
||||
| M2 | `main.py` | the per-unit comparison forced to `inv.batt` at N=1 too | −31 B | **RED** — AC 8 |
|
||||
| M3 | `main.py` | the site half never evaluated | −6 B | **RED** — AC 3 |
|
||||
| **M4** | `main.py` | **the site half armed at N=1 as well** | **+0 B** | **SURVIVOR on the first battery — GAP CLOSED**, then RED. See below |
|
||||
| M5 | `main.py` | the `sign_site` claim never filed | −6 B | **RED** — `test_watchdog.py`. ⚠️ `test_peakguard.py` stayed **GREEN**, correctly: 10f's AST scan pins which sources CAN be filed and the append is still written; whether it is REACHED is a dynamic question no fixture there gets to |
|
||||
| M6 | `main.py` | the site latch not persisted to the store | −58 B | **RED** — the flat-keys and restart checks |
|
||||
| M7 | `main.py` | the operator clear leaves the site latch set | −42 B | **RED** — the clear check |
|
||||
| M8 | `watchdog.py` | `arm()` clears `disagree_since` | +35 B | **RED** — "re-armed on every one of 38 cycles still trips" |
|
||||
| M9 | `watchdog.py` | the reason hard-codes "a meter reading" again | +1 B | **RED** — `test_watchdog.py`. `test_inverter.py` stayed **GREEN**; its part-3 row reads the noun off `sign_wd.against` rather than out of the reason string, so the two checks bite in different places |
|
||||
| M10 | `watchdog.py` | `unit_sign_disagree_w` 250 → 300 | **+0 B** | **RED** — the `saturation_w / 2` pin and the CFG-03 pin |
|
||||
| M11 | `watchdog.py` | `site_sign_disagree_s` 325 → 180, i.e. **the number carried** | **+0 B** | **RED** — the measured-215 s check and the CFG-03 pin |
|
||||
| M12 | `arbiter.py` | `sign_site` scoped `SCOPE_UNIT` | **+0 B** | **RED** — `test_arbiter.py` **and** `test_peakguard.py` |
|
||||
| M13 | `validate.py` | the `unit_sign_disagree_w` rule removed | −126 B | **RED** — `test_validate.py` |
|
||||
|
||||
### M4 is recorded as a survivor because it was one
|
||||
|
||||
The check it should have gone red on read:
|
||||
|
||||
```python
|
||||
not _c1s.site_sign_wd.tripped and _c1s.site_sign_wd.disagree_since is None
|
||||
```
|
||||
|
||||
Arming the site detector at a roster of one left that **green**, and the reason
|
||||
is worth reading because the check looked airtight. In the N=1 fixture the unit
|
||||
detector trips first at its 1 s hold and parks the inverter; the site's standing
|
||||
command is then **0 W**, so the site predicate is false, its window is never
|
||||
opened, and `disagree_since is None` is true *whether or not the detector was
|
||||
evaluated at all*. **The assertion ran, the sentence was true, and the data
|
||||
could not reach the failure it named** — MUTATION.md's third failure mode,
|
||||
inside the check written to prevent it.
|
||||
|
||||
Replaced with a **call counter installed on the object** before the run: twelve
|
||||
cycles, zero calls, printed. A counter can only be zero for one reason. The
|
||||
mutation was re-run against it and is red; the run that found it was discarded
|
||||
along with its tree, so this is a result and not an artefact.
|
||||
|
||||
## 6. What this card does NOT close
|
||||
|
||||
* **The flapping-load blind spot in the SITE comparison.** §3, at length. Present
|
||||
identically at the shipped roster of one; wants an alternation test, not a
|
||||
number; a card of its own.
|
||||
* **A unit misbehaving while it is NOT loop-driven.** Gate 1 (`following`) is
|
||||
kept for the per-unit comparison, deliberately: the whole no-false-positive
|
||||
argument is built on it, and the saturation freeze already grades that window.
|
||||
So between the start of a maintenance cycle and its end, a unit reversing
|
||||
against its own command is the freeze's to notice. Widening it is a card with a
|
||||
rig campaign behind it, not an edit.
|
||||
* **A sign inversion downstream of this add-on** — in the firmware, on the wire,
|
||||
or in a future driver. Unchanged: our view stays self-consistent and both
|
||||
comparisons stay silent. That is the witness's problem and it always was.
|
||||
* **A false-alarm interaction with SAFETY-31 that nobody has to think about.**
|
||||
They overlap in one scenario and both fire in it. That is correct — they
|
||||
conclude different things — but an operator will see two alarms for one event.
|
||||
Whether the banner should say so in one sentence is a UI card.
|
||||
Reference in New Issue
Block a user