Merge pull request 'SAFETY-30: grade each inverter against its own power, and the site's total against the meter' (#46) from SAFETY-30 into release/1.0

This commit was merged in pull request #46.
This commit is contained in:
2026-09-04 12:28:44 +02:00
14 changed files with 2279 additions and 89 deletions
+83
View File
@@ -2,6 +2,89 @@
## Unreleased
**At two inverters the sign watchdog stops comparing one unit against the whole
site meter, and becomes two checks instead of one (SAFETY-30).**
`disagrees(commanded_w, grid_w, ...)` 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 - §6.2's identity - and
that is what makes the check sound and what earned it the best record in the
safety table. At two they are not, and it fails **in both directions**: 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, and - worse - a unit genuinely
driving the wrong way is **MASKED** by a sibling that covers for it, because the
site figure nets. No threshold recovers that; the summation destroys the
evidence.
* **PER UNIT: each inverter's own share 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* - evidence
about one device, so the remedy stays the per-unit `failsafe` SET that
`arbiter.CLAIM_SCOPE` has classified `SCOPE_UNIT` since it was written. That
entry read *"the evidence is site-wide and the remedy is not"*; after this card
the first half is simply true. Verified by making one unit reverse while its
sibling covers it at the meter: the shipped comparison is false on **0 of 14**
cycles while the new one convicts that unit on every one, and only that unit is
parked.
* **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 fault the 2026-08-20
capture contains and **no per-unit check can see it**, because every inverter
is faithfully executing its share - what is wrong is the number they were
given. It stops every inverter and the banner names the site rather than an
inverter, so nobody is sent to swap the wrong box.
* **⚠️ Different questions from SAFETY-31, and the PR and the decision record
both say which.** `circulation.py` compares the units **against each other**
and catches two inverters pushing *through* each other, which nets to zero at
the meter and which no single command is evidence about. 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: that 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.
* **⚠️ All four thresholds RE-DERIVED at two inverters - one carried with a
justification, one refused.** 1890 closed-loop runs in which nothing is faulty,
over the whole range `config.yaml` accepts for `gain` and `slew_w`, across load
reversals with write staggers and flapping loads, **run at both rosters so the
N=1 arm is the control.** `unit_sign_disagree_w: 250` is `saturation_w / 2`, so
the smallest divergence this can see is exactly the gap at which the control
law itself already says the inverter is not following; `unit_sign_disagree_s:
85` is 8.5× the worst legitimate opposition those runs produce (10.0 s), 2.8×
the firmware's own 30 s link veto and under half the site hold.
`site_sign_disagree_w: 300` is **carried** - its argument is the meter's noise
floor and the deadband, and the roster touches neither. **`site_sign_disagree_s`
is NOT: 180 s would have shipped a false trip.** 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.
325 s is 1.51× that, inside the 900 s billing quarter and inside every episode
of the fault day. See `DECISIONS/SAFETY-30-per-unit-sign.md`.
* **⚠️ At ONE inverter this changes nothing, and "nothing" is asserted rather
than assumed.** The two questions are the same sentence at a roster of one, 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's window is never so much as opened.
`unit_sign_disagree_*` and `site_sign_disagree_*` do nothing on a
one-inverter install.
* **The threshold WIRING is pinned, not just the threshold values.** Review
found that `arm(self.sign_cfg if one else self.unit_sign_cfg, ...)` could be
collapsed to `arm(self.sign_cfg, ...)` — arming every unit with the
meter-facing pair instead of the re-derived per-unit one — with all 19 test
files still green, because nothing at a roster of two read `sign_wd.cfg` and
the trips in the fixture happen under either pair. `test_watchdog.py` part 3
now asserts the pair and the instrument on **every** detector against
literals, and the fixture's three threshold pairs were made pairwise distinct
in both coordinates so that a hold delivered from the wrong pair cannot
compare equal to the right one. M14–M16 in
`DECISIONS/SAFETY-30-per-unit-sign.md` §5.
* **Known and recorded rather than 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 at *both* rosters. The N=1 control arm is what says this is
a property of comparing a lagged command against a meter and has nothing to do
with the second inverter, so it is not acted on here - it wants an alternation
test, not a number, and a card of its own.
**An operator can declare a second inverter, and the shape was measured against
the Supervisor rather than assumed (CFG-04).** DEV-02 made rating, SoC,
watchdogs, heartbeat and store keys per-instance, so the code no longer assumed
@@ -0,0 +1,474 @@
# 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` |
| **M14** | `main.py` | **`arm()` handed `self.sign_cfg` at EVERY roster** — the WIRING, not the values | −31 B | **SURVIVOR at review — GAP CLOSED**, then RED. See below |
| M15 | `main.py` | per-unit `arm()` given the right `min_w` and the SHIPPED hold — a HOLD-ONLY mis-wire | +78 B | **RED** — the same wiring pin |
| M16 | `main.py` | per-unit `arm()` given the right `min_w` and the SITE hold — hold-only again, and behaviour-preserving | +83 B | **RED** — the wiring pin, and the ONLY red in `test_watchdog.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.
### M14: the VALUES were pinned and the WIRING was not
M10 and M11 mutate the numbers in `watchdog.SIGN_OPTION_DEFAULTS` and both are
red through the config pins. Neither of them touches the line that DELIVERS
those numbers to a detector:
```python
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg,
AGAINST_METER if one else AGAINST_OWN_POWER)
```
Drop the conditional — `arm(self.sign_cfg, ...)` — and every unit is armed with
SAFETY-02's meter-facing pair instead of the re-derived per-unit one. At review
that left **all 19 files green**. `grep -rn "sign_wd.cfg" test_*.py` found
exactly one threshold assertion, and it is §7's N=1 arm — `(300.0, 1.0)` —
which the mutation PRESERVES. Nothing anywhere read `inv.sign_wd.cfg` at a
roster of two.
The behavioural checks in part 3 cannot close it and it is worth saying why: B
is reversed at ~2000 W and every hold in that fixture is short, so B trips under
either pair. **A fault big enough and long enough to trip under both thresholds
is evidence about neither.**
What the survivor costs in the field is the whole product of §3: at
`config.yaml`'s own defaults the per-unit check would run at `sign_disagree_s`
**180 s instead of 85 s** — 2.1× slower to park a reversed inverter — and its
gate would move from 250 W to 300 W, so a unit reversing a share between the two
becomes invisible, while `SIGN_OPTION_DEFAULTS`, `config.yaml` and §3 all go on
describing a pair the running code does not use. With CI green.
**Closed by a wiring pin in `test_watchdog.py` part 3**, on the real
two-inverter Controller: every `inv.sign_wd.cfg` equals the per-unit pair,
`site_sign_wd.cfg` equals the site pair, and each detector's `against` is its
own instrument — all graded **against literals**, since `== self.unit_sign_cfg`
cannot tell *the per-unit pair* from *whatever this Controller was handed*.
⚠️ **And the fixture had to be changed for that pin to mean anything.**
`_sign_site` set `unit_sign_disagree_s: 1` **and** `site_sign_disagree_s: 1`
**and** `sign_disagree_s: 1`, with two of the three magnitude gates at 300 W. A
bare equality there discriminates on `min_w` alone: a mis-wiring that delivered
the right magnitude with the wrong hold would compare EQUAL to the pair it was
supposed to have. That is this document's own §5 lesson and MUTATION.md's third
failure mode — a check comparing two things that happen to be equal cannot see
the difference it names — reappearing inside the fix for it. The three pairs are
now pairwise distinct **in both coordinates** (250 W / 1 s, 320 W / 2 s,
300 W / 3 s), a PRECONDITION check asserts that distinctness so a later edit
collapsing them goes red rather than quietly vacuous, and both pairs are
**printed**. M15 and M16 exist to show the hold half actually bites: right
magnitude, wrong hold, red both times — and M16, whose wrong hold still trips
inside the drive, is red on the wiring pin and on **nothing else** in that file.
## 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.
+41
View File
@@ -363,6 +363,10 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `auto_start` | false | Start controlling on boot (only after commissioning) |
| `sign_disagree_w` | 300 | Both the command and the meter must exceed this before a sign difference counts. Must stay **below `max_w`** or the watchdog can never fire, and the add-on refuses to start |
| `sign_disagree_s` | 180 | How long they must stay opposed, continuously, before the failsafe trips |
| `unit_sign_disagree_w` | 250 | **Two or more inverters only.** Both a unit's own share and its own measured power must exceed this before their sign difference counts. It is `saturation_w / 2`, so the smallest divergence it can see is exactly the gap at which the loop already calls the inverter "not following". Must stay **below `max_w`** or it can never fire, and the add-on refuses to start |
| `unit_sign_disagree_s` | 85 | **Two or more inverters only.** How long one unit's command and its own power must stay opposed before that unit is parked |
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
#### The sign-disagreement failsafe
@@ -374,6 +378,43 @@ there. The banner reads `FAILSAFE`, the commissioning list says which two
numbers disagreed and for how long, and the `Controller status` entity reports
`failsafe`.
#### ⚠️ At two or more inverters this is TWO checks, not one (SAFETY-30)
The paragraph above describes what a **one-inverter** install does, and it is
unchanged: there is one meter, one actuator, and comparing the command against
the meter is comparing a thing with a view of itself.
With a second inverter that stops being true. The meter answers for **both**
units, so a command put on one of them can be opposed to it with nothing wrong
(inverter A charging while inverter B discharges more), and - the worse
direction - a unit genuinely driving the wrong way can be **hidden**, because its
sibling's watts arrive at the meter and the sum looks healthy. So the check
splits in two, and they are different faults:
- **Per inverter** — that unit's own share against **its own measured power**
(the `batt_entity` reading, register 0x0518). This catches *"this inverter is
not doing what it was told"*. Only that inverter is parked; the site keeps
running on the one that works. Thresholds: `unit_sign_disagree_w` /
`unit_sign_disagree_s`.
- **Site-wide** — the **sum** of what every inverter has standing, against the
meter. This catches *"the site as a whole is pushing the wrong way"*, which no
per-inverter check can see because every unit may be faithfully executing its
share of a number that is itself wrong. **Every** inverter is parked and the
banner names the site rather than an inverter. Thresholds:
`site_sign_disagree_w` / `site_sign_disagree_s`, latched under flat
`site_sign_tripped` in `/data/state.json`.
Both latch, both are cleared by the same *stop, then start*, and both survive a
restart. **On a one-inverter install neither pair does anything at all** - the
two questions are the same question there, and only the check described above is
armed.
This is **not** the circulating-power alarm further down: that one compares the
inverters *against each other* and catches them pushing power through each other,
which nets to zero at the meter and which no command is evidence about. It is
also not the saturation freeze, which is a *magnitude* test that shapes the loop
rather than a *direction* test that parks an inverter.
**It does not clear itself, and restarting the add-on does not clear it either.**
Pressing *Start / stop control* twice — stop, then start — is the only thing that
does. That is deliberate on both counts:
+40 -7
View File
@@ -151,6 +151,30 @@ CLAIM_SCOPE = {
# other went on feeding the loop. The evidence is site-level and so is the
# remedy: stop commanding, everywhere, and say why.
"circulation": SCOPE_SITE,
# main.py: `Claim.set("sign_site", P_SAFETY, 0.0, "site sign disagreement")`.
# SAFETY-30, and it is the twin of `failsafe` below rather than a duplicate
# of it - the two exist BECAUSE the same words describe two different
# measurements once there is more than one inverter.
#
# `failsafe` grades ONE unit's standing command against THAT UNIT'S OWN
# measured power (register 0x0518). Its conclusion is "the actuator I am
# driving is not doing what I told it", which is a fact about one device,
# so it parks one device.
#
# This one grades the SUM of the standing commands against P1. Its
# conclusion is "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 - and there is nothing per-unit about it: the quantity is a sum
# over the roster and the offending number is the site target, which no
# single inverter owns. Parking one unit on that evidence would leave the
# rest of the site pushing the same way with the guard satisfied.
#
# ⚠️ AT N=1 IT IS NEVER FILED, and that is the identity rather than a
# special case: with one inverter the sum of the commands IS that unit's
# command and `failsafe` already carries exactly this trip, under the keys,
# the banner and the claim an install has had since SAFETY-02 shipped.
# `Controller.cycle()` does not even arm the site detector there.
"sign_site": SCOPE_SITE,
# --- UNIT: about one pack, one inverter, one device -------------------
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
@@ -164,13 +188,22 @@ CLAIM_SCOPE = {
# same reason, so it is scoped like `device`.
"supervised": SCOPE_UNIT,
# main.py: `Claim.set("failsafe", P_SAFETY, 0.0, "sign disagreement")`.
# ⚠️ THE EVIDENCE IS SITE-WIDE AND THE REMEDY IS NOT, and that is why this
# entry needs its reasoning written down. SAFETY-02 compares P1 - a site
# measurement - against `standing`, THE COMMAND THIS CONTROLLER PUT ON THIS
# ACTUATOR. What it concludes is "the unit I am driving is pushing power the
# wrong way", and what it does is park that unit. At N>=2 the same evidence
# cannot be attributed to a sibling nobody here commands, so a site-wide
# remedy would be this watchdog stopping an inverter it never graded.
# ⚠️ THE EVIDENCE IS PER-UNIT AND SO IS THE REMEDY - AND SINCE SAFETY-30 THE
# FIRST HALF OF THAT SENTENCE IS TRUE. It used to read "the evidence is
# site-wide and the remedy is not", because the watchdog compared THIS
# unit's standing command against P1: a site measurement, attributed to one
# actuator on the strength of §6.2's N=1 identity. That identity does not
# survive a second inverter - at N>=2 the meter answers for a sibling this
# unit's command says nothing about, in both directions (a false trip when
# the sibling covers the meter, and a missed one when the sibling MASKS a
# unit that really is reversed).
#
# So at N>=2 the comparison behind this claim is THIS unit's command against
# THIS unit's own measured power - register 0x0518, per-instance since
# DEV-02 - and the scope that was already right for the wrong reason is now
# right for the right one. The site-wide half moved to `sign_site` above.
# At N=1 the two are the same comparison and this claim is the one filed,
# exactly as it shipped.
"failsafe": SCOPE_UNIT,
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
+317 -14
View File
@@ -64,7 +64,10 @@ from .tariff import PeakStore, PeakTracker
from .store import migrate_unit_keys
from .validate import (ConfigRefused, refuse, validate_startup_config,
validate_unit_ranges)
from .watchdog import SignWatchdogConfig, loop_is_following
from .watchdog import (
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
SignWatchdog, SignWatchdogConfig, loop_is_following,
)
from . import web
OPTIONS_PATH = "/data/options.json"
@@ -173,10 +176,42 @@ class Controller:
# first or every existing tripped install comes up ARMED, into the
# fault, with a green banner - which is the exact failure the latch was
# persisted to prevent, rebuilt by a key rename.
sign_cfg = SignWatchdogConfig(
# SAFETY-02's pair, and since SAFETY-30 it is THE SITE COMPARISON's
# pair - which at the shipped roster of one is the same thing under a
# more careful name. It is what `Inverter.sign_wd` is armed with at N=1,
# byte for byte, and what the site detector is armed with at N>=2.
self.sign_cfg = sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("sign_disagree_w", 300)),
hold_s=float(opts.get("sign_disagree_s", 180)),
)
# SAFETY-30. THE PER-UNIT COMPARISON'S OWN PAIR, RE-DERIVED AT TWO
# INVERTERS AND NOT CARRIED ACROSS. `sign_disagree_w` / `sign_disagree_s`
# were measured on two SINGLE-INVERTER bus captures against a command
# opposed to THE METER; this comparison is a command against ONE UNIT'S
# OWN POWER, where the magnitudes, the transient and the worst
# legitimate episode are all different quantities. watchdog.py's
# SIGN_OPTION_DEFAULTS carries the campaign that produced these, and
# DECISIONS/SAFETY-30-per-unit-sign.md carries the tables.
self.unit_sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("unit_sign_disagree_w",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"])),
hold_s=float(opts.get("unit_sign_disagree_s",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"])),
)
# SAFETY-30. THE SITE COMPARISON'S OWN PAIR, USED ONLY AT N>=2. The
# threshold is the shipped 300 W - carried, because the argument behind
# it is about the meter's noise floor and the deadband, neither of which
# the roster touches - and the WINDOW is not, because the transient it
# rejects is the loop crossing zero at `slew_w` per cycle across a rail
# that IS the roster sum. Measured at both rosters on the same stimuli:
# 110 s at one inverter, 215 s at two, against a 180 s hold. See
# watchdog.SIGN_OPTION_DEFAULTS.
self.site_sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("site_sign_disagree_w",
SIGN_OPTION_DEFAULTS["site_sign_disagree_w"])),
hold_s=float(opts.get("site_sign_disagree_s",
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])),
)
self.store_migration = migrate_unit_keys(store, UNIT_A)
self.inverters = build_roster(opts, store, sign_cfg)
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
@@ -261,6 +296,45 @@ class Controller:
"opposed the meter.", inv.name,
inv.sign_wd.reason or "(reason not recorded)")
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
# ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2.
# The per-unit watchdogs above ask "is THIS actuator doing what I told
# it", graded on that unit's own measured power. This one asks "is the
# SITE driving the wrong way", graded on the SUM of the standing
# commands against P1 - the comparison SAFETY-02 shipped, re-scoped to
# the quantity it was always actually about.
#
# ⚠️ AT A ROSTER OF ONE IT IS CONSTRUCTED AND NEVER EVALUATED, and that
# is the whole compatibility promise of this card rather than an
# oversight. With one inverter the sum of the commands IS that
# inverter's command, so arming this would be arming a duplicate of
# `inverters[0].sign_wd`: two latches for one fault, two banners, and a
# false-positive record measured on ONE comparison being spent twice.
# `cycle()` is where that is enforced and says so at the line.
#
# Restored from the store like SAFETY-02's and SAFETY-31's latches, for
# the reason store.py gives: "restart the add-on" is the first thing an
# operator looking at a FAILSAFE banner tries, and a latch held only in
# memory brings the controller back armed, into the fault, unattended.
# FLAT keys, not namespaced - store.py carries that argument too.
self.site_sign_wd = SignWatchdog(
self.site_sign_cfg,
tripped=bool(store.data.get("site_sign_tripped", False)),
reason=str(store.data.get("site_sign_reason", "") or ""))
if self.site_sign_wd.tripped:
_LOG.error(
"SAFETY-30 (site) was TRIPPED before this restart and stays "
"tripped: %s. Every inverter is held at 0 W. Restarting does "
"not clear this - start control from the UI once you know why "
"the site's total command opposed the meter.",
self.site_sign_wd.reason or "(reason not recorded)")
# And the per-unit detectors are told which question they are asking,
# here as well as in `cycle()`. `checks()` and the status page read
# `inv.sign_wd.cfg` and can be called before the first cycle has run, so
# a roster of two would otherwise advertise the site thresholds on a
# detector that is about to be graded against a unit's own power.
self._arm_sign_watchdogs()
# SAFETY-18. The frozen-but-fresh meter detector - see freeze.py for why
# it is a conjunction and not an `unchanged_s` threshold, and for the
# measurements behind the default.
@@ -712,6 +786,45 @@ class Controller:
def sign_wd(self):
return self._only().sign_wd
# -- SAFETY-30: which comparison each sign detector is making -------------
def _arm_sign_watchdogs(self) -> None:
"""Tell every sign detector which of SAFETY-30's two questions it asks.
⚠️ A PURE FUNCTION OF `len(self.inverters)`, AND THE ONLY PLACE THE
ROSTER DECIDES ANYTHING ABOUT THIS CHECK. Written as one method rather
than as a conditional at the two call sites for `loop_is_following()`'s
reason: the arrangement that does not keep two things in step is the one
where they are written down twice.
AT ONE INVERTER - §6.2's identity, and the whole of AC 8. The single
unit's detector is armed against THE METER with the shipped
`sign_disagree_w` / `sign_disagree_s`, which is what it has always been
armed against, and the site detector is not armed at all. A one-inverter
install therefore makes the same comparison, at the same thresholds,
writes the same reason string, latches under the same keys and files the
same `failsafe` claim as it did before this card.
AT TWO OR MORE - the identity is gone and the meter stops being a view
of any one actuator. Each unit's detector is armed against ITS OWN
MEASURED POWER (`Inverter.batt`, register 0x0518) under the re-derived
per-unit pair, and the site detector is armed against the meter under
the site pair. Two questions, two remedies, two latches.
⚠️ CALLED AGAIN AT THE TOP OF EVERY `cycle()`, not only from
`__init__`. The roster is re-read every cycle for liveness and for the
allocation, and this repository's two-inverter fixtures append an
`Inverter` AFTER the Controller is constructed - a detector frozen at
construction would then be graded against the meter at a roster of two,
which is the defect this card exists to remove, wearing a fixture's
name. `arm()` touches no timer and no latch, so calling it every cycle
cannot disturb an episode in progress.
"""
one = len(self.inverters) <= 1
for inv in self.inverters:
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg,
AGAINST_METER if one else AGAINST_OWN_POWER)
self.site_sign_wd.arm(self.site_sign_cfg, AGAINST_METER)
# -- the site aggregates, each one named for the question it answers ------
# ⚠️ THESE ARE NOT "THE SCALARS WITH A SUM IN FRONT". Each one picks a
# DIFFERENT reduction, because the questions are different, and getting the
@@ -1239,17 +1352,44 @@ class Controller:
# case where the last write was REJECTED - the inverter is then still
# latched on `last_write`, which is exactly what `last_write` holds).
# ⚠️ ONE WATCHDOG PER INVERTER, EVALUATED ON THAT INVERTER'S OWN
# STANDING COMMAND. The meter is site-wide - there is one P1 - but what
# the watchdog concludes is "THE UNIT I AM DRIVING is pushing power the
# wrong way", and that evidence cannot be pinned on a sibling this
# controller commanded differently. `inv.target` is that unit's share
# from the last resolution, which at N=1 is the site target itself.
# STANDING COMMAND. What the watchdog concludes is "THE UNIT I AM
# DRIVING is pushing power the wrong way", and that evidence cannot be
# pinned on a sibling this controller commanded differently.
# `inv.target` is that unit's share from the last resolution, which at
# N=1 is the site target itself.
#
# ⚠️ SAFETY-30: AND WHAT IT IS COMPARED AGAINST IS DECIDED BY THE ROSTER.
# This block used to hand every unit's detector `self.grid`. There is
# ONE P1, so at N>=2 that compares a number this controller put on ONE
# actuator against a measurement that answers for ALL of them, and it is
# wrong 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 instructed. Nothing is
# faulty and the highest-value check in the safety table
# fires - on a record of zero false positives.
# 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
# sum destroys the evidence; no threshold recovers it.
#
# So at N>=2 each unit is graded against ITS OWN measured power -
# `inv.batt`, register 0x0518, per-instance since DEV-02 - which is the
# only instrument that can answer "is THIS actuator doing what I told
# it". At N=1 §6.2's identity holds, the meter IS the view of that one
# unit, and the comparison stays the meter, byte for byte. The site's
# own question has not gone away; it is asked below, once, of the sum.
# `_arm_sign_watchdogs()` states the choice and its docstring argues it.
self._arm_sign_watchdogs()
n_units = len(self.inverters)
standing = {}
for inv in self.inverters:
was_tripped = inv.sign_wd.tripped
stood = inv.last_write if inv.last_write is not None else inv.target
standing[inv.name] = stood
if inv.sign_wd.evaluate(stood, self.grid, self.following, now) \
against = self.grid if n_units <= 1 else inv.batt
if inv.sign_wd.evaluate(stood, against, self.following, now) \
and not was_tripped:
# ⚠️ REASON FIRST, FLAG SECOND, and each `set` writes the file.
# The ordering is the whole atomicity story: a crash between the
@@ -1290,6 +1430,50 @@ class Controller:
# share of it. At the shipped roster of one the two are the same number.
site_standing = float(sum(standing.values()))
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
# The comparison SAFETY-02 shipped, asked of the quantity it was always
# about: the TOTAL this controller has standing on the wire, against the
# meter that total is answerable for. What it catches is a fault no
# per-unit check can see, because every unit is faithfully executing its
# share of a site target that is itself pointing 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, and it is
# the reason the site question survives rather than being replaced.
#
# ⚠️ ONLY AT N>=2, AND THIS BRANCH IS AC 8. At one inverter the sum of
# the commands IS that inverter's command and the loop above has already
# made exactly this comparison, at exactly these thresholds, on the
# detector whose latch, store keys, claim and banner an install has had
# since SAFETY-02 shipped. Arming this as well would be two latches for
# one fault, two banners, and the same false-positive record spent
# twice. A shipped install does not reach this block at all.
if n_units > 1:
was_site = self.site_sign_wd.tripped
if self.site_sign_wd.evaluate(site_standing, self.grid,
self.following, now) and not was_site:
# REASON FIRST, FLAG SECOND - the same atomicity ordering the
# per-unit trip uses above and SAFETY-31 uses below: a crash
# between the two must not persist a latch with no explanation
# for the operator who has to decide about clearing it.
#
# ⚠️ AND THE KEYS ARE FLAT, NOT NAMESPACED, AND NOT THE
# PRE-DEV-02 PAIR EITHER. `sign_tripped` / `sign_reason` are the
# ROLLBACK VIEW of a UNIT latch (store.py rule 2) and mirroring
# a site trip onto them would hand a downgraded add-on - which
# drives exactly one actuator - a latch taken on evidence about
# a roster it cannot have. `site_sign_*` is its own pair, for
# the same reason `circ_*` is.
self.store.set("site_sign_reason", self.site_sign_wd.reason)
self.store.set("site_sign_tripped", True)
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason)
_LOG.error("SAFETY-30 TRIPPED at the SITE - %s. That is the "
"SUM of every standing command opposing the meter, "
"which no single inverter is answerable for. "
"Commanding 0 W on every inverter and holding it "
"until control is started again. This survives an "
"add-on restart; restarting will not clear it.",
self.site_sign_wd.reason)
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
# not yet stale" hold below. A run of held cycles would otherwise leave
@@ -1329,8 +1513,14 @@ class Controller:
# list below, because its `failsafe` SET is what parks it. One tripped
# unit therefore takes the whole roster past the hold, which is the
# conservative direction: every other unit's claims are re-filed too.
# ⚠️ AND SAFETY-30's SITE LATCH TAKES THE ROSTER PAST THE HOLD TOO, for
# the same reason: its `sign_site` SET is what parks every inverter, and
# a latched site that returned early here would leave each unit's last
# share standing for the heartbeat to re-emit - i.e. the trip would stop
# acting the moment a poll was missed.
if not self.inputs_ok and not any(i.sign_wd.tripped
for i in self.inverters):
for i in self.inverters) \
and not (n_units > 1 and self.site_sign_wd.tripped):
bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0
if bad_for < stale_after:
# A single missed poll is not a fault. Hold, keep the heartbeat
@@ -1384,6 +1574,30 @@ class Controller:
"sign disagreement",
actuator=self.actuator_of("failsafe", inv)))
# --- SAFETY-30: THE SITE SIGN TRIP, AND IT IS A SITE SET ------------
# ⚠️ THE TWIN OF THE LOOP ABOVE, NOT A DUPLICATE OF IT. That loop files
# one `failsafe` per unit on the evidence of THAT unit's own measured
# power, and parks that unit. This files ONE claim on the SITE, because
# what tripped is the SUM of every standing command against the meter -
# a fact about the grid connection, whose offending number is the site
# target and 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. See arbiter.CLAIM_SCOPE, which classifies `sign_site` and
# `failsafe` side by side and says why they differ.
#
# ⚠️ AND THE `n_units > 1` GUARD IS NOT REDUNDANT, EVEN THOUGH `cycle()`
# never arms this detector at a roster of one. The latch is RESTORED
# FROM THE STORE, so an install that ran with two inverters, tripped,
# and was then reconfigured back to one would come up with
# `site_sign_tripped: true` and would otherwise file a claim on evidence
# about a roster it no longer has. Guarded, that install comes up
# driving - which is right: the fault it latched on is not a statement
# about the site it is now.
if n_units > 1 and self.site_sign_wd.tripped:
claims.append(Claim.set("sign_site", P_SAFETY, 0.0,
"site sign disagreement",
actuator=self.actuator_of("sign_site")))
# --- SAFETY-31: WHAT "ACTED ON" MEANS, AND WHAT IT HONESTLY CANNOT --
# ⚠️ A SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. Read the next
# paragraph before changing it, because the obvious objection is right
@@ -2608,8 +2822,15 @@ class Controller:
# here would be a surface to migrate later. This sensor is already
# discovered, already on every dashboard, and "failsafe" is
# additive to its vocabulary rather than a change to it.
# SAFETY-30 adds the site sign latch to the same vocabulary, for
# the reason the note above gives: "failsafe" is additive to a
# sensor that already exists and is already on every dashboard, and
# a P_SAFETY stop that did not reach it would be a site at 0 W
# reporting "running".
"status": ("failsafe" if any(i.sign_wd.tripped
for i in self.inverters)
or (self.site_sign_wd.tripped
and len(self.inverters) > 1)
or self.circ_wd.tripped
else "running" if self.auto else "stopped"),
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
@@ -2936,24 +3157,70 @@ class Controller:
# and a trip on B leaves A commanding. A single row would have to pick
# one of them to describe, and the honest answer at a roster of two is
# both. At a roster of one the wording is unchanged.
# ⚠️ AND SINCE SAFETY-30 THE ROW SAYS WHAT THE UNIT WAS COMPARED
# AGAINST, because at a roster of two that is no longer the meter and a
# row that still said "the command opposed the meter" would send an
# operator to look at P1 for a fault that is visible only on one
# inverter's own power sensor. `sign_wd.against` is the same string the
# trip sentence uses, so the two cannot drift apart.
for inv in self.inverters:
tag = "" if len(self.inverters) == 1 else f" [{inv.name}]"
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the
# shipped roster of one the two sentences below are the ones that
# have always been on the page, word for word - AC 8 is about what
# an operator sees as much as about what the detector computes, and
# "opposed a meter reading" would be a gratuitous change to a line
# somebody has learned to recognise. At N>=2 the noun is load-
# bearing: the row would otherwise send them to look at P1 for a
# fault that is visible only on one inverter's own power sensor.
vs = ("" if len(self.inverters) == 1
else f" against {inv.sign_wd.against}")
if inv.sign_wd.tripped:
what = ("the meter" if len(self.inverters) == 1
else inv.sign_wd.against)
out.append({"ok": False, "warn": False,
"text": f"SIGN-DISAGREEMENT FAILSAFE{tag} - "
+ inv.sign_wd.reason
+ ". The inverter is held at 0 W. Find out why the "
"command opposed the meter before starting control "
+ f". The inverter is held at 0 W. Find out why the "
f"command opposed {what} before starting control "
"again; starting clears this. Restarting the "
"add-on does NOT clear it."})
else:
held = inv.sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": f"sign watchdog{tag}: armed (trips after "
"text": f"sign watchdog{tag}: armed{vs} (trips after "
f"{inv.sign_wd.cfg.hold_s:.0f} s opposed, both over "
f"{inv.sign_wd.cfg.min_w:.0f} W)"
+ (f" - opposed for {held:.0f} s now" if held else "")})
# --- SAFETY-30: the SITE row -----------------------------------------
# ⚠️ ONLY AT N>=2, and the absence at N=1 is the honest report rather
# than a missing row. With one inverter the site comparison and the row
# above are the same sentence about the same actuator; printing it twice
# would advertise two guards where there is one, which is how an
# operator learns to skim the list.
if len(self.inverters) > 1:
if self.site_sign_wd.tripped:
out.append({"ok": False, "warn": False,
"text": "SITE SIGN-DISAGREEMENT FAILSAFE - "
+ self.site_sign_wd.reason
+ ". EVERY inverter is held at 0 W. This is the "
"SUM of the commands against the meter, so it is "
"not one inverter's fault to find - look at the "
"loop, the integrator and whatever won the "
"arbitration. Starting control clears this; "
"restarting the add-on does NOT."})
else:
_sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": f"site sign watchdog: armed against the sum of "
f"the commands (trips after "
f"{self.site_sign_wd.cfg.hold_s:.0f} s opposed, "
f"both over "
f"{self.site_sign_wd.cfg.min_w:.0f} W)"
+ (f" - opposed for {_sheld:.0f} s now"
if _sheld else "")})
# --- SAFETY-31 -----------------------------------------------------
# ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and the row says
# so. There is one circulating figure for the site; naming an inverter
@@ -3010,10 +3277,28 @@ class Controller:
level, banner = "bad", ("CIRCULATING POWER — the inverters were "
"pushing against each other. Site held at "
"0 W.")
elif self.site_sign_wd.tripped and len(self.inverters) > 1:
# SAFETY-30's site half, ABOVE the per-unit rows below and below
# circulating power above, and the ordering is the same argument
# both times: the banner carries one sentence, so it carries the one
# that says the widest true thing. A site sign trip means EVERY
# inverter is parked and the fault is in what the site was asking
# for, not in one device - naming a unit here would send an operator
# to swap the wrong box.
level, banner = "bad", ("FAILSAFE — the site's total command "
"opposed the meter. Every inverter held at "
"0 W.")
elif any(i.sign_wd.tripped for i in self.inverters):
# Outranks "NOT READY": nothing is missing, something went wrong.
level, banner = "bad", ("FAILSAFE — the command opposed the meter. "
"Inverter held at 0 W.")
# ⚠️ THE SENTENCE IS UNCHANGED AT A ROSTER OF ONE, where "the meter"
# is what the unit was graded against. At N>=2 it is that unit's own
# power, so the banner says so - see checks() on why the noun is
# load-bearing rather than cosmetic.
level, banner = "bad", (
"FAILSAFE — the command opposed the meter. "
"Inverter held at 0 W." if len(self.inverters) == 1 else
"FAILSAFE — an inverter's command opposed its own measured "
"power. That inverter held at 0 W.")
elif bad:
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may
# need to say more to an operator reading the checks list than fits
@@ -3118,6 +3403,24 @@ class Controller:
# unit in particular.
self.store.set("sign_tripped", False)
self.store.set("sign_reason", "")
# SAFETY-30's SITE latch, cleared by the SAME press and for the
# same reason the per-unit ones are: the condition always goes
# away on its own, because the trip commands 0 W everywhere and
# a site commanding zero cannot oppose anything. Only a human
# who has read the banner can say the site is clear.
#
# ⚠️ UNCONDITIONAL, LIKE THE FLAT PAIR ABOVE, AND NOT GUARDED ON
# THE ROSTER. An install that ran with two inverters, tripped,
# and was then reconfigured back to one would otherwise carry
# `site_sign_tripped: true` in its file forever, with no roster
# that can reach the code which clears it - and would re-arm the
# moment a second inverter was configured again.
if self.site_sign_wd.tripped:
self.log_event("site sign-disagreement failsafe cleared "
"by operator")
self.site_sign_wd.reset()
self.store.set("site_sign_tripped", False)
self.store.set("site_sign_reason", "")
# SAFETY-31, cleared by the SAME press and for the same reason:
# the condition always goes away on its own, because the trip
# commands 0 W everywhere and two inverters at zero cannot
+20
View File
@@ -71,6 +71,24 @@ _LOG = logging.getLogger("goodwe.store")
# the card is gone while the fault runs on - exactly how the 2026-09-02 outage
# went unseen for 6 h 25 m with the detector correct throughout. main.py
# re-raises the notification from this key on every start.
#
# ⚠️ AND SAFETY-30's PAIR IS FLAT FOR THE SAME REASON SAFETY-31's IS, WHICH IS
# NOT THE SAME REASON THE ONES ABOVE ARE NAMESPACED. `site_sign_tripped` /
# `site_sign_reason` hold the SITE sign-disagreement latch: the SUM of every
# standing command opposed the meter, continuously. That is a statement about
# the grid connection - the offending number is the site target, which no single
# inverter owns - so there is no unit to hang it on, and hanging it on one would
# let an operator clear "inverter B's half" of a fault that has no halves.
# `unit/<name>/sign_tripped` above stays what it always was and now means what
# it says: THAT unit's own command against THAT unit's own measured power.
#
# ⚠️ AND THEY ARE NOT IN `LEGACY_UNIT_KEYS`, MUST NEVER BE, AND ARE NOT WRITTEN
# AT A ROSTER OF ONE AT ALL. At N=1 the site comparison and the unit comparison
# are the same sentence and `Controller.cycle()` arms only the per-unit
# detector, so a one-inverter install's state file is byte-identical to what it
# was before this card - these two keys sit at their defaults and nothing reads
# them. A rollback therefore finds exactly the flat `sign_tripped` mirror it
# always found.
DEFAULTS = {
"phase": "idle",
"phase_started": None,
@@ -80,6 +98,8 @@ DEFAULTS = {
"sign_reason": "",
"circ_tripped": False,
"circ_reason": "",
"site_sign_tripped": False,
"site_sign_reason": "",
}
# --------------------------------------------------------------------------- #
+35 -1
View File
@@ -74,7 +74,8 @@ NOT READY banner already names the problem, (b) does not apply.
was overruled. That firmware file's own comment says a lower bound on this
"belongs in CFG-01, not here" - so here it is.
5. `sign_disagree_w` at or above `max_w`. [(a)]
5. `sign_disagree_w` - or SAFETY-30's `unit_sign_disagree_w` - at or above
`max_w`. [(a)]
The arbiter's `supervised` limit bounds every command to +-max_w, and
SAFETY-02's predicate needs `abs(command) > sign_disagree_w`. Set the
threshold at or above the ceiling and the command side of the comparison
@@ -234,6 +235,7 @@ from .p1 import is_enabled, validate_meter_options
from .peakguard import PEAK_OPTION_DEFAULTS
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
from .static_tariff import ProfileInvalid, parse_static_profile
from .watchdog import SIGN_OPTION_DEFAULTS
_LOG = logging.getLogger("goodwe.config")
@@ -741,6 +743,38 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
f"sign_disagree_w below {weakest:.0f} W (300 W is the measured "
"default), or raise max_w.")
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
# SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a
# unit's own SHARE against that unit's own measured power, and a share is
# bounded by the same `supervised` claim at +-max_w - so a threshold at or
# above the ceiling makes the command side unsatisfiable by construction,
# exactly as above. Written as its own branch rather than folded into the
# one above because the two produce different sentences and an operator
# needs to be told WHICH watchdog they have disarmed.
#
# ⚠️ REFUSED EVEN AT A ROSTER OF ONE, WHERE THIS CHECK IS NOT ARMED. A
# config that cannot work is refused when it is WRITTEN, not on the day a
# second inverter is added - which is the day nobody is watching the startup
# log. This is `meter_max_age_s`'s rule read the same way: the failure it
# prevents is silent.
unit_sign_w = _number(opts, "unit_sign_disagree_w",
float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]))
if unit_sign_w is None:
problems.append(f"unit_sign_disagree_w "
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
elif max_w is not None and max_w > 0 and unit_sign_w >= max_w:
problems.append(
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w is "
f"{max_w:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
"watchdog can never fire: no inverter is ever commanded past max_w, "
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
"inverters that is the guard which catches a unit doing the "
"OPPOSITE of what it was told while its sibling covers for it at "
"the meter - the case the site-level watchdog is structurally "
f"blind to. Lower unit_sign_disagree_w below {max_w:.0f} W "
f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the "
"measured default), or raise max_w.")
# --- 6. a maintenance charge the ceiling can actually accommodate --------
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
# `Claim.limit(hi=-charge_w)` against `supervised`'s `lo=-max_w`, so
+351 -18
View File
@@ -164,11 +164,106 @@ saturation freeze's question (|command - the battery's actual power| > 500 W,
NOTES.md §4.7 row 4), and the two checks are complementary rather than
overlapping. Neither substitutes for the other.
⚠️ THAT PARAGRAPH IS ABOUT THE COMMAND-VERSUS-METER COMPARISON, AND SINCE
SAFETY-30 THAT IS NOT THE ONLY ONE THIS MODULE DRIVES. At a roster of two or
more each unit is ALSO graded against its own measured power, which is exactly
the comparison the paragraph says nothing here makes - see the SAFETY-30 section
below, which says what that does and does not take away from the freeze. At the
shipped roster of one the paragraph stands unchanged, word for word: the site
comparison is the only detector armed and the freeze is still the whole answer.
Nor does it see a sign inversion introduced DOWNSTREAM of this add-on - in the
firmware, on the wire, or in a future driver. Our view stays self-consistent and
this stays silent. That is a wire-versus-intent problem and it belongs to the
witness, which is the only vantage point that sees both.
------------------------------------------------------------------------------
SAFETY-30 - AT TWO INVERTERS THIS IS TWO QUESTIONS, NOT ONE
Everything above was written at a roster of ONE, and at N=1 it is right for a
reason that stops being true at N=2. `commanded_w` is what THIS controller put
on THIS actuator. `grid_w` 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 comparing them sound. At two they are not:
* A FALSE TRIP THE COMPARISON CANNOT AVOID. "Inverter A commanded -1500 W
while the meter reads +1500 W" is evidence of nothing at N=2: inverter B may
be discharging 3000 W and doing exactly what it was told. No fault anywhere,
and the check NOTES.md calls the highest-value entry in the safety table -
93 % agreement while healthy against 4 % during runaway, four episodes, zero
false positives - fires.
* THE CONVERSE, WHICH IS WORSE. A unit genuinely driving the wrong way is
MASKED, because the site figure NETS. Commanded +1500 W, measured -1500 W,
beside a sibling measured at +1500 W: the meter sits exactly where a healthy
site leaves it. This is not a threshold that is set too high - the
information is destroyed by the summation, and no tuning recovers it.
So the one comparison becomes two, each asked of the evidence that can answer
it, and they are DIFFERENT FAULTS rather than one fault at two scales:
PER UNIT this unit's standing command against ITS OWN measured power -
`Inverter.batt`, which on the live system is holding register
0x0518, this inverter's own AC power, per-instance since DEV-02.
Conclusion: "THE ACTUATOR I AM DRIVING IS NOT DOING WHAT I TOLD
IT". The evidence is about one unit, so the remedy is that unit:
the `failsafe` SET, which arbiter.CLAIM_SCOPE has called a UNIT
claim from the day it was written, for this exact reason.
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 pushing the wrong
side of the meter. That is the fault the 2026-08-20 capture
contains and it is a property of the CONNECTION, so the remedy is
the connection: a SITE SET that stops every inverter.
⚠️ AND AT N=1 THERE IS EXACTLY ONE ARMED DETECTOR, WHICH IS THE ONE THAT
SHIPPED. The two questions above are the SAME SENTENCE at a roster of one: the
sum of one command is that command, and "the site" and "the unit" name one
actuator. Arming both would be arming a duplicate - two latches, two banners and
two chances to spend a false-positive record that was earned by ONE comparison
on two captured bus days. `Controller.cycle()` therefore hands the single unit's
watchdog THE METER, under the shipped `sign_disagree_w` / `sign_disagree_s`,
exactly as before, and never arms the site one. `arm()` below is where that
choice is written down. Nothing about a one-inverter install changes - not the
comparison, not the thresholds, not the store keys, not the claim, not the
banner.
⚠️ WHAT THE PER-UNIT COMPARISON IS NOT. Two guards that overlap without saying
so is how a false-alarm generator gets built, so both neighbours are named:
* IT IS NOT SAFETY-31. `circulation.py` compares the units AGAINST EACH OTHER
- `sum(|B_i|) - |sum(B_i)|` over MEASURED power - and catches two inverters
pushing THROUGH each other, which nets to zero at the meter and about which
no single command is evidence at all. This compares each unit against ITS
OWN COMMAND. Take two units faithfully executing an allocation that ALLOC-01
would refuse: circulating power is the whole loop, and every per-unit sign
check here reads perfect agreement. Take one unit doing the exact opposite
of its share while its sibling covers for it: this reads the whole
disagreement, and circulating power can be zero, because the two measured
powers may well agree in sign. Neither is the other with a different
threshold.
* IT IS NOT THE SATURATION FREEZE, AND THE OVERLAP IS REAL AND BOUNDED. The
freeze asks `|command - measured| > saturation_w` (500 W) and concludes "the
inverter cannot follow"; it then stops the integrator winding, which is a
control action taken every day, on a unit that is merely SATURATED as
readily as on one that is reversed, and it neither latches nor parks
anything. This asks for OPPOSITION - a sign, on both sides of a threshold,
held continuously - which a saturated-but-obedient unit cannot produce at
all, and it LATCHES and commands that unit to zero. The freeze is a
magnitude test that shapes the loop; this is a direction test that stops an
actuator.
⚠️ GATE 1 STILL APPLIES TO BOTH, AND THAT IS A DELIBERATE CHOICE WITH A COST.
The per-unit comparison could have been armed whenever ANY non-zero command is
standing - a unit should do what it is told whoever told it - and that would
catch a wedged inverter during a maintenance charge. It is not, because the
whole no-false-positive argument above is built on `following`, because the
freeze already grades that window, and because this card's own warning is that
SAFETY-02's record was earned at N=1 and must not be spent on a check that fires
when nothing is wrong. What it costs is written here rather than discovered:
between the start of a maintenance cycle and its end, a unit reversing against
its own command is the freeze's to notice, not this module's. Widening it is a
card, with a rig campaign behind it, not an edit.
------------------------------------------------------------------------------
STATE, TRIP AND LATCH
@@ -198,6 +293,178 @@ from dataclasses import dataclass
from datetime import datetime
# SAFETY-30. CFG-03: THE ONE IN-CODE COPY OF THE FOUR MULTI-INVERTER DEFAULTS.
# `config.yaml`'s `options:` block is the source of truth an operator edits and
# the Supervisor reads, and `schema:` holds the bounds. This dict exists for the
# reason `circulation.CIRC_OPTION_DEFAULTS` and `maintenance.MAINT_OPTION_DEFAULTS`
# do - the Dockerfile does not put config.yaml in the image, so the running
# add-on cannot read it - and `test_watchdog.py` pins the two together by READING
# config.yaml, so a drift goes red in CI rather than shipping.
#
# ⚠️ EVERY KEY HERE IS DEAD AT A ROSTER OF ONE, and that is the point rather
# than a caveat. `Controller._arm_sign_watchdogs()` arms the single unit's
# detector against the meter with the SHIPPED `sign_disagree_w` /
# `sign_disagree_s`, which are read in main.py exactly where they always were
# and are not in this dict. Nothing below is consulted until a second inverter
# exists.
SIGN_OPTION_DEFAULTS = {
# --- THE MAGNITUDE GATE, AND IT IS NOT SAFETY-02's 300 W BY ACCIDENT ---
# ⚠️ DERIVED FROM `control.Tuning.saturation_w`, WHICH IS 500 W, AND THE
# DERIVATION IS THE POINT. This check gates on BOTH sides being over
# `min_w`, so the smallest divergence it can ever see between a command and
# the power that command produced is `2 * min_w` - the two are OPPOSED, so
# the gap is the sum of the magnitudes. At 250 W that is exactly 500 W: the
# gap at which `compute()`'s own anti-windup test already concludes THE
# INVERTER IS NOT FOLLOWING and freezes the integrator. So this watchdog
# cannot fire on a divergence the shipped control law itself calls ordinary
# tracking, and the two guards meet at a number rather than overlapping in a
# band nobody has drawn.
#
# MEASURED, on 1890 closed-loop two-inverter runs in which NOTHING is faulty
# (every unit executes the share the allocator gave it), swept over 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. Worst CONTINUOUS opposition between a unit's standing command
# and its own 0x0518 power, as a function of this threshold:
#
# 50 W -> 25.0 s 250 W -> 10.0 s 700 W -> 5.0 s
# 150 W -> 15.0 s 300 W -> 10.0 s 1000 W -> 0.0 s
# 200 W -> 15.0 s 500 W -> 5.0 s
#
# ⚠️ AND THE CURVE IS FLAT, WHICH 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 holds 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.
# The work is therefore done by the WINDOW below, and this number is set
# where the control law already draws its line rather than being tuned.
#
# The largest opposed magnitude seen anywhere in those 1890 runs is 875 W;
# a threshold above that would gate NOTHING legitimate in at all, and is
# rejected because it would also be blind to a unit reversing a 900 W share.
"unit_sign_disagree_w": 250,
# --- THE WINDOW -------------------------------------------------------
# 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: `inverter.LINK_STALE_S`, 30 s. A unit not
# executing its share for less than the firmware's own veto window is a
# LINK fault, and the add-on already answers it - liveness drops the unit
# and the survivors carry its share, with the record saying so. Firing
# inside that window would raise a second, louder alarm about a state
# already being handled, which is 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 unlike the site comparison this one needs
# nothing to net out before it can conclude - the evidence is complete on
# the first cycle it appears.
#
# 85 s: seventeen control cycles - 8.5x the worst legitimate episode this
# rig can produce, 2.8x the firmware's own veto window, and under HALF the
# site hold. ⚠️ IT IS DELIBERATELY NOT 60, 75, 90, 120 OR 180. A round
# number here would be a guess wearing a measurement's clothes, and the
# three bounds above are what the number has to answer to.
#
# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST. Its ramp and lag in the rig
# are inverter A's, COPIED, carrying `measured=False` - SPIKE-04 measures
# the real ones. A slower second unit lengthens every crossing above, which
# is what the 8.5x buys headroom for. SPIKE-04 is the revisit trigger for
# both numbers here, not a reason to move them now.
"unit_sign_disagree_s": 85,
# --- THE SITE COMPARISON AT N>=2, AND IT IS NOT THE SHIPPED PAIR -------
# ⚠️ THE THRESHOLD IS CARRIED AND THE WINDOW IS NOT, WHICH IS A FINDING
# RATHER THAN A COMPROMISE. Only one of the two arguments behind 300 W /
# 180 s depends on how many inverters the site has:
#
# THE THRESHOLD 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`. The
# deadband is 15 W and P1 is one meter whatever the roster, so nothing
# about that argument moves. 300 W, carried - and the two-inverter
# campaign says the carry is safe rather than merely convenient: in the
# load-reversal family the site figure ever goes opposed AND over 300 W in
# 994 of 1050 runs at two inverters and 955 of 1050 at one. The gate opens
# about as often either way; what changed is how LONG it stays open, which
# is the window's problem and not the threshold's.
#
# THE WINDOW is about a TRANSIENT, and the transient scales with the site.
# The measured worst legitimate case is a load reversal that the loop
# crosses zero slowly: the command has to travel the whole rail at
# `slew_w` per cycle, and at N>=2 the rail is `Controller.site_max_w` - the
# ROSTER SUM - while `slew_w` is unchanged. So the crossing takes about
# twice as long at two inverters as at one, and that is exactly what the
# rig measures.
#
# 1890 closed-loop runs in which nothing is faulty, the SAME campaign the
# per-unit pair above is read off, run at BOTH rosters as its own control.
# Worst CONTINUOUS opposition between the standing command(s) and the meter,
# LOAD-REVERSAL family, at a 300 W threshold:
#
# N=1 (the shipped comparison) 110.0 s - inside 180 s, 1.6x
# N=2 (the sum, same stimuli) 215.0 s - OVER 180 s
#
# ⚠️ SO CARRYING 180 s WOULD HAVE SHIPPED A FALSE TRIP, and it is a config a
# schema-legal install can reach: `gain` 0.05 and `slew_w` 50 are both the
# floor of what config.yaml accepts, and a +-2200 W load reversal is an oven
# and a heat pump. That is the number this card exists to not assume.
#
# 325 s: 1.51x the measured worst, well inside the 900 s quarter that bills
# the capacity tariff, and still 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, so a 325 s window fires in all of them.
# What it costs is stated rather than hidden: about two and a half extra
# minutes of wrong-direction SITE power before the stop, bought to remove a
# false trip that a legal configuration produces. ⚠️ DELIBERATELY NOT 300 OR
# 360; the measurement is what it answers to.
#
# ⚠️ AND IT IS NOT THE PER-UNIT WINDOW EITHER, IN THE OTHER DIRECTION. The
# per-unit comparison is 85 s because a unit's own power CHASES ITS OWN
# COMMAND and the transient is two cycles; the site comparison is 325 s
# because the meter is driven by the LOAD and the transient is the loop
# crossing zero. Same predicate, two instruments, two windows, both measured.
"site_sign_disagree_w": 300,
"site_sign_disagree_s": 325,
}
# ⚠️ A LIMIT OF THE SITE COMPARISON THAT SAFETY-30 DID NOT INTRODUCE AND DOES
# NOT FIX, RECORDED HERE BECAUSE THE CAMPAIGN FOUND IT AND SILENCE WOULD BE
# WORSE. The rig's other stimulus family is a FLAPPING LOAD, and 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. Across the 840 flapping runs the worst continuous opposition
# at a 300 W threshold is 980 s at two inverters - and 1000 s at ONE, which is
# the whole point. The N=1 arm is the control, and it says this is a property of
# comparing a lagged command against a meter, not a property of the second
# inverter. No window inside the 900 s billing quarter survives it, and no
# threshold up to 1000 W does either.
#
# It is not acted on here for three reasons, in order of weight: it is identical
# at the roster this add-on actually 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 comment is its evidence.
# SAFETY-30. THE TWO NOUNS THE TRIP SENTENCE CAN NAME, WRITTEN ONCE. They are
# constants rather than literals at the call sites because `main.py` arms the
# detectors and `test_watchdog.py` grades what they say, and a string typed in
# two places is a string that drifts in one of them - the same argument
# `store.unit_key()` makes about a key name. `AGAINST_METER` is the wording that
# has always shipped and must not change: it is what a one-inverter install's
# `reason` reads, in the store, in the action log and on the status page.
AGAINST_METER = "a meter reading"
AGAINST_OWN_POWER = "its own measured power"
@dataclass(frozen=True)
class SignWatchdogConfig:
"""Defaults, not constants - config.yaml carries both (SAFETY-02 AC 5)."""
@@ -206,7 +473,7 @@ class SignWatchdogConfig:
hold_s: float = 180.0
def disagrees(commanded_w, grid_w, following: bool,
def disagrees(commanded_w, against_w, following: bool,
cfg: SignWatchdogConfig) -> bool:
"""The predicate, and the ONLY place it is written down.
@@ -215,12 +482,23 @@ def disagrees(commanded_w, grid_w, following: bool,
inline. Two copies of a safety predicate is one copy plus a future
disagreement about what the check means, and the copy that drifts is always
the one in the harness that grades it.
⚠️ `against_w` WAS CALLED `grid_w`, AND THE RENAME IS THE WHOLE OF SAFETY-30
IN ONE PARAMETER. The arithmetic is unchanged - opposed signs, neither side
near zero, and the loop actually driving - but WHAT the command is opposed
TO is now a property of the caller rather than a fact about this function.
The site comparison hands it the meter; each unit's own comparison hands it
that unit's measured power (register 0x0518). One predicate, two callers,
for `loop_is_following()`'s reason: a safety predicate written down twice is
one copy plus a future disagreement about what the check means. The
parameter is positional at every call site in both repositories, so the
rename moves no caller.
"""
return (following
and commanded_w is not None and grid_w is not None
and commanded_w * grid_w < 0
and commanded_w is not None and against_w is not None
and commanded_w * against_w < 0
and abs(commanded_w) > cfg.min_w
and abs(grid_w) > cfg.min_w)
and abs(against_w) > cfg.min_w)
def loop_is_following(resolution, loop_asked) -> bool:
@@ -272,7 +550,17 @@ def loop_is_following(resolution, loop_asked) -> bool:
class SignWatchdog:
"""One instance per actuator, evaluated once per control cycle.
"""One instance per COMPARISON, evaluated once per control cycle.
⚠️ SINCE SAFETY-30 THAT IS NOT THE SAME SENTENCE AS "one per actuator",
which is what this docstring used to say. There is one instance per
inverter - `Inverter.sign_wd`, latched under that unit's own store keys -
AND one for the site, `Controller.site_sign_wd`, latched under flat ones.
The two ask different questions of different evidence and reach different
remedies; `arm()` below is where an instance is told which of them it is.
At a roster of one they would be the same question, so only the per-unit
instance is ever armed there - see the SAFETY-30 section of the module
docstring.
⚠️ NOT SHARED ACROSS CADENCES. `disagree_since` is a wall-clock start time,
so driving one instance from two loops at two rates would have each one
@@ -282,8 +570,14 @@ class SignWatchdog:
"""
def __init__(self, cfg: SignWatchdogConfig | None = None, *,
tripped: bool = False, reason: str = ""):
tripped: bool = False, reason: str = "",
against: str = AGAINST_METER):
self.cfg = cfg or SignWatchdogConfig()
# SAFETY-30. WHAT THE COMMAND IS BEING COMPARED AGAINST, as the operator
# reads it in the trip sentence. The default is the meter, which is what
# every trip this add-on has ever taken was compared against and is what
# keeps a one-inverter install's `reason` string byte-identical.
self.against = str(against)
self.disagree_since: datetime | None = None
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED. A latch that lives only in
# memory is not a latch: a container restart clears it, and the add-on
@@ -292,8 +586,31 @@ class SignWatchdog:
self.tripped = bool(tripped)
self.reason = reason if self.tripped else ""
# -- which of SAFETY-30's two questions this instance is asking ----------
def arm(self, cfg: SignWatchdogConfig, against: str) -> None:
"""Set the thresholds and the subject of the comparison. Nothing else.
⚠️ IT IS CONFIGURATION, NOT STATE, AND IT DELIBERATELY DOES NOT TOUCH
`disagree_since`, `tripped` OR `reason`. A detector that cleared its own
window when it was told what it was comparing against would lose the
episode it is in the middle of, every cycle, and would never accumulate
a hold at all - the check would be permanently green and nothing would
say so.
⚠️ AND IT IS RE-STATED EVERY CYCLE RATHER THAN FROZEN AT CONSTRUCTION.
The answer is a property of the ROSTER and of nothing else, and
`Controller.cycle()` already re-reads the roster every cycle for
liveness and for the allocation. Freezing it in `__init__` would also
make it wrong for any caller that appends an inverter afterwards, which
is exactly how a two-inverter fixture is built in this repository - and
a fixture that silently got the one-inverter comparison would grade
nothing while looking green.
"""
self.cfg = cfg
self.against = str(against)
# -- the whole check, and it is a pure function of its arguments ----------
def evaluate(self, commanded_w, grid_w, following: bool,
def evaluate(self, commanded_w, against_w, following: bool,
now: datetime) -> bool:
"""Advance the detector one cycle. Returns True while tripped.
@@ -301,15 +618,23 @@ class SignWatchdog:
after deadband, slew, quantisation and the arbiter. Not
the optimizer's raw wish: the wire carries the emitted
value, so anything else can disagree with the meter for a
reason that is not a fault.
`grid_w` net P1, + = importing. None when the meter is stale or
missing, which CLEARS the timer rather than accumulating
- a frozen figure held against a moving command would
otherwise manufacture a disagreement in about three
minutes, on top of SAFETY-01 having already commanded
zero for the same cause.
reason that is not a fault. For a per-unit instance it is
THAT UNIT'S share of it; for the site instance it is the
roster sum.
`against_w` what the command is graded against, and SAFETY-30 made
this a decision rather than a fact: net P1 for the SITE
comparison (+ = importing), and this unit's own measured
power for the PER-UNIT one (+ = discharging, register
0x0518). Both carry the same sign convention as the
command, which is why one predicate serves both. None
when the reading is stale or missing, which CLEARS the
timer rather than accumulating - a frozen figure held
against a moving command would otherwise manufacture a
disagreement in about three minutes, on top of SAFETY-01
having already commanded zero for the same cause.
`following` did the grid-following loop actually win this cycle? See
gate 1 in the module docstring.
gate 1 in the module docstring, and the SAFETY-30 section
on why the per-unit comparison keeps it.
`now` the caller's clock, so this stays testable and replayable.
"""
if self.tripped:
@@ -317,7 +642,7 @@ class SignWatchdog:
return True
cfg = self.cfg
if not disagrees(commanded_w, grid_w, following, cfg):
if not disagrees(commanded_w, against_w, following, cfg):
self.disagree_since = None
return False
@@ -330,9 +655,17 @@ class SignWatchdog:
return False
self.tripped = True
# ⚠️ `self.against` IS THE ONLY THING SAFETY-30 CHANGED IN THIS STRING,
# and it defaults to the wording that shipped - "a meter reading" - so a
# one-inverter install's `reason` is byte-identical to what an operator
# (and test_watchdog.py) has always read. A per-unit instance names what
# it actually compared instead, because "commanded +1500 W against a
# meter reading -1500 W" would be a false statement about which
# instrument produced the evidence, on the one line somebody woken by
# this alarm reads first.
self.reason = (
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against a meter "
f"reading {grid_w:+.0f} W for {held:.0f} s continuously "
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against "
f"{self.against} {against_w:+.0f} W for {held:.0f} s continuously "
f"(limits: both over {cfg.min_w:.0f} W, held over {cfg.hold_s:.0f} s)"
)
return True
+83
View File
@@ -167,6 +167,62 @@ options:
sign_disagree_w: 300
sign_disagree_s: 180
# --- SAFETY-30 per-inverter sign-disagreement watchdog ---------------------
# ⚠️ A DIFFERENT COMPARISON, NOT A SECOND COPY OF THE ONE ABOVE, AND IT IS
# ARMED ONLY WHEN THIS SITE HAS MORE THAN ONE INVERTER. The pair above grades
# the SITE's total command against P1 - one meter, one grid connection. This
# pair grades EACH inverter's own share against THAT inverter's own measured
# power (holding register 0x0518), and it catches the fault the meter cannot
# answer for once there are two units: a unit doing the opposite of what it
# was told, while its sibling covers for it and P1 reads exactly what a
# healthy site reads.
#
# At ONE inverter these two keys do nothing at all. §6.2's identity makes the
# site's total command that inverter's command, so the pair above is already
# asking this question of the same actuator, and arming a second detector
# would be two latches and two banners for one fault.
#
# Both defaults are measured on admin/goodwe's two-master rig, over 1890
# closed-loop runs in which nothing is faulty, swept across the whole range
# this file accepts for `gain` and `slew_w`. 250 W is `saturation_w / 2`, so
# the smallest divergence this can see is exactly the gap at which the control
# law itself already says the inverter is not following; 85 s is 8.5x the
# worst legitimate opposition those runs produce (10.0 s), 2.8x the firmware's
# own 30 s link veto, and under half the 180 s the site check takes. See
# app/watchdog.py's SIGN_OPTION_DEFAULTS and
# DECISIONS/SAFETY-30-per-unit-sign.md.
#
# ⚠️ unit_sign_disagree_w must stay BELOW max_w, for sign_disagree_w's reason:
# a share is bounded to +-max_w, so a threshold at or above it can never fire.
# validate.py refuses that config.
unit_sign_disagree_w: 250
unit_sign_disagree_s: 85
# --- SAFETY-30 SITE sign-disagreement watchdog -----------------------------
# ⚠️ THE SAME COMPARISON `sign_disagree_*` MAKES, AT THE ROSTER WHERE IT STOPS
# BEING A STATEMENT ABOUT ONE INVERTER. At two or more units the SUM of the
# standing commands is graded against P1, and the trip stops the whole site -
# what it catches is the site being asked for the wrong thing, which every
# per-inverter check is blind to because each unit is faithfully executing its
# share. At ONE inverter these two keys do nothing: `sign_disagree_*` above is
# already asking this of the same actuator.
#
# The threshold is the shipped 300 W, CARRIED, and the reason is that the
# argument behind it does not depend on the roster: near zero the sign is
# noise, the deadband is 15 W, and there is one P1 whatever is behind it.
#
# ⚠️ THE WINDOW IS NOT CARRIED, AND THAT IS A MEASUREMENT. The transient 180 s
# 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 - a rail
# that is the roster sum while `slew_w` is not. On 1890 two-inverter runs in
# which nothing is faulty, the worst continuous legitimate opposition at 300 W
# is 110 s at one inverter and 215 s at two: carrying 180 s across would have
# shipped a false trip reachable at `gain: 0.05`, `slew_w: 50` and an ordinary
# 2.2 kW load reversal. 325 s is 1.51x that, inside the 900 s billing quarter,
# and still inside every episode of the 2026-08-20 fault day.
site_sign_disagree_w: 300
site_sign_disagree_s: 325
# --- SAFETY-18 frozen-but-fresh meter detector -----------------------------
# Seconds the meter may keep answering with an UNCHANGED measurement while the
# command stands at the rail before the add-on raises an alarm. Both halves
@@ -487,6 +543,33 @@ schema:
sign_disagree_w: int(50,5000)
sign_disagree_s: int(30,1800)
# SAFETY-30. No "off" value, for SAFETY-02's reason.
#
# ⚠️ THE THRESHOLD'S RANGE IS THE SAME AS THE SITE CHECK'S ON PURPOSE, AND THE
# WINDOW'S FLOOR IS NOT. On the two-inverter rig the per-unit comparison's
# worst continuous legitimate opposition is nearly FLAT in the threshold -
# 25.0 s at 50 W, 15.0 s at 150 W, 10.0 s at 250 W, 5.0 s at 500 W - because a
# unit's own power chases its own command rather than the load. So no value in
# this range puts the guard outside the regime it can be answered for, and the
# floor does not have to be a knee the way `circulation_w`'s does.
unit_sign_disagree_w: int(50,5000)
# The floor of 30 is `inverter.LINK_STALE_S`, the firmware's own veto window:
# below it, a unit not executing its share is a LINK fault the add-on already
# answers by dropping the unit from the split, and firing inside that window
# would be a second, louder alarm about a state that is already being handled.
unit_sign_disagree_s: int(30,1800)
# SAFETY-30's SITE pair. Same ranges as `sign_disagree_*`, because it is the
# same comparison asked of the site total - and no "off" value, for the same
# reason. ⚠️ THERE IS DELIBERATELY NO validate.py RULE PAIRING
# site_sign_disagree_w WITH max_w, unlike the other two thresholds: the bound
# on the SITE command is `Controller.site_max_w`, the ROSTER SUM, and
# `validate_startup_config()` is handed options rather than a roster. A rule
# written against max_w would refuse a perfectly reachable two-inverter
# configuration the day CFG-04 lands. The schema range is the bound here.
site_sign_disagree_w: int(50,5000)
site_sign_disagree_s: int(30,1800)
# No "off" value, for SAFETY-02's reason: disarming a safety alarm is not a
# supported configuration. ⚠️ THE FLOOR OF 60 IS NOT A RECOMMENDATION. At 300 s
# the slowest a HEALTHY meter's 1 Wh register can tick and still look unchanged
+13 -5
View File
@@ -165,21 +165,29 @@ SITE_SOURCES = {
"identically zero for any one unit - there is no fact of "
"the matter about which of two opposing inverters is the "
"offender, so there is nothing to file it per-unit on",
# SAFETY-30, and it is the entry most easily confused with `failsafe`
# below, because the same three words describe both.
"sign_site": "the SUM of every standing command against the meter. The "
"offending number is the SITE target, which no single "
"inverter owns - parking one of them would leave the rest "
"pushing the same way with the guard satisfied",
}
UNIT_SOURCES = {
"device": "one inverter's own reported min/max rating",
"supervised": "max_w caps what may be WRITTEN to one setpoint entity",
"failsafe": "site-wide evidence, per-unit remedy: SAFETY-02 grades the "
"command THIS controller put on THIS actuator",
"failsafe": "per-unit evidence, per-unit remedy: since SAFETY-30 it "
"grades THIS unit's command against THIS unit's OWN "
"measured power (0x0518), so what it learns is about one "
"device. The site-wide half moved to `sign_site`",
"maintenance": "the monthly recalibration is a pack procedure",
}
for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}")
check("all NINE claim sources are classified, and only those nine",
check("all TEN claim sources are classified, and only those ten",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the five that are about the GRID CONNECTION are site claims",
check("the six that are about the GRID CONNECTION are site claims",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
check("the four that are about a PACK, a DEVICE or one write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
@@ -200,7 +208,7 @@ check("with one inverter every source routes to the site, per-inverter ones "
check("...and with none configured at all, likewise",
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("with TWO the identity breaks and the split becomes real: the four "
"per-inverter sources route to the unit this controller drives, the five "
"per-inverter sources route to the unit this controller drives, the six "
"site sources stay on the grid connection",
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
== {**{s: "inverter-a" for s in UNIT_SOURCES},
+52 -17
View File
@@ -609,28 +609,40 @@ print("3. AC 3: a trip on B does not latch A, and it survives a restart")
# inverters get a share of ONE site target, so an ordinary wrong-way loop trips
# BOTH - correctly, because both really are pushing the wrong way. The state
# this part is about is the one the flat store keys made unreachable: ONE
# inverter standing on a command that opposes the meter while the other does
# not. It is reached the way the field would reach it - inverter B's writes are
# being REJECTED, so `inv.last_write` stays where it was while A's tracks the
# loop - and `standing` is what SAFETY-02 grades, per unit.
# inverter standing on a command that opposes what it is graded against while
# the other does not. It is reached the way the field would reach it - inverter
# B's writes are being REJECTED, so `inv.last_write` stays where it was while
# A's tracks the loop - and `standing` is what SAFETY-02 grades, per unit.
#
# ⚠️ SAFETY-30 CHANGED WHAT "OPPOSES" MEANS HERE, AND THE FIXTURE HAD TO MOVE
# WITH IT - WHICH IS THIS CHECK DOING ITS JOB. It used to stand B on a -1500 W
# charge against a +2000 W METER and let the site figure convict it. At a roster
# of two that comparison is exactly the defect SAFETY-30 removes: the meter
# answers for BOTH inverters, so it is evidence about neither. B is now
# convicted by its OWN power sensor - standing on a -1500 W charge while
# `sensor.batt_b` measures it DISCHARGING at +1500 W, which is one inverter
# doing the opposite of what it was told and is a fact about that inverter
# alone. Inverter A's own reading stays at 0 W, so A is never gated in.
#
# ⚠️ AND THE REJECTION WINDOW IS WHY THIS RUNS IN THREE SECONDS AND NOT THIRTY.
# `sign_disagree_s` is 1 s here; `LINK_STALE_S` is 30 s, so B is still LIVE for
# the whole run and the trip is graded before liveness has anything to say.
# `unit_sign_disagree_s` is 1 s here; `LINK_STALE_S` is 30 s, so B is still LIVE
# for the whole run and the trip is graded before liveness has anything to say.
def _tripping_site():
hass = Hass(
{"sensor.grid": 2000.0, "sensor.soc_a": 50.0, "sensor.batt_a": 0.0,
"sensor.soc_b": 50.0, "sensor.batt_b": 0.0},
"sensor.soc_b": 50.0, "sensor.batt_b": 1500.0},
{"number.sp_a": (-A_RATING, A_RATING, 1.0),
"number.sp_b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS, 1.0)},
reject={"number.sp_b"})
store = MemStore()
c, _ = built({"sign_disagree_w": 300, "sign_disagree_s": 1,
"unit_sign_disagree_w": 300, "unit_sign_disagree_s": 1,
"auto_start": True}, store=store, hass=hass)
c.inverters.append(inverter_b(store, sign_cfg=c.inverters[0].sign_wd.cfg))
c.auto = True
# B is standing on a charge command while the house imports 2000 W. Its
# writes are rejected, so this is what it goes on standing at.
# B is standing on a charge command while its own sensor says it is
# discharging. Its writes are rejected, so this is what it goes on
# standing at.
c.inverters[1].last_write = -1500.0
return c, hass, store
@@ -681,10 +693,12 @@ check("⚠️ ...AND THE TRIP IS MIRRORED ONTO THE PRE-DEV-02 FLAT PAIR, WHICH I
# THE RESTART, which is the whole reason the latch is persisted at all.
_h3b = Hass({"sensor.grid": 2000.0, "sensor.soc_a": 50.0,
"sensor.batt_a": 0.0, "sensor.soc_b": 50.0, "sensor.batt_b": 0.0},
"sensor.batt_a": 0.0, "sensor.soc_b": 50.0,
"sensor.batt_b": 1500.0},
{"number.sp_a": (-A_RATING, A_RATING, 1.0),
"number.sp_b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS, 1.0)})
_c3r, _ = built({"sign_disagree_w": 300, "sign_disagree_s": 1,
"unit_sign_disagree_w": 300, "unit_sign_disagree_s": 1,
"auto_start": True}, store=_s3, hass=_h3b)
_c3r.inverters.append(inverter_b(_s3,
sign_cfg=_c3r.inverters[0].sign_wd.cfg))
@@ -703,12 +717,32 @@ check("a brand new Controller against the same store comes back with B "
check("...and A goes on carrying the site target across the restart too",
_c3r.allocation.shares.get("inverter-a") == _c3r.allocation.sum_w
and _c3r.allocation.sum_w != 0)
check("...and the status page shows BOTH rows - one latched, one armed. A "
"single row would have to pick one inverter to describe, and 'the sign "
"watchdog is armed' printed over a latched sibling is the page lying",
len([r for r in _c3r.checks() if "sign watchdog" in r["text"]]) == 1
check("...and the status page shows BOTH per-inverter rows - one latched, one "
"armed. A single row would have to pick one inverter to describe, and "
"'the sign watchdog is armed' printed over a latched sibling is the page "
"lying",
len([r for r in _c3r.checks()
if r["text"].startswith("sign watchdog [")]) == 1
and len([r for r in _c3r.checks()
if "SIGN-DISAGREEMENT FAILSAFE" in r["text"]]) == 1)
if "SIGN-DISAGREEMENT FAILSAFE [" in r["text"]]) == 1)
check("⚠️ AND SAFETY-30's SITE ROW IS THERE BESIDE THEM, EXACTLY ONCE. At two "
"inverters there are three sign questions on this page and they are three "
"rows: is A doing what it was told, is B, and is the SITE'S TOTAL "
"pointing the right way. The last one is not derivable from the other "
"two - both units can execute their shares perfectly while the number "
"they were given is itself wrong - so a page that showed only the "
"per-unit rows would be advertising two guards where the install has "
"three",
len([r for r in _c3r.checks()
if r["text"].startswith("site sign watchdog:")]) == 1)
check("...and the per-inverter rows now say WHAT the unit was compared "
"against, because at a roster of two it is no longer the meter. A row "
"still reading 'the command opposed the meter' would send an operator to "
"look at P1 for a fault that is visible only on one inverter's own power "
"sensor",
all("its own measured power" in r["text"] for r in _c3r.checks()
if r["text"].startswith(("sign watchdog [",
"SIGN-DISAGREEMENT FAILSAFE ["))))
# The operator's clear, which is one button and has to reach every latch.
run(_c3r.handle_action("auto_toggle")) # -> stopped
@@ -728,8 +762,9 @@ check("...and the same press cleared the flat rollback view, so a downgrade "
"- and neither does the next start, which now reads that key on EVERY "
"boot rather than only on the one that migrates",
_s3.data["sign_tripped"] is False and _s3.data["sign_reason"] == "")
_c3r2, _ = built({"sign_disagree_w": 300, "sign_disagree_s": 1}, store=_s3,
hass=_h3b)
_c3r2, _ = built({"sign_disagree_w": 300, "sign_disagree_s": 1,
"unit_sign_disagree_w": 300, "unit_sign_disagree_s": 1},
store=_s3, hass=_h3b)
check("⚠️ AND THE RESTART AFTER THAT CLEAR COMES UP UNLATCHED. This is the "
"'re-arm forever' hazard the every-start guard would have if the clear "
"did not reach both spellings, driven through the real button and a real "
+47 -25
View File
@@ -2410,8 +2410,19 @@ check("...and so does the persistent notification, which outlives the page and "
# loop} at every SoC from 0 to 100 %, because nothing in those fixtures
# circulates. What it does is stop the site when two inverters are measured
# pushing against each other. See DECISIONS/SAFETY-31-circulating-power.md.
# ⚠️ SAFETY-30 ADDED THE TENTH, AND IT NEEDS ITS OWN PARAGRAPH FOR THE REASON
# `circulation` needed one. `sign_site` is a SET at P_SAFETY on the SITE
# actuator, filed only while `site_sign_wd.tripped` AND the roster holds more
# than one inverter. It is not a second copy of `failsafe`: that one grades ONE
# unit's command against THAT unit's own measured power and parks that unit,
# while this grades the SUM of the commands against the meter and stops the
# site. It appears in NO cycle of the sweeps below - the dynamic halves still
# observe exactly {device, supervised, loop} at every SoC, because the shipped
# roster is one inverter and this claim cannot be filed there at all. See
# DECISIONS/SAFETY-30-per-unit-sign.md.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop", "circulation"}
"arbitrage", "maintenance", "loop", "circulation",
"sign_site"}
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
# that a THIRD such indirection reads as an unrecognised append rather than as a
# silent gap in the scan.
@@ -2590,13 +2601,17 @@ print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
if v == SCOPE_SITE))
+ " | per-inverter: "
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
check("...and the split is the one ARB-03 argued, with SAFETY-31's addition: "
"the five about the GRID CONNECTION are site claims and the four about a "
"pack, a device or one write are per-inverter. `circulation` is a site "
"claim because its quantity is defined over the roster and is identically "
"zero for any one unit - there is no inverter to attribute it to",
check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
"SAFETY-30's additions: the six about the GRID CONNECTION are site "
"claims and the four about a pack, a device or one write are "
"per-inverter. `circulation` is a site claim because its quantity is "
"defined over the roster and is identically zero for any one unit; "
"`sign_site` is one because the number that tripped it is the SITE "
"target, which no single inverter owns - while `failsafe` beside it is "
"per-inverter because since SAFETY-30 it grades one unit's command "
"against that unit's OWN measured power",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
== {"loop", "peak", "arbitrage", "safety", "circulation"}
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance"})
@@ -2672,19 +2687,21 @@ _restore_b()
print(" with a second inverter on the roster, main.py routes: "
+ ", ".join(f"{s}->{a}" for s, a in sorted(_routed.items())))
check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
"inverter and its OWN routing method splits the nine: `device`, "
"inverter and its OWN routing method splits the ten: `device`, "
"`supervised`, `failsafe` and `maintenance` bind the unit this controller "
"drives, `loop`, `peak`, `arbitrage`, `safety` and SAFETY-31's "
"`circulation` stay on the grid connection. Swap any one of them - route "
"`peak` to the unit and the site keeps no floor; route `maintenance` to "
"the site and it can cross that floor into `lo > hi`; route `circulation` "
"to one unit and a site-wide loop parks the inverter that may well be the "
"one still listening - and this line goes red",
"drives, `loop`, `peak`, `arbitrage`, `safety`, SAFETY-31's `circulation` "
"and SAFETY-30's `sign_site` stay on the grid connection. Swap any one of "
"them - route `peak` to the unit and the site keeps no floor; route "
"`maintenance` to the site and it can cross that floor into `lo > hi`; "
"route `circulation` to one unit and a site-wide loop parks the inverter "
"that may well be the one still listening; route `sign_site` to one unit "
"and a SITE total pointing the wrong way parks one inverter while the "
"rest of the roster goes on pushing the same way - and this line goes red",
_routed == {"device": "inverter-a", "supervised": "inverter-a",
"failsafe": "inverter-a", "maintenance": "inverter-a",
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": ACTUATOR_SITE})
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
check("...and the roster it was restored to is the shipped one, so nothing "
"below inherits a two-inverter Controller",
c_src.units == (app_main.UNIT_A,) and len(c_src.inverters) == 1)
@@ -2716,7 +2733,7 @@ check("...and the roster it was restored to is the shipped one, so nothing "
# actuator as a LITERAL - `actuator=self.actuator_of('<source>')` - so the
# literal is simply required to EQUAL the source the claim is filed under. That
# is what makes "a mis-typed `actuator_of(...)` goes red" a true sentence about
# all nine rather than about the three a fixture happens to reach.
# all ten rather than about the three a fixture happens to reach.
def append_actuator(node):
@@ -2765,16 +2782,16 @@ _unit_sources_unrouted = [
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all NINE of `Controller.cycle()`'s DIRECT appends route through "
check("all TEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once",
len(_direct) == 9
len(_direct) == 10
and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE NINE THE LITERAL IT ROUTES THROUGH IS THE "
check("⚠️ AND AT EVERY ONE OF THE TEN THE LITERAL IT ROUTES THROUGH IS THE "
"SOURCE THE CLAIM IS FILED UNDER. THIS is the line a mis-typed "
"`actuator_of(...)` at a call site goes red on - all nine of them, on "
"the line the typo is written, at every roster, INCLUDING the six "
@@ -2788,8 +2805,11 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
"site-scoped sources and is the whole defect for a per-inverter one: a "
"`device` limit built inside the roster loop and routed without `inv` "
"files inverter B's rating on inverter A, where it binds the wrong "
"share. Five of the nine appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe` and maintenance's two - and all five pass one",
"share. Five of the ten appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe` and maintenance's two - and all five pass one. "
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
"the site, beside `circulation`, because the number that tripped it is "
"the site target",
_unit_sources_unrouted == []
and len([r for r in _routing
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 5)
@@ -2844,13 +2864,15 @@ check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN "
("loop", ACTUATOR_SITE), ("supervised", "inverter-a"),
("supervised", "inverter-b")])
check("...and naming those three is not an accident of this fixture but the "
"reason the static pin exists - six of the nine sources need a state "
"reason the static pin exists - SEVEN of the ten sources need a state "
"no check in this file reaches. SAFETY-31's `circulation` is one of the "
"six: it is filed only while the site is MEASURED to be circulating, "
"which no fixture here is",
"seven: it is filed only while the site is MEASURED to be circulating, "
"which no fixture here is. SAFETY-30's `sign_site` is another, and it "
"needs TWO states at once - a roster of two AND the site's total command "
"standing opposed to the meter for its whole window",
{c.source for c in _cl2}
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
"arbitrage", "circulation"})
"arbitrage", "circulation", "sign_site"})
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
# WHOSE NUMBERS the bound is built from - and those are two different defects
+17
View File
@@ -523,9 +523,26 @@ check("...and the message explains it would read as a trip, not a mistake",
# check below.
check("the smallest sane max_w still starts",
validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
unit_sign_disagree_w=0.5,
maintenance_charge_w=1), -5000, 5000) == [])
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
why(validate_startup_config(base(max_w=1), -5000, 5000), "never fire"))
# ⚠️ SAFETY-30 ADDED A SECOND WAY TO DISARM A SIGN WATCHDOG BY CONFIGURATION,
# and the fixture above now has to name BOTH thresholds - which is the check
# doing its job rather than a bookkeeping edit. `unit_sign_disagree_w` bounds a
# unit's own SHARE, which the same `supervised` claim holds at +-max_w, so at or
# above the ceiling it is unsatisfiable by construction exactly as
# `sign_disagree_w` is. Refused even at the shipped roster of one, where this
# watchdog is not armed: a config that cannot work is refused when it is
# written, not on the day somebody adds a second inverter.
check("...and the per-inverter threshold has the same rule, named separately "
"so an operator is told WHICH watchdog they disarmed",
why(validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
maintenance_charge_w=1), -5000, 5000),
"unit_sign_disagree_w"))
check("...and one watt under that ceiling is reachable, so accepted",
validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999),
-5000, 5000) == [])
# --------------------------------------------------------------------------- #
print("what must NOT be refused - a validator that fires on trivia gets deleted")
+706 -2
View File
@@ -401,10 +401,19 @@ print("controller and the first thing an operator will try")
class _MemStore:
"""A Store that keeps its dict across "restarts", like the real file does."""
"""A Store that keeps its dict across "restarts", like the real file does.
⚠️ SEEDED FROM `store.DEFAULTS`, NOT FROM A LITERAL. `Store.load()` merges
that dict over whatever is on disk, so a fake that hand-listed three keys
answered `None` where the real store answers a default - and a check reading
"this latch was never written" then could not tell an absent key from a
False one. It is also a place to drift: SAFETY-31 and SAFETY-30 both added
flat pairs, and neither would have appeared here.
"""
def __init__(self):
self.data = {"phase": "idle", "sign_tripped": False, "sign_reason": ""}
from app.store import DEFAULTS as _SDEF
self.data = dict(_SDEF)
def set(self, k, v):
self.data[k] = v
@@ -1105,6 +1114,701 @@ check("⚠️ AND THE 'HAS A HUMAN SEEN THIS' FLAG IS CLEARED WITH IT. Left "
_cc._circ_announced is False)
print()
print("=" * 72)
print("SAFETY-30: at two inverters the sign check is TWO questions, not one")
print("=" * 72)
# ⚠️ READ THIS BEFORE ADDING A CHECK HERE. Half of this card was already built
# and it is NOT the half below. DEV-02 shipped the PER-INSTANCE half: each
# `Inverter` owns its own `SignWatchdog`, latched under its own namespaced store
# keys, and a trip on B no longer latches A. test_inverter.py part 3 grades
# that; nothing here re-tests it.
#
# What is graded here is THE COMPARISON ITSELF. Before this card every unit's
# detector was handed `self.grid`. There is ONE P1, so at N>=2 that is a number
# this controller put on ONE actuator, graded against a measurement that answers
# for ALL of them - wrong in both directions: a FALSE trip when a sibling
# accounts for the meter, and a MISSED one when a sibling MASKS a unit that
# really is reversed. The fix is two comparisons, and they are two different
# faults: each unit against ITS OWN measured power, and the SUM of the standing
# commands against the meter.
#
# ⚠️ AND IT IS NOT SAFETY-31, WHICH IS THE SECTION ABOVE. `circulation.py`
# compares the units AGAINST EACH OTHER and catches two inverters pushing
# THROUGH each other, which nets to zero at the meter. This compares each unit
# against ITS OWN COMMAND and catches a unit not doing what it was told. They
# overlap in exactly one scenario - a unit reversing while its sibling pulls the
# other way - and part 4 below asserts what each one concludes there, because
# two guards that overlap without saying so is how a false-alarm generator gets
# built.
from app.control import Tuning as _Tuning # noqa: E402
from app.watchdog import ( # noqa: E402
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS, disagrees,
)
print()
print("1. ONE predicate, TWO second terms - and the trip sentence says which")
# ⚠️ THE ARITHMETIC IS UNCHANGED AND THAT IS THE POINT: `disagrees()` was not
# split in two. What moved is a PARAMETER NAME - `grid_w` became `against_w` -
# because what the command is opposed TO is now the caller's decision. A second
# copy of a safety predicate is one copy plus a future disagreement about what
# the check means (see `loop_is_following()`), so there is still exactly one.
_scfg = SignWatchdogConfig(min_w=300.0, hold_s=180.0)
_ucfg = SignWatchdogConfig(
min_w=float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]),
hold_s=float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"]))
check("the same call that means 'commanded -1000 W into a +1500 W import' "
"means 'commanded -1000 W while measuring +1500 W of discharge' when the "
"caller hands it a unit's own power - one function, two questions",
disagrees(-1000.0, 1500.0, True, _scfg) is True)
check("...and every gate still binds on the second term whatever it is: a "
"250 W reading does not qualify against a 300 W gate",
disagrees(-1000.0, 250.0, True, _scfg) is False)
check("...nor does a unit sitting at zero, which is a REFUSED command and not "
"a reversed one - the distinction the module docstring makes about a "
"battery declining a discharge, now read of the unit's own sensor",
disagrees(1500.0, 0.0, True, _ucfg) is False)
_wd_site = SignWatchdog(_scfg)
_wd_unit = SignWatchdog(_ucfg, against=AGAINST_OWN_POWER)
for _t in range(0, 400, 5):
_wd_site.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=_t))
_wd_unit.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=_t))
print(f" site: {_wd_site.reason!r}")
print(f" unit: {_wd_unit.reason!r}")
check("⚠️ THE SITE INSTANCE'S SENTENCE IS BYTE-IDENTICAL TO THE ONE THAT "
"SHIPPED. It is what an operator reads on the status page, what goes in "
"the action log and what is persisted to /data/state.json, so it is "
"asserted against the literal rather than against a format string",
_wd_site.reason == (
"SIGN_DISAGREEMENT: commanded -1000 W against a meter reading "
"+1500 W for 180 s continuously (limits: both over 300 W, held over "
"180 s)"))
check("...and the per-unit instance names what it ACTUALLY compared instead. "
"'against a meter reading' there would be a false statement about which "
"instrument produced the evidence, on the one line somebody woken by "
"this alarm reads first",
AGAINST_OWN_POWER in _wd_unit.reason
and AGAINST_METER not in _wd_unit.reason
and "+1500 W" in _wd_unit.reason)
# ⚠️ `arm()` IS CONFIGURATION AND MUST NOT BE STATE. `Controller.cycle()` calls
# it on EVERY cycle, so a version that cleared `disagree_since` there would
# restart the window every cycle, never accumulate a hold, and leave the check
# permanently green with nothing saying so. Driven rather than argued: open a
# window, re-arm mid-episode, and see the window still standing.
_rearm = SignWatchdog(_ucfg, against=AGAINST_OWN_POWER)
_rearm.evaluate(-1000.0, 1500.0, True, T0)
_rearm.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=5))
_rearm.arm(_ucfg, AGAINST_OWN_POWER)
check("re-arming mid-episode leaves the window exactly where it was",
_rearm.disagree_since == T0)
for _t in range(10, 200, 5):
_rearm.arm(_ucfg, AGAINST_OWN_POWER)
_rearm.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=_t))
check("...so a detector re-armed on every one of 38 cycles still trips at its "
"85 s hold, which is what says `arm()` did not quietly disarm it",
_rearm.tripped)
print()
print("2. CFG-03: config.yaml is the source of truth and there is ONE copy")
_sign_from_yaml = {k: int(v) for k, v in
_re.findall(r"^ ((?:unit|site)_sign_disagree_\w+):\s*(\d+)\s*$",
_cfg_opts, _re.M)}
print(f" config.yaml options: {_sign_from_yaml}")
print(f" SIGN_OPTION_DEFAULTS: {SIGN_OPTION_DEFAULTS}")
check("all four new options are declared in config.yaml's options: block and "
"the in-code mirror agrees key for key and value for value - the "
"Dockerfile does not put config.yaml in the image, so the running add-on "
"cannot read it and this is where the two can drift",
_sign_from_yaml == SIGN_OPTION_DEFAULTS and len(_sign_from_yaml) == 4)
_sign_bounds = {m[0]: (int(m[1]), int(m[2])) for m in
_re.findall(r"^ ((?:unit|site)_sign_disagree_\w+):"
r"\s*int\((\d+),(\d+)\)\s*$",
_cfg_schema, _re.M)}
print(f" schema bounds: {_sign_bounds}")
check("...and ALL FOUR have a schema range, so the Supervisor refuses an "
"out-of-range value rather than the add-on discovering it at runtime",
set(_sign_bounds) == set(SIGN_OPTION_DEFAULTS))
check("the shipped defaults sit inside their own schema ranges",
all(_sign_bounds[k][0] <= v <= _sign_bounds[k][1]
for k, v in SIGN_OPTION_DEFAULTS.items()))
check("⚠️ THE THRESHOLD IS `saturation_w / 2`, WHICH IS WHERE THE CONTROL LAW "
"ITSELF ALREADY DRAWS THE LINE. This check gates on BOTH sides, and the "
"two are OPPOSED, so the smallest command-versus-power gap it can ever "
"see is 2 x min_w. At 250 W that is exactly the 500 W at which "
"`compute()`'s own anti-windup test concludes the inverter is not "
"following and freezes the integrator - so this watchdog cannot fire on "
"a divergence the shipped loop calls ordinary tracking, and the two "
"guards meet at a number instead of overlapping in a band nobody drew",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"] * 2
== _Tuning().saturation_w)
check("...and the window clears its three bounds: above the firmware's own "
"30 s link veto (`inverter.LINK_STALE_S` - inside it, a unit not "
"executing its share is a LINK fault the add-on already answers by "
"dropping it from the split), and UNDER HALF the 180 s the site check "
"takes, because the per-unit conclusion needs nothing to net out before "
"it is complete",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"] > LINK_STALE_S
and SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"] < 180 / 2)
check("⚠️ AND NEITHER IS A ROUND NUMBER. 250 W is read off `saturation_w`; "
"85 s is 8.5x the worst legitimate opposition 1890 two-inverter runs "
"produce (10.0 s) and 2.8x the firmware veto. 60, 75, 90, 100, 120, 180 "
"and 300 are all values a guess would have landed on",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"]
not in (30, 60, 75, 90, 100, 120, 180, 300))
# ⚠️ AND THE SITE PAIR IS TWO DECISIONS, NOT ONE, WHICH IS WHY BOTH ARE PINNED.
# The THRESHOLD is carried from the shipped pair because its argument is about
# the meter's noise floor and the deadband - a property of ONE P1 and of
# `Tuning.deadband_w`, neither of which the roster touches. The WINDOW is NOT
# carried, because the transient it rejects is the loop crossing zero across a
# rail that IS the roster sum: measured at 110 s with one inverter and 215 s
# with two, on the same stimuli, against a 180 s hold. That is a carried number
# justified and a carried number refused, in the same pair.
check("the SITE threshold is the shipped one, carried - the noise argument "
"behind 300 W is about the meter and the deadband, and a second inverter "
"changes neither",
SIGN_OPTION_DEFAULTS["site_sign_disagree_w"] == 300)
check("⚠️ ...AND THE SITE WINDOW IS LONGER THAN THE SHIPPED 180 s, WHICH IS THE "
"MEASUREMENT REFUSING TO BE CARRIED. At two inverters the worst "
"legitimate opposition the rig produces is 215.0 s - reachable at "
"`gain: 0.05`, `slew_w: 50` and a 2.2 kW load reversal, all inside what "
"config.yaml accepts - so 180 s would have shipped a false trip on the "
"check with the best record in the safety table. 325 s is 1.51x that",
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] > 215.0
and SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] < 215.0 * 2)
check("...and it is still inside the 900 s quarter that bills the capacity "
"tariff, and inside every episode the 2026-08-20 fault day contains - "
"its four clusters run 16, 30, 77 and 77 minutes",
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] < 900
and SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] < 16 * 60)
check("...and it is deliberately not 300 or 360, the two round numbers next to "
"it",
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] not in (300, 360, 600, 900))
print()
print("3. AC 1: ONE unit drives the wrong way, and ONLY that unit trips")
# ⚠️ THE PLANT IS MODELLED AND SAYS SO. `_SignHass` is two inverters that obey
# INSTANTLY - no ramp, no lag - and one meter that is `load - sum(measured)`.
# That is deliberately not a claim about transients: the worst legitimate
# opposition a real ramp and lag can produce is measured on admin/goodwe's
# two-master rig, over 1890 closed-loop runs, and recorded in
# DECISIONS/SAFETY-30-per-unit-sign.md. What this fixture is for is the
# ATTRIBUTION - which detector concludes what, from which instrument - and for
# that an instant plant is the honest model, because it removes the one
# mechanism that could make a unit look reversed without being reversed.
class _SignHass(_Hass):
"""Two inverters, one meter, and a chosen subset that does the OPPOSITE.
`reversed_units` are roster indices whose measured power is the NEGATION of
what this controller told them - HW-01's wedged power stage, an inverted
driver, a unit executing a stale share backwards. Everything else obeys.
"""
def __init__(self, load_w=2000.0, reversed_units=()):
super().__init__()
self.load_w = float(load_w)
self.reversed_units = set(reversed_units)
self.ctrl = None
def measured(self):
if self.ctrl is None:
return [0.0, 0.0]
out = []
for i, inv in enumerate(self.ctrl.inverters):
w = inv.last_write if inv.last_write is not None else inv.target
w = float(w or 0.0)
out.append(-w if i in self.reversed_units else w)
return out
async def number(self, entity, invert=False):
m = self.measured() + [0.0, 0.0]
return {"sensor.grid": self.load_w - sum(m[:2]),
"sensor.soc": 50.0, "sensor.soc_b": 50.0,
"sensor.batt": m[0], "sensor.batt_b": m[1]}.get(entity)
def _sign_site(hass, store=None, **over):
"""A two-inverter Controller, with SAFETY-31 deliberately given a long window.
⚠️ THE LONG `circulation_hold_s` IS ISOLATION, NOT A CLAIM THAT SAFETY-31 IS
SILENT HERE. A unit reversing while its sibling pulls the other way IS
circulating power and SAFETY-31 sees it - part 4 asserts exactly that, and
what each guard concludes about it. What must not happen is the SITE being
stopped by circulation while this section is trying to grade a PER-UNIT
attribution, because then every "only B is parked" check below would be
measuring the wrong guard.
⚠️ THE THREE THRESHOLD PAIRS ARE PAIRWISE DISTINCT IN BOTH COORDINATES,
AND THAT IS LOAD-BEARING RATHER THAN COSMETIC. This fixture is the only
place in the suite where `_arm_sign_watchdogs()` runs at a roster of two, so
it is the only place that can see WHICH pair reaches WHICH detector. The
first version of it set every hold to 1 s and two of the three magnitude
gates to 300 W; a detector armed with the shipped pair, or with the site
pair, or with the right magnitude and the wrong hold, then compared EQUAL
to the pair it was supposed to have, and the wiring check below would have
passed on three of those four arrangements. That is MUTATION.md's third
failure mode written into a fixture - a check comparing two things that
happen to be equal cannot see the difference it names - and the
precondition beside the check is what stops a later edit collapsing them
again without a red line.
Anyone changing a hold here: the PER-UNIT hold is what part 3's trip timing
is read against, and the SITE hold must still expire inside `_drive()`'s
window (14 cycles x 0.2 s), which part 5 depends on.
"""
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": 0.6,
"sign_disagree_w": 300, "sign_disagree_s": 3,
"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 1,
"site_sign_disagree_w": 320, "site_sign_disagree_s": 2,
"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())
b = Inverter("inverter-b", setpoint_entity="number.sp_b",
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
max_w=B_RATING_HYPOTHESIS, store=c.store)
b.dev_min, b.dev_max = -B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS
c.inverters.append(b)
c.auto = True
for inv in c.inverters:
if inv.dev_min is None:
inv.dev_min, inv.dev_max = -5000.0, 5000.0
hass.ctrl = c
return c
async def _drive(c, n=14, dt=0.2, watch=None):
seen = []
for _ in range(n):
await c.read_inputs()
await c.cycle()
if watch is not None:
seen.append(watch(c))
await asyncio.sleep(dt)
return seen
async def _one_reversed():
hass = _SignHass(load_w=2000.0, reversed_units={1})
c = _sign_site(hass)
seen = await _drive(c, watch=lambda c: dict(
grid=c.grid,
stood=[i.last_write if i.last_write is not None else i.target
for i in c.inverters],
meas=[i.batt for i in c.inverters],
a=c.inverters[0].sign_wd.tripped, b=c.inverters[1].sign_wd.tripped,
site=c.site_sign_wd.tripped))
return c, hass, seen
_c30, _h30, _s30 = asyncio.run(_one_reversed())
_a30, _b30 = _c30.inverters
# ⚠️ THE WIRING PIN FOR AC 5, AND IT IS A DIFFERENT CLAIM FROM EVERY
# BEHAVIOURAL CHECK IN THIS SECTION. The rest of part 3 grades what the
# detectors CONCLUDED; this grades WHICH THRESHOLDS THEY WERE HANDED, and the
# gap between the two was live. Replacing
# `arm(self.sign_cfg if one else self.unit_sign_cfg, ...)` with
# `arm(self.sign_cfg, ...)` in `Controller._arm_sign_watchdogs()` throws the
# re-derived per-unit pair away and arms every unit with SAFETY-02's
# meter-facing one - and left ALL 19 FILES GREEN. Nothing at a roster of two
# read `inv.sign_wd.cfg` at all; the one place that does is part 7's N=1 arm,
# which that edit PRESERVES.
#
# WHY THE TRIPS BELOW CANNOT CATCH IT, said here because "the suite is green"
# and "nothing was wrong" are the two readings this comment exists to separate.
# B is reversed at ~2000 W - far over 250 W and far over 300 W - and every hold
# in this fixture is short enough to expire inside `_drive()`. A fault that is
# big enough and long enough to trip under BOTH pairs is evidence about
# neither. Only an equality on the pair itself can tell them apart.
#
# WHAT THE SURVIVOR COSTS IN THE FIELD, at config.yaml's own defaults: the
# per-unit check runs at `sign_disagree_s` 180 s instead of the re-derived 85 s
# - 2.1x slower to park a reversed inverter - and its gate moves from 250 W to
# 300 W, so a unit reversing a share between the two becomes invisible, while
# watchdog.SIGN_OPTION_DEFAULTS, config.yaml and DECISIONS section 3 all go on
# describing a pair the running code does not use.
_UNIT_PAIR = (250.0, 1.0) # `unit_sign_disagree_w` / `_s` - one per unit
_SITE_PAIR = (320.0, 2.0) # `site_sign_disagree_w` / `_s` - one per site
_SHIPPED_PAIR = (300.0, 3.0) # `sign_disagree_w` / `_s` - N=1 only
_armed = {inv.name: (inv.sign_wd.cfg.min_w, inv.sign_wd.cfg.hold_s)
for inv in _c30.inverters}
_armed_site = (_c30.site_sign_wd.cfg.min_w, _c30.site_sign_wd.cfg.hold_s)
print(f" armed per unit: {_armed}")
print(f" armed site: {_armed_site} "
f"shipped pair, wanted by NOBODY here: {_SHIPPED_PAIR}")
check("PRECONDITION: the three pairs are pairwise distinct IN BOTH "
"COORDINATES, so an equality below can fail on the magnitude alone, on "
"the hold alone, or on both. This fixture originally gave all three the "
"same 1 s hold and two of them the same 300 W gate, and a mis-wiring "
"that delivered the right magnitude with the wrong hold would then have "
"compared EQUAL to the pair it was supposed to have",
len({_UNIT_PAIR, _SITE_PAIR, _SHIPPED_PAIR}) == 3
and len({p[0] for p in (_UNIT_PAIR, _SITE_PAIR, _SHIPPED_PAIR)}) == 3
and len({p[1] for p in (_UNIT_PAIR, _SITE_PAIR, _SHIPPED_PAIR)}) == 3)
check("PRECONDITION: and all three are pairs this Controller actually holds, "
"so the literals below are the fixture's own numbers and not three "
"constants that agree with nothing. In particular the shipped pair is "
"still constructed and still a reachable mis-wiring target",
(_c30.unit_sign_cfg.min_w, _c30.unit_sign_cfg.hold_s) == _UNIT_PAIR
and (_c30.site_sign_cfg.min_w, _c30.site_sign_cfg.hold_s) == _SITE_PAIR
and (_c30.sign_cfg.min_w, _c30.sign_cfg.hold_s) == _SHIPPED_PAIR)
check("⚠️ AC 5 WIRING. EVERY per-unit detector on this roster of two is armed "
"with the PER-UNIT pair - 250 W / 1 s here, `unit_sign_disagree_w` and "
"`unit_sign_disagree_s` in the field. Graded against LITERALS rather "
"than against `_c30.unit_sign_cfg`, which cannot tell 'the per-unit "
"pair' from 'whatever this Controller was handed'. This is the line that "
"goes red when `_arm_sign_watchdogs()` stops choosing between the two "
"pairs and hands every unit the site-facing one",
len(_armed) == 2 and all(p == _UNIT_PAIR for p in _armed.values()))
check("...and the SITE detector is armed with the SITE pair - 320 W / 2 s "
"here - which differs from the per-unit pair AND from the shipped pair "
"in both coordinates, so this equality is not carried by a number the "
"fixture happens to hold twice",
_armed_site == _SITE_PAIR)
check("...and each detector is pointed at its own INSTRUMENT as well as at its "
"own thresholds: the units at their own 0x0518 power, the site at the "
"meter. They are two arguments of one call and a pin on one is not a pin "
"on the other",
all(inv.sign_wd.against == AGAINST_OWN_POWER for inv in _c30.inverters)
and _c30.site_sign_wd.against == AGAINST_METER)
_mid = _s30[3]
print(f" cycle 3: stood {[round(x) for x in _mid['stood']]} "
f"measured {[round(x) for x in _mid['meas']]} "
f"meter {_mid['grid']:+.0f} W")
check("PRECONDITION: the stimulus is not vacuous - inverter B really is doing "
"the OPPOSITE of what it was told, and inverter A really is obeying. "
"Without this line every claim below could be true of a fixture in which "
"nothing was reversed",
_mid["stood"][1] * _mid["meas"][1] < 0
and abs(_mid["meas"][1]) > 250
and _mid["stood"][0] * _mid["meas"][0] > 0)
check("⚠️ AC 1. THE REVERSED UNIT TRIPS AND ITS SIBLING DOES NOT. The evidence "
"is B's own 0x0518 power against B's own share, so it is a fact about B "
"and nothing about A - which is what makes parking one of them the right "
"remedy rather than an attribution nobody measured",
_b30.sign_wd.tripped and not _a30.sign_wd.tripped)
check("...and the reason names the instrument, so the operator is sent to the "
"right one", AGAINST_OWN_POWER in _b30.sign_wd.reason)
print(f" {_b30.sign_wd.reason!r}")
# ⚠️ READ OFF THE ALLOCATION RECORD, NOT OFF `Controller.reason`. At a roster
# of two `reason` is the SITE resolution's line - "loop -> 2000 W" - because
# B's failsafe is a UNIT claim resolved on B's own actuator and never competes
# for the site winner. That is the DEV-02 split working, and a check that
# grepped the site line for "failsafe" would go green only while the roster was
# one. The per-unit record is where the pin is stated, and it names its source.
_bplan = [pl for pl in (_c30.allocation.plans if _c30.allocation else ())
if pl.unit == "inverter-b"]
print(f" allocation: {_c30.allocation.explain()}")
print(f" inverter-b plan: {_bplan[0].why if _bplan else '(none)'}")
check("...B is commanded 0 W by its OWN `failsafe` claim, named in the "
"allocation record, rather than by some other guard happening to stop it",
_b30.target == 0.0 and _bplan and "failsafe" in _bplan[0].why)
check("...and A IS STILL COMMANDING. One inverter in trouble stops that "
"inverter; the site goes on running on the one that works, which is what "
"a UNIT-scoped claim buys and what a site-wide stop would have thrown "
"away", _a30.target != 0.0)
check("...the latch is under B's OWN namespaced key, and A's is untouched",
_c30.store.data.get(unit_key("inverter-b", "sign_tripped")) is True
and _c30.store.data.get(TRIPPED_KEY) is False)
print()
print("4. AC 2: and it is caught EVEN THOUGH THE METER SAYS EVERYTHING IS FINE")
# ⚠️ THIS IS THE CHECK THE WHOLE CARD IS FOR, so it is built the way MUTATION.md
# says to build one: the claim is "the new comparison sees what the old one
# structurally cannot", and the OLD comparison is RUN, on the same samples, and
# its blindness is asserted as its own precondition and PRINTED - not argued.
_old_view = [disagrees(s["stood"][1], s["grid"], True, _scfg) for s in _s30]
_site_view = [disagrees(sum(s["stood"]), s["grid"], True, _scfg) for s in _s30]
print(f" the SHIPPED comparison (B's command vs the meter) was true on "
f"{sum(_old_view)} of {len(_old_view)} cycles")
print(f" the SITE comparison (sum of commands vs the meter) on "
f"{sum(_site_view)} of {len(_site_view)}")
check("⚠️ THE COMPARISON THAT SHIPPED IS FALSE ON EVERY SINGLE CYCLE HERE. B is "
"commanded to discharge and the house is importing, so B's command and "
"the meter AGREE all the way through - the meter cannot tell that the "
"watts arriving are inverter A's while B does the opposite. This is not "
"a threshold set too high: the summation destroys the evidence and no "
"tuning recovers it",
not any(_old_view))
check("...and the SITE comparison is blind for the same reason, which is why "
"it is kept as a SEPARATE question rather than offered as the fix",
not any(_site_view))
check("...while the shipped SITE watchdog stayed correctly silent, so nothing "
"here is a site-level trip wearing a per-unit label",
not _c30.site_sign_wd.tripped
and _c30.store.data.get("site_sign_tripped") is not True)
check("⚠️ AND SAFETY-31 SEES SOMETHING TOO, WHICH IS NOT A DUPLICATE AND IS "
"SAID HERE RATHER THAN LEFT FOR SOMEBODY TO DISCOVER. A unit reversing "
"while its sibling pulls the other way IS power going round a loop, so "
"`circulating_w()` is over its threshold on this fixture - it is only "
"the long window this fixture gives it that keeps it from stopping the "
"site. The two guards CONCLUDE different things: circulating power is "
"defined over the roster and names no offender, while this one names B "
"and parks B. Neither is the other with a different threshold",
max(circulating_w(s["meas"]) for s in _s30) > _c30.circ_wd.cfg.min_w
and not _c30.circ_wd.tripped
and _c30.site_circulating_w == 0.0)
print(f" circulating power over the episode: "
f"{[round(circulating_w(s['meas'])) for s in _s30]}")
check("...and it falls back to ZERO the moment B is parked, which is the "
"remedy working rather than the sensor going quiet: a unit held at 0 W "
"cannot be half of a loop. It is asserted on the EPISODE rather than on "
"the end state for exactly that reason",
_s30[-1]["meas"][1] == 0.0)
print()
print("5. AC 3: the SITE question survives, separately, and catches whole-site")
print(" reversal - which no per-unit check can see")
# ⚠️ THE FAULT HERE IS THE ONE THE 2026-08-20 CAPTURE CONTAINS, and it is
# INVISIBLE to every per-unit comparison by construction: both inverters are
# doing EXACTLY what they were told. What is wrong is the number they were told.
# A negative gain is the minimal faithful model of it - an inverted polarity, a
# wound-up integrator, a strategy on the wrong side of the meter - and it is not
# a reachable configuration (`gain` is float(0.05,1.0)), which is the point: it
# stands in for a code regression, which is the case a check can hold the line on.
async def _site_reversed():
hass = _SignHass(load_w=2000.0, reversed_units=())
c = _sign_site(hass, gain=-0.6)
seen = await _drive(c, watch=lambda c: dict(
grid=c.grid, site=c.site_sign_wd.tripped,
stood=[i.last_write if i.last_write is not None else i.target
for i in c.inverters],
meas=[i.batt for i in c.inverters]))
return c, hass, seen
_c31, _h31, _s31 = asyncio.run(_site_reversed())
# ⚠️ THE LAST SAMPLE BEFORE THE TRIP, NOT A FIXED INDEX. After the trip every
# command is 0 W by design, so a fixed index reads the REMEDY rather than the
# fault and the precondition below would be asserting that a stopped site is
# stopped. This is `Run.p2p_between`'s lesson from the rig, in a unit test: name
# the window rather than inferring it.
_pre31 = [x for x in _s31 if not x["site"]]
_mid31 = _pre31[-1]
print(f" last cycle before the trip: stood "
f"{[round(x) for x in _mid31['stood']]} "
f"measured {[round(x) for x in _mid31['meas']]} "
f"meter {_mid31['grid']:+.0f} W")
check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
"agrees in sign with its own share - and the site is still charging into "
"an importing house. Without this the site trip below could be a "
"per-unit fault in disguise",
all(s * m > 0 for s, m in zip(_mid31["stood"], _mid31["meas"]))
and sum(_mid31["stood"]) * _mid31["grid"] < 0)
check("⚠️ AC 3. THE SITE WATCHDOG TRIPS AND NOT ONE PER-UNIT WATCHDOG DOES. "
"That asymmetry is the card: the fault is in the number the site was "
"asking for, which no single inverter owns, and every unit is innocent "
"of it",
_c31.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c31.inverters))
print(f" {_c31.site_sign_wd.reason!r}")
check("...and it stops the WHOLE site, through the `sign_site` claim by name "
"rather than by some other guard happening to stop it",
_c31.target == 0.0 and "sign_site" in _c31.reason
and all(i.target == 0.0 for i in _c31.inverters))
check("...the latch is FLAT - `site_sign_tripped`, beside SAFETY-31's pair - "
"and NOT mirrored onto the pre-DEV-02 `sign_tripped`, which is the "
"rollback view of a UNIT latch. A downgraded add-on drives exactly one "
"actuator; handing it a latch taken on evidence about a roster it cannot "
"have would be a trip it can never explain",
_c31.store.data.get("site_sign_tripped") is True
and "SIGN_DISAGREEMENT" in str(_c31.store.data.get("site_sign_reason"))
and _c31.store.data.get("sign_tripped") is not True)
check("the status page carries a SITE row of its own, and the banner says the "
"site's TOTAL command opposed the meter rather than naming an inverter - "
"naming one would send an operator to swap the wrong box",
any("SITE SIGN-DISAGREEMENT FAILSAFE" in ck["text"]
for ck in _c31.checks())
and "total command" in _c31.status_payload()["banner"])
_pub30 = {}
_c31.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub30.update(v),
"close": lambda _s: None})()
_c31.publish()
check("...and the controller-status sensor reports the failsafe state, so a "
"site parked at 0 W cannot publish 'running'",
_pub30.get("status") == "failsafe")
# The latch, and the one way out of it.
_was31 = _c31.site_sign_wd.tripped
asyncio.run(_c31.handle_action("auto_toggle")) # stop
check("pressing STOP clears nothing - the condition always goes away on its "
"own, because the trip commands 0 W and a site at zero cannot oppose "
"anything", _c31.site_sign_wd.tripped and _was31)
asyncio.run(_c31.handle_action("auto_toggle")) # the deliberate start
check("pressing START clears the latch in memory and on disk together, which "
"is the same deliberate human act that clears every other latch here",
not _c31.site_sign_wd.tripped
and _c31.store.data.get("site_sign_tripped") is False
and _c31.store.data.get("site_sign_reason") == "")
async def _site_restart(store):
hass = _SignHass(load_w=2000.0)
c = _sign_site(hass, store=store, gain=-0.6)
await _drive(c, n=3, dt=0.05)
return c
_store31 = _MemStore()
_store31.data["site_sign_tripped"] = True
_store31.data["site_sign_reason"] = "SIGN_DISAGREEMENT: (from before the restart)"
_c31r = asyncio.run(_site_restart(_store31))
check("⚠️ AND IT SURVIVES A RESTART, which is the only thing that makes it a "
"latch rather than a pause. 'Restart the add-on' is the first thing an "
"operator staring at a FAILSAFE banner tries, and a site that came back "
"armed would drive straight into the fault it had just stopped for",
_c31r.site_sign_wd.tripped and _c31r.target == 0.0
and "sign_site" in _c31r.reason)
print()
print("6. AC 4: two inverters commanded OPPOSITE SIGNS by a legitimate")
print(" allocation, and nothing trips")
# ⚠️ THIS IS THE FALSE TRIP THE CARD OPENS WITH, AND IT IS DRIVEN THROUGH THE
# SHIPPED PREDICATE RATHER THAN DESCRIBED. Inverter A charging 1500 W while
# inverter B discharges 1500 W into a 1500 W import is an ordinary two-inverter
# arrangement - a per-unit maintenance charge on A while the loop drives B, or
# SAFETY-25's floor pinning one unit. Nothing is faulty. Before this card the
# meter convicted A on it.
_A_CMD, _B_CMD, _METER = -1500.0, 1500.0, 1500.0
_wd_a = SignWatchdog(_ucfg, against=AGAINST_OWN_POWER)
_wd_b = SignWatchdog(_ucfg, against=AGAINST_OWN_POWER)
_sitecfg = SignWatchdogConfig(
min_w=float(SIGN_OPTION_DEFAULTS["site_sign_disagree_w"]),
hold_s=float(SIGN_OPTION_DEFAULTS["site_sign_disagree_s"]))
_wd_s = SignWatchdog(_sitecfg)
for _t in range(0, 1200, 5):
_wd_a.evaluate(_A_CMD, _A_CMD, True, T0 + timedelta(seconds=_t))
_wd_b.evaluate(_B_CMD, _B_CMD, True, T0 + timedelta(seconds=_t))
_wd_s.evaluate(_A_CMD + _B_CMD, _METER, True, T0 + timedelta(seconds=_t))
print(f" A commanded {_A_CMD:+.0f} W and measured {_A_CMD:+.0f} W; "
f"B {_B_CMD:+.0f} / {_B_CMD:+.0f}; meter {_METER:+.0f} W")
check("PRECONDITION: THE COMPARISON THAT SHIPPED CONVICTS INVERTER A HERE, on "
"every one of these samples. A is commanded to CHARGE while the meter "
"reads IMPORT - opposed, both far over 300 W, and it would have held for "
"20 minutes. That is the false trip, and it is asserted rather than "
"asserted about",
disagrees(_A_CMD, _METER, True, _scfg) is True)
check("⚠️ AC 4. NEITHER UNIT TRIPS UNDER THE NEW COMPARISON, over 20 minutes at "
"an 85 s hold. Each one is doing exactly what it was told, and its own "
"power sensor is the instrument that can say so",
not _wd_a.tripped and not _wd_b.tripped)
check("...and the SITE watchdog does not trip either, because the shares SUM "
"to zero and a 0 W total is not a wrong-direction command - the "
"magnitude gate that has always been there, now applied to the quantity "
"it belongs on", not _wd_s.tripped and _wd_s.disagree_since is None)
# ⚠️ AND THE COUNTERFACTUAL, BECAUSE "nothing tripped" IS ALSO WHAT A DISARMED
# GUARD REPORTS. Same site, same meter, but the shares no longer cancel: a NET
# -1600 W of charge into a +1500 W import is the site genuinely driving the
# wrong way, and it must trip. Without this line the check above passes
# identically against a site watchdog that was never wired to anything.
_wd_s_net = SignWatchdog(_sitecfg)
for _t in range(0, 600, 5):
_wd_s_net.evaluate(-1600.0, _METER, True, T0 + timedelta(seconds=_t))
check("...and the site question is not thereby disarmed: the same site, "
"commanded a NET -1600 W into the same import, DOES trip",
_wd_s_net.tripped)
print()
print("7. AC 8: at ONE inverter this card changes NOTHING, and 'nothing' is")
print(" asserted rather than assumed")
async def _n1_sign():
hass = _SignHass(load_w=2000.0)
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": -0.6,
"sign_disagree_w": 300, "sign_disagree_s": 1,
"auto_start": True}
c = Controller(opts, hass, _MemStore(), _P(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -5000.0, 5000.0
hass.ctrl = c
# ⚠️ A CALL COUNTER, NOT AN OBSERVATION OF THE DETECTOR'S STATE, AND A
# MUTATION IS WHY. The first version of the check below read
# `site_sign_wd.disagree_since is None` - and arming the site detector at
# N=1 as well left this suite GREEN, because the unit detector trips first
# at its 1 s hold, parks the inverter, and the site's standing command is
# then 0 W: the site predicate is false, so its window is never opened and
# "never evaluated" reads identically to "evaluated and found nothing". That
# is MUTATION.md's third failure mode - the assertion ran, the sentence was
# true, and the data could not reach the failure it names. A counter can
# only be zero for the one reason.
_n1_calls = []
_orig_eval = c.site_sign_wd.evaluate
def _spy(*a, **kw):
_n1_calls.append(a)
return _orig_eval(*a, **kw)
c.site_sign_wd.evaluate = _spy
await _drive(c, n=12, dt=0.2)
return c, _n1_calls
_c1s, _n1_site_calls = asyncio.run(_n1_sign())
_inv1 = _c1s.inverters[0]
print(f" roster={_c1s.units} armed against {_inv1.sign_wd.against!r} "
f"at {_inv1.sign_wd.cfg.min_w:.0f} W / {_inv1.sign_wd.cfg.hold_s:.0f} s")
check("the single unit is graded against THE METER, at the SHIPPED thresholds "
"- §6.2's identity, and the whole compatibility promise of this card. "
"The unit's own power is not consulted, and `unit_sign_disagree_*` does "
"nothing on a one-inverter install",
_inv1.sign_wd.against == AGAINST_METER
and (_inv1.sign_wd.cfg.min_w, _inv1.sign_wd.cfg.hold_s) == (300.0, 1.0))
check("...it trips, on the same evidence and through the same UNIT `failsafe` "
"claim as before, commanding 0 W",
_inv1.sign_wd.tripped and _c1s.target == 0.0
and "failsafe" in _c1s.reason and "sign_site" not in _c1s.reason)
check("...the reason sentence still says 'a meter reading', word for word - it "
"is what an operator reads, what the action log carries and what is "
"persisted, so it is graded on the literal",
"against a meter reading" in _inv1.sign_wd.reason)
check("...the latch is under the same namespaced key with the same flat "
"rollback mirror, and SAFETY-30's site pair is untouched at its default. "
"A one-inverter install's state file is byte-identical to what it was",
_c1s.store.data.get(TRIPPED_KEY) is True
and _c1s.store.data.get("sign_tripped") is True
and _c1s.store.data.get("site_sign_tripped") is False
and _c1s.store.data.get("site_sign_reason") == "")
print(f" site detector evaluated {len(_n1_site_calls)} times over "
f"12 cycles")
check("⚠️ ...AND THE SITE DETECTOR IS NEVER ARMED. Not 'did not trip', and not "
"'its window stayed closed' - NEVER CALLED, counted on the object over "
"twelve cycles. That is what says the second detector is ABSENT at a "
"roster of one rather than merely quiet, and arming it would be two "
"latches and two banners for one fault with a false-positive record "
"measured on ONE comparison spent twice",
_n1_site_calls == []
and not _c1s.site_sign_wd.tripped
and _c1s.site_sign_wd.disagree_since is None)
check("...the banner is the sentence that shipped",
_c1s.status_payload()["banner"]
== "FAILSAFE — the command opposed the meter. Inverter held at 0 W.")
check("...the status page carries ONE sign row, with no unit tag and no site "
"row - an extra row for an identically-duplicated question is a page "
"advertising two guards where the install has one",
len([ck for ck in _c1s.checks()
if "sign watchdog" in ck["text"] or "SIGN-DISAGREEMENT" in ck["text"]
]) == 1
and not any("site sign" in ck["text"].lower() for ck in _c1s.checks()))
check("...and no `sign_site` claim can be filed at a roster of one, so the "
"arbitration is byte-identical to what shipped",
"sign_site" not in _c1s.reason)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")