SAFETY-31: measure circulating power, which nets to zero at the meter #44

Merged
admin merged 1 commits from SAFETY-31 into release/1.0 2026-09-04 02:40:51 +02:00
12 changed files with 1813 additions and 33 deletions
+72
View File
@@ -2,6 +2,78 @@
## Unreleased
**Circulating power - two inverters pushing against each other - becomes a
measured quantity, a bounded one, and an alarm that survives a restart
(SAFETY-31).** Inverter A charging at 3 kW while inverter B discharges at 3 kW
nets ~0 W at P1: the site reads idle while the packs are cycled through two
round-trip conversion losses continuously. **Every guard in this add-on is a
consumer of that net figure**, so none of them can see it - SAFETY-02's sign
watchdog gates on `abs(grid_w) > 300` and the gate never opens, and both the
peak guard and the capacity tariff bill grid exchange.
* **A site sensor, `Σ|Bᵢ| − |ΣBᵢ|`.** Zero when the units pull the same way,
`2 × min(|B_a|,|B_b|)` when they oppose. Published as
`sensor.goodwe_circulating_power` beside the grid figure, because *"grid 0 W,
circulating 6000 W"* is the whole phenomenon in two dashboard rows and no
arithmetic on the grid figure alone can produce the second one.
* **⚠️ Derived from MEASURED per-inverter power, never from the allocation
record, and that is the whole card.** ALLOC-01's sign-coherence gate
(`REFUSE_SIGN`, its AC 7) already refuses any allocation whose shares carry
two signs, so a record-derived sensor reads zero **by construction** and could
only ever confirm that the allocator agrees with itself. What this detects is
circulation **nobody commanded**: a unit ignoring or lagging its share, a
stale share still being executed, a maintenance or SAFETY-25 pin holding one
unit while the loop drives the other, or HW-01's power stage running its last
instruction for **6 h 25 m** with the comms dead and the allocation record
impeccable throughout. Verified by making one unit ignore its share on the
TEST-19 rig: the record stays `ok` with both shares at −1250 W - a
record-derived sensor reads **0 W** - while the shipped sensor reads
**2000 W**. `Inverter.batt` became per-instance in DEV-02; before that this
sensor could not have been written.
* **The trip stops the site, and the decision record says why that is an action
rather than theatre.** Sustained opposition files `Claim.set("circulation",
P_SAFETY, 0.0)` on the **site** actuator - every inverter to 0 W, latched.
HW-01 established that a wedged inverter cannot be commanded at all, so the
remedy is not aimed at it: a circulating loop needs **two** participants and
**one of them is this controller**, faithfully driving the listening sibling
into the one that stopped listening. Stopping halves the loop, and - the more
important half - **makes the residue visible at the meter**, where every
existing guard can finally see it. What it cannot do is stop the wedged unit;
the notification says so, and names the isolator as the last actuator.
* **The threshold and the window were measured, not chosen (AC 3).** 924
closed-loop runs on the TEST-19 two-master rig in which *nothing* is faulty,
swept over the whole range the schema accepts for `gain` and `slew_w`. Worst
**continuous** legitimate opposition by threshold: **505.0 s at 100 W, 40.0 s
at 250 W, 10.0 s at 300 W, 5.0 s at 400 W and above**. The knee is 400 W and
it is the schema floor. `circulation_w: 700` is 1.75× it, the headroom bought
for inverter B's ramp and lag being **placeholders until SPIKE-04 measures
them**. `circulation_hold_s: 75` clears three bounds - 15× the worst
legitimate episode (5.0 s), 2.5× the firmware's own `LINK_STALE_S` veto, and
12× inside the 900 s capacity-tariff quarter. **A momentary opposition while
one inverter slews through zero is legitimate** - the serialised write path
lands B's number up to a control cycle after A's - and the guard does not fire
on it, in any of the 924 runs.
* **The alarm survives a Home Assistant restart (SAFETY-34).**
`persistent_notification` is in memory, which is exactly how the 2026-09-02
outage went unseen for 6 h 25 m with the detector correct throughout. The
latch is persisted to `/data` as flat, site-level keys - flat because the
quantity is defined over the roster and is identically zero for any one unit,
so there is no inverter to attribute it to - and the notification is
**re-raised on the first cycle after every start**, through the same code path
as a fresh trip.
* **At one inverter the sensor is identically zero and nothing changes (AC 5).**
`|B| − |B| == 0` for every reading, including one nobody took; no claim is
ever filed, the arbitration is byte-identical, and the status page says *n/a*
rather than claiming to watch something that cannot happen.
* **Verified by breaking it.** 18 mutations against the sensor, the window, the
latch, the claim, the store, the alarm, the routing table and both config
copies - **all red**, each on a named check; one equivalent mutant argued
rather than claimed; and one genuine **survivor found and closed** (`publish()`
turning an unknown into a published `0 W`). `arbiter.CLAIM_SCOPE` gains its
ninth source and `test_peakguard.py`'s 10f/10g tripwires were updated
deliberately, as their own comments require. Full ledger in
`DECISIONS/SAFETY-31-circulating-power.md`.
**Every per-device value stops being a Controller scalar: rating, SoC, battery
power, watchdogs, heartbeat, write bookkeeping and store keys are now
per-instance (DEV-02).** `app/inverter.py` introduces an `Inverter` object that
@@ -0,0 +1,316 @@
# SAFETY-31 — what "acted on" means for circulating power, and what it cannot mean
**Status: DECIDED 2026-09-04.** The trip **stops the site**: a `SET 0 W` claim
at `P_SAFETY` on the **site** actuator, latched, persisted, and re-announced
after a restart. This document records why that is an action rather than
theatre, given that [[HW-01]] established a wedged inverter cannot be commanded
at all — and it records the numbers the threshold and the window were read off,
because "establish the window from the rig, do not pick a round number" is an
acceptance criterion and a number without its measurement is a guess.
Filed with SAFETY-31. Card `6a99e0022b6a6c8ca15dbe3e`.
---
## 0. The two halves, and which one this card built
Half of this card was already shipped, and it is not the half below.
| | **ALLOC-01, AC 7 — the COMMAND half** | **SAFETY-31 — the MEASUREMENT half** |
|---|---|---|
| what it is | `allocator.REFUSE_SIGN` | `circulation.circulating_w()` + `CirculationWatchdog` |
| what it sees | the shares the allocator is about to hand out | each inverter's **own measured power** (`Inverter.batt`, register 0x0518) |
| what it does | **refuses** the allocation, names both units, returns no shares | **stops the site**, latches, alarms durably |
| what it covers | circulating power the allocator would have **commanded** | circulating power **nobody commanded** |
| graded by | `test_alloc.py` part 6 | `test_watchdog.py`, the SAFETY-31 section |
**They are complements, not duplicates.** The gate refuses an allocation before
it is written and cannot see one millisecond past that. This card reads the
hardware and does not care what was written. Neither replaces the other, and
**a sensor built on the allocation record would collapse the second into the
first and detect nothing** — see §2.
## 1. What "acted on" means
> **The trip files `Claim.set("circulation", P_SAFETY, 0.0)` on the SITE
> actuator. Every inverter is commanded 0 W and held there until an operator
> starts control again.**
### The objection, which is correct
[[HW-01]] is on the record: on 2026-09-02 the comms subsystem failed and the
power stage went on executing its last instruction for **6 h 25 m**, through
every command this add-on sent it. If the cause of the circulation is a unit
that has stopped listening, **a command is not a remedy**, and shipping one as
though it were is theatre.
### The answer
**A circulating loop needs two participants.** In every cause on this card's
list — a unit ignoring its share, a unit lagging it, a stale share still being
executed, a maintenance or [[SAFETY-25]] pin holding one unit while the loop
drives the other, or HW-01's wedged power stage — **exactly one participant is
the unit that stopped listening, and the other one is this controller**,
faithfully commanding the sibling to a share computed to sum with a number the
wedged unit is not actually producing.
Stopping the commanding half does two things a notification cannot:
1. **It halves the loop.** The unit that *is* still listening stops being driven
into the one that is not. That is real energy and real cycles off a real
pack, and it needs no cooperation from the wedged unit to take effect.
2. **It makes the residue visible — and this is the more important half.**
Circulating power hides *because it nets to zero at the meter*. With this
controller's contribution removed, whatever the wedged unit is still doing
stops being cancelled and appears at P1 as a plain net flow, where
SAFETY-02's sign watchdog, the peak guard and the capacity tariff can all see
it. **The fault stops being invisible.**
Measured on the [[TEST-19]] rig, and this is the number that makes the case:
in the card's own scenario — A at −3000 W, B at +3000 W, house load 0 W — the
meter reads **+0 W** and the shipped `SignWatchdog`'s predicate is **false on
every one of 30 cycles**, because it gates on `abs(grid_w) > 300` and the gate
never opens.
### What it does not do, said plainly
**It does not stop the wedged inverter. Nothing this add-on can send will.** If
the loop persists at the meter after this trip, the remaining actuator is a
human at the isolator, and the notification says so in those words.
### Why the claim is SITE-scoped
`arbiter.CLAIM_SCOPE` classifies `circulation` as `SCOPE_SITE`, beside `loop`,
`peak`, `arbitrage` and `safety`. The neighbouring `failsafe` entry looks like
it and is not: SAFETY-02 has site-wide *evidence* and a per-unit *conclusion*,
because it compares the meter against the command **this controller put on one
actuator**.
Circulating power has no such conclusion available. It is
`Σ|Bᵢ| − |ΣBᵢ|` — defined over the whole roster, **identically zero for any one
unit** — and there is no fact of the matter about which of two opposing
inverters is the offender: *a unit ignoring its share* and *a sibling faithfully
executing a share computed to sum with it* produce **identical readings**.
Filing this per-unit would require inventing an attribution the measurement
cannot support, and would park one inverter while the other went on feeding the
loop — quite possibly parking the only one still listening.
The same argument is why the latch is **flat** in the store (`circ_tripped`,
`circ_reason`) and not namespaced like SAFETY-02's. One site, one latch.
## 2. Why the sensor is MEASURED, and why the cheap version is worthless
The obvious cheap version computes the same formula over `Allocation.shares`.
It would be simpler, would never be `None`, and would be **worth nothing**, for
two independent reasons:
1. **It cannot fire.** ALLOC-01's sign-coherence gate already refuses any
allocation whose shares carry two signs. A record-derived sensor is therefore
a sensor over a set that is **guaranteed sign-coherent**. It reads zero *by
construction*, forever.
2. **It is [[MUTATION.md]]'s third failure mode.** It could only ever confirm
that the allocator agrees with itself — a check whose data cannot reach the
failure it names.
Verified rather than argued. On the rig, with inverter B wedged on a stale
discharge while PV drives the site to charge:
```
S = -2500 W shares a = -1250 W b = -1250 W <- record: ok, ONE sign, sums to S
MEASURED A = -1250 W B = +1000 W <- hardware
sensor over the RECORD : 0 W
sensor over MEASURED power : 2000 W -> trips at 190 s
```
And the card's own headline scenario, driven on the same rig — A at −3000 W,
B at +3000 W, house load 0 W:
```
METER (P1) : +0 W <- the site looks IDLE
sensor over MEASURED power : 6000 W -> trips at 100 s
SAFETY-02 SignWatchdog : predicate false on 30 of 30 cycles
```
The same pair is asserted in `test_watchdog.py` **with the precondition written
as its own check and printed**, per MUTATION.md's rule, and mutation **M10**
(the property rewritten to read `self.allocation.shares`) takes it **red**.
## 3. The numbers, and where they come from
Both were read off a curve produced on `admin/goodwe`'s `sim/` two-master rig,
used read-only: the add-on's own `compute()`, its own `allocator.plan()` and its
own `circulating_w()`, driving `plantsim.Plant` through the firmware's own
encoder, with the sensor input taken from each unit's holding register 0x0518.
**924 closed-loop runs in which nothing is faulty** — every unit executes the
share it was given — over both families of stimulus that can produce legitimate
opposition (7 reversals × 5 write staggers, and 7 flap periods × 4 amplitudes),
each swept across the **whole range `config.yaml` accepts** for the two settings
that decide how fast the site can turn around: `gain` `float(0.05,1.0)` and
`slew_w` `int(50,5000)`.
### Reproducing it
The rig lives in `admin/goodwe` (`sim/`) and was used **read-only**; the harness
is not committed here because it imports that repo's `plantsim`/`frame_check`,
which are not present in this one or in CI. What it does is small enough to
restate: build `Site([Inverter.from_spec("A", INVERTER_A),
Inverter.from_spec("B", INVERTER_B)])`, close the loop with the add-on's own
`compute()` and `allocator.plan()`, put each share on its unit's wire through
`Inverter.command()` (which goes out through the firmware's real encoder), and
read the sensor from `inv.reg(0x0518)` — **the same register the live add-on's
`batt_entity` binds**, so the input is the one the shipped code gets. Point it
at a checkout with `GOODWE_ADDON_REPO`. ⚠️ MUTATION.md's note applies: those
harnesses write `__pycache__` into **both** trees, so purge both by hand before
grading a mutation through one.
Every figure below was re-run against the final shipped `CirculationConfig`
(`min_w=700`, `hold_s=75`) rather than against the draft values they were first
measured at.
### Worst CONTINUOUS legitimate opposition, by threshold
| threshold | worst continuous | runs that ever cross it |
|---:|---:|---:|
| 100 W | **505.0 s** | 281 / 924 |
| 200 W | 445.0 s | 174 / 924 |
| 250 W | 40.0 s | 132 / 924 |
| 300 W | 10.0 s | 115 / 924 |
| **400 W** | **5.0 s** | 74 / 924 |
| 700 W | 5.0 s | 33 / 924 |
| 1000 W | 5.0 s | 16 / 924 |
| 1700 W | 0.0 s | 0 / 924 |
****With the shipped `min_w=700 W` and `hold_s=75 s`, 0 of those 924 runs trip.**
The knee is at 400 W and it is sharp.** Below ~250 W a flapping load holds the
sensor above the threshold for *minutes* at a time, and no window shorter than
the 900 s billing quarter could tell that from a fault. At 400 W and above the
worst legitimate episode collapses to **5.0 s — one control cycle — and stays
there**. The worst peak on any legitimate run is **1700 W**.
That 5.0 s is the serialised write path doing exactly what it should: on a
reversing cycle inverter B's new number lands up to a whole control cycle after
A's, both then slew through zero at 800 W/s behind a 10 s lag, and **between the
two zero crossings they genuinely oppose**. Nothing is wrong. A guard that
tripped on it would be the fourth false-alarm generator of the week, and this
project's doctrine on crying wolf is explicit ([[SAFETY-15]], [[SAFETY-19]]).
### `circulation_w: 700` — schema floor `400`
`700` is **1.75× the measured knee**. The headroom is bought for one named
reason: **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 or faster second unit moves the transient's shape, and the
margin above the knee is what absorbs that without re-opening this card.
**Revisit when SPIKE-04 lands, not before.**
The **schema floor is the knee itself**, 400 W, so a legally configured install
cannot land in the 445–505 s regime. What the threshold costs: a circulating
loop smaller than 350 W per unit is not reported, which is deliberate — at that
scale the round-trip loss is a few watts.
### `circulation_hold_s: 75` — schema range `30..900`
Three bounds, and the value clears all three:
* **lower, from the rig — 5.0 s.** The worst continuous legitimate opposition
above 700 W across all 924 runs.
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit still
executing a stale 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.
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
**75 s** is fifteen control cycles: **15× the worst legitimate episode**, **2.5×
the firmware veto**, and **12× inside the quarter**. It is deliberately not 60
or 120 — a round number here would be a guess wearing a measurement's clothes,
and a check in `test_watchdog.py` asserts it is none of the values a guess
lands on.
## 4. Why the window cannot be defeated the way SAFETY-02's was
`allocator.py` records the measurement that makes SAFETY-02 blind to the
broadcast fight: the sign disagreement **flips on alternate ticks**, so
`disagree_since` is cleared between every pair of them and the 180 s hold never
accumulates.
Circulating power cannot do that. It is a **magnitude**, non-negative by
construction, and a sustained loop holds it high on *every* cycle rather than
every other one. An oscillation that genuinely alternated between opposing and
agreeing is not a circulating loop and should not be reported as one — which is
exactly the flapping-load case in §3, and it is why the window clears.
## 5. SAFETY-34: the latch is not the alarm
`persistent_notification` is **in memory**. Home Assistant restarts and the card
is gone while the fault runs on — which is precisely how the 2026-09-02 outage
went unseen for **6 h 25 m with the detector correct throughout**.
So the durable half and the visible half are joined, and the join is
`Controller._circ_announced`: a **per-process** flag, deliberately *not*
persisted. The latch in `/data/state.json` says *"this site is tripped"*; the
flag says *"this process has put that in front of a human"*. A restart clears
the flag and keeps the latch, so **the notification is re-raised on the first
cycle after every start** — which is the first cycle after a Home Assistant
restart, because the add-on restarts with it.
**One code path serves both** the fresh trip and the restart, on purpose: two
paths would be two chances for the restart one to rot unnoticed, and the restart
one is the case nobody exercises by hand. Mutations **M13** (flag initialised
from the latch) and **M18** (the call removed) both take it red.
## 6. Mutation ledger
Every run on a **freshly copied tree with no `__pycache__`, mounted `:ro`** into
the CI image — the structural immunity MUTATION.md records for CI and for
SAFETY-25 — so no stale `.pyc` could exist to be read. Byte deltas recorded;
**three are `+0 B`, the risky delta, and are marked**.
| # | file | mutation | Δ | result |
|---|---|---|---|---|
| M1 | `circulation.py` | `sum(abs(v))` → `sum(v)` in the sensor | −5 B | **RED** — the 6000 W, 2000 W and three-unit checks |
| M2 | `circulation.py` | N≤1 returns `None` instead of `0.0` | +1 B | **RED** — AC 5's nine-reading sweep |
| M3 | `circulation.py` | a missing reading at N≥2 returns `0.0` | −1 B | **RED** — "health from an absence" |
| M4 | `circulation.py` | drop `max(0.0, ...)` | — | **EQUIVALENT, not run as a claim.** For a same-sign roster the two sums accumulate the same magnitudes in the same order and are bit-identical; 700 000 random rosters produced no negative. Argued at the line and in the check's own text. |
| M5 | `circulation.py` | threshold `<=` → `<` | −1 B | **RED** — "exactly 700 W forever does not trip" |
| M6 | `circulation.py` | window `<` → `<=` | +1 B | **RED** — "trips at 75 s, not before" |
| M7 | `circulation.py` | the window removed entirely | −85 B | **RED** — both slew-transient checks |
| M8 | `circulation.py` | `None` no longer clears the window | +34 B | **RED** — the stale-reading gap check |
| M9 | `circulation.py` | the latch removed | −123 B | **RED** — "restored as tripped stays tripped" |
| **M10** | `main.py` | **the sensor derived from `self.allocation.shares`** | +60 B | **RED** — AC 2's headline check. This is the card. |
| M11 | `main.py` | the `circulation` claim never filed | −235 B | **RED** — `test_watchdog.py` **and** `test_peakguard.py` 10f/10g |
| M12 | `main.py` | the latch not persisted to the store | −112 B | **RED** — all three durability checks |
| M13 | `main.py` | `_circ_announced` initialised from the latch | +21 B | **RED** — SAFETY-34's re-raise |
| M14 | `main.py` | the operator clear leaves `_circ_announced` set | −45 B | **RED** — "the next trip would raise nothing" |
| M15 | `arbiter.py` | `circulation` scoped `SCOPE_UNIT` | **+0 B** | **RED** — `test_arbiter.py` ×3 and `test_peakguard.py` ×3 |
| M16 | `config.yaml` | schema floor `400` → `100` | **+0 B** | **RED** — the measured-knee check |
| M17 | `circulation.py` | default `700` → `500` | **+0 B** | **RED** — the CFG-03 pin and the not-a-round-number check |
| M18 | `main.py` | `await self.check_circulating()` removed | −39 B | **RED** — all four notification checks |
| M19 | `main.py` | publish `round(x or 0.0)` instead of `None` | −68 B | **survivor on the first battery — GAP CLOSED**, then RED |
**M19 is recorded as a survivor because it was one.** The first battery found no
check grading what `publish()` does when a per-unit reading is missing, so
turning *"I do not know"* into *"0 W"* on the one surface an operator actually
watches went unnoticed — the sensor's own `None` rule, defeated one layer out.
A check was added and the mutation re-run; it is red now. The run that found it
was purged, so it is a result and not an artefact.
## 7. What this card does NOT close
* **Inverter B's ramp and lag are placeholders.** Every transient number in §3
was produced with inverter A's parameters copied onto B (`measured=False`).
SPIKE-04 measures the real ones and is the revisit trigger for the 1.75×
margin. **Do not present any figure here as a measurement of inverter B.**
* **A wedged inverter still cannot be stopped by this add-on.** §1 says so; the
notification says so; there is no software fix and this card does not pretend
to one.
* **A loop below ~350 W per unit is not reported.** Deliberate, §3.
* **The saturation freeze fires under a wedged unit and leaves a standing grid
error.** Observed on the rig while producing §2's numbers: with B wedged, the
loop's anti-windup test compares a site target against a measured site power
that cannot track it, freezes the integrator and leaves the meter parked at
−3750 W. Nothing alarms on that today — a freeze is an ordinary control action
by design. It is a real finding, it is **not** in scope here, and it wants a
card of its own.
+78 -2
View File
@@ -761,6 +761,82 @@ unchanged is 12 W, which is inside the 15 W deadband — so the command there is
parked at 0 W and the two halves of the rule cannot both be true. At the schema
floor of 60 s that figure is 60 W and only the margin is left.
### The circulating-power alarm (SAFETY-31)
> **Only at two or more inverters.** At the shipped roster of one this quantity
> is identically zero for every reading, no claim is ever filed, and the status
> page says *n/a* rather than pretending to watch it.
Two inverters can move a great deal of energy while the meter reads almost
nothing. Inverter A charging at 3 kW while inverter B discharges at 3 kW nets
**~0 W at P1**: the site looks idle, and in reality the packs are being cycled
through two round-trip conversion losses continuously, for no benefit.
**Nothing else in this add-on can see it, and that is structural rather than an
oversight.** Every other guard is a consumer of the *net* figure at the grid
connection — the sign-disagreement failsafe gates on `abs(grid_w) > 300 W` and
that gate never opens at a netted zero; the capacity-peak guard and the tariff
both bill grid exchange. Circulating power is precisely the component of
per-inverter power that the sum destroys.
So the add-on computes a quantity none of them were computing:
```
circulating_w = Σ|Bᵢ| − |ΣBᵢ|
```
Zero when the units pull the same way. `2 × min(|B_a|, |B_b|)` when they oppose
— twice the power going round the loop, which is what both conversion stages are
processing between them. It is published as
`sensor.goodwe_circulating_power`, deliberately **beside** the grid figure:
*"grid 0 W, circulating 6000 W"* is the whole fault in two dashboard rows, and
no arithmetic on the grid figure alone can produce the second one.
**It is derived from each inverter's own measured power, not from what the
controller believed it commanded.** That matters because the allocator already
*refuses* to command opposite directions (ALLOC-01's sign-coherence gate), so a
figure computed from the allocation record would read zero by construction and
detect nothing. What this catches is circulation **nobody commanded** — a unit
ignoring or lagging its share, a stale share still being executed, or an
inverter doing something of its own accord.
| option | default | meaning |
|---|---|---|
| `circulation_w` | 700 | Watts of measured opposition below which nothing is reported |
| `circulation_hold_s` | 75 | Seconds it must stand there continuously before the trip |
**Why 700 W and 75 s, and why neither is a round number.** Both were read off
924 closed-loop runs on the two-master test rig in which *nothing* was faulty,
swept across the whole range the schema accepts for `gain` and `slew_w`. A
momentary opposition is **legitimate**: writes to the inverters are serialised,
so on a reversing cycle one unit gets its new number up to a control cycle
before its sibling, both slew through zero, and between the two crossings they
genuinely oppose. The worst *continuous* legitimate opposition, by threshold,
was **505 s at 100 W, 40 s at 250 W, 10 s at 300 W and 5.0 s — one control
cycle — at 400 W and above**. The knee is 400 W and it is the schema floor;
below it, ordinary operation would hold the sensor over the line for minutes and
no usable window could tell that from a fault. 700 W carries margin above the
knee because the second inverter's ramp and lag are not yet measured. 75 s is
15× the worst legitimate episode, 2.5× the firmware's own 30 s link-veto window,
and comfortably inside the 900 s quarter that bills the capacity tariff.
**What happens when it trips, and what it honestly cannot do.** The site is
commanded **0 W on every inverter** and latched there until you press start.
That is not aimed at making a misbehaving inverter stop — if a unit has stopped
listening, no command will reach it. It does two things that do not need its
cooperation: it removes *this controller* as one of the two participants in the
loop, and it makes anything still happening **visible at the meter** instead of
cancelled at it, where the ordinary guards can see it. **If the grid reading
does not move after the trip, one inverter is no longer listening and the
remaining actuator is a human at the isolator.**
**The alarm survives a Home Assistant restart.** The latch is written to `/data`
and the notification is re-raised on the first cycle after every start —
`persistent_notification` lives only in memory, and an alarm that vanishes with
a restart is how a real outage on this system went unseen for over six hours.
Restarting does **not** clear the trip; starting control from the add-on UI
does, and only after you have read why.
### Energy price providers
> **A fetched price moves energy only when you switch arbitrage on.** This
@@ -985,6 +1061,6 @@ abort one.
## Status entities
If an MQTT broker is available the add-on publishes setpoint, grid power,
battery power, state of charge, maintenance phase and controller status by MQTT
discovery. This is observability only — the controller works fine without a
battery power, circulating power, state of charge, maintenance phase and
controller status by MQTT discovery. This is observability only — the controller works fine without a
broker, and MQTT problems can never affect control.
+19
View File
@@ -132,6 +132,25 @@ CLAIM_SCOPE = {
# controller regulates against. Both must stop every inverter, and a site
# SET at P_SAFETY is how that is said once rather than N times.
"safety": SCOPE_SITE,
# main.py: `Claim.set("circulation", P_SAFETY, 0.0, "circulating power")`.
# SAFETY-31, and this one is a SITE claim for a reason worth writing down,
# because the neighbouring `failsafe` entry looks like it and is not.
#
# `failsafe` is SAFETY-02: site-wide evidence, per-unit conclusion. It
# compares the meter against THE COMMAND THIS CONTROLLER PUT ON ONE
# ACTUATOR, so what it learns is about that actuator and the remedy is that
# actuator.
#
# Circulating power is not a property any single inverter has. It is
# `sum(|B_i|) - |sum(B_i)|`, defined over the whole roster and identically
# zero for any one unit, and there is NO FACT OF THE MATTER about which of
# two opposing inverters is the offender - a unit ignoring its share and a
# sibling faithfully executing a share computed to sum with it produce the
# same reading. Filing this per-unit would require inventing an attribution
# the measurement cannot support, and would park one inverter while the
# 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,
# --- UNIT: about one pack, one inverter, one device -------------------
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
+339
View File
@@ -0,0 +1,339 @@
"""SAFETY-31 - the power two inverters move through each other, which the meter cannot see.
Inverter A charging at 3 kW while inverter B discharges at 3 kW nets ~0 W at
P1. The site looks idle. In reality 3 kW is going round a loop through two
conversion stages continuously, for no benefit, and the wear on both packs is
real even though every instrument in this add-on says nothing is happening.
WHY NOTHING EXISTING SEES IT, AND IT IS STRUCTURAL RATHER THAN AN OVERSIGHT
==========================================================================
Every consumer of the site measurement is a consumer of the NET. SAFETY-02's
sign-disagreement watchdog gates on `abs(grid_w) > cfg.min_w` (300 W by
default) - with the grid at ~0 that gate never opens, on any cycle, for any
duration. The peak guard bills the quarter-hour average AT THE GRID
CONNECTION. The capacity tariff bills grid exchange. `freeze.looks_frozen()`
grades P1 against the site rail. There is one meter, it reads the sum, and
circulating power is exactly the component of per-unit power that the sum
DESTROYS. So this cannot be fixed by tuning a threshold on any existing
detector; it needs a quantity none of them are computing.
THE QUANTITY
============
circulating_w = sum(|B_i|) - |sum(B_i)|
Zero when every unit pulls the same way - then sum(|B_i|) == |sum(B_i)| by the
triangle equality. `2 * min(|B_a|, |B_b|)` at N=2 when they oppose, which is
twice the power going round the loop and is the right scale: that is what both
conversion stages are processing between them. It is non-negative always, it is
zero at N<=1 identically, and it needs no threshold to be interpretable - the
number IS the phenomenon in watts.
⚠️ IT IS COMPUTED FROM MEASURED PER-INVERTER POWER, AND THAT IS THE WHOLE CARD.
=============================================================================
The obvious cheap version reads the allocation record - the shares the
allocator just handed out - and reports the same formula over those. It would
be simpler, it would never see a `None`, and it would be worth NOTHING, for two
independent reasons.
The first is that it cannot fire. ALLOC-01 shipped the sign-coherence gate as
its AC 7 (`allocator.REFUSE_SIGN`, graded by `test_alloc.py` part 6): an
allocation whose shares carry two signs is REFUSED, both units named, no shares
returned. So a sensor over the allocation record is a sensor over a set the
allocator has already guaranteed is sign-coherent. It reads zero by
construction, forever, and a check written against it passes forever.
The second is the one MUTATION.md names as the third way a check lies. A sensor
derived from the allocation record can only ever confirm THE ALLOCATOR AGREES
WITH ITSELF. Every fault in this card's list is a divergence between what was
commanded and what the hardware is doing:
* a unit ignoring or lagging its share;
* a stale share still being executed by one inverter;
* SAFETY-25's deferred loop floor, or a maintenance drain, pinning one unit
while the loop drives the other;
* an inverter doing something of its own accord - and THIS HARDWARE HAS DONE
THAT TWICE. On 2026-09-02 the comms subsystem failed and the power stage
went on executing its last instruction for 6 h 25 m (HW-01). Through all of
it the allocation record was impeccable.
In every one of those the command side is coherent and the MEASUREMENT side is
not. So the input is `Inverter.batt` - this unit's own power, read from its own
`batt_entity`, which on the live system is holding register 0x0518, the
inverter's own AC power. DEV-02 made that per-instance; before it there was one
`batt` scalar on the Controller and this sensor could not have been written at
all.
⚠️ AND THIS IS THE MEASUREMENT HALF OF A PAIR. ALLOC-01's gate is the COMMAND
half: the allocator can no longer COMMAND circulating power. This module is the
detection half: circulating power the allocator never commanded. Neither one
replaces the other and neither is redundant - the gate refuses an allocation
before it is written, and cannot see a millisecond past that; this reads the
hardware and does not care what was written. Do not "unify" them.
THE TRIP, AND WHY A THRESHOLD ALONE IS A FALSE-ALARM GENERATOR
==============================================================
A momentary opposition is LEGITIMATE. Writes to the inverters are serialised
(one `hass.call()` at a time - see `Inverter`'s docstring on what a hung write
to A costs B), so on a cycle where the site target reverses, one unit receives
its new command up to a whole control cycle before its sibling. Both then slew
through zero, with a real ramp rate and a real first-order lag, and BETWEEN THE
TWO ZERO CROSSINGS THE UNITS GENUINELY OPPOSE. Nothing is wrong; the site is
turning around.
This project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19) and
this is exactly the shape that generates it. So the trip is a threshold AND a
sustained window, and BOTH numbers were measured on the TEST-19 two-master rig
rather than chosen - see `CIRC_OPTION_DEFAULTS` for the measurements and
`DECISIONS/SAFETY-31-circulating-power.md` for the runs.
⚠️ THE WINDOW ACCUMULATES, WHICH IS WHY THIS IS NOT SAFETY-02's PROBLEM AGAIN.
The reason the sign watchdog is blind to the broadcast fight is that the
disagreement FLIPS SIGN on alternate ticks, so `disagree_since` is cleared
between every pair of them and the 180 s hold never accumulates (allocator.py's
docstring records the measurement). Circulating power cannot do that: it is a
MAGNITUDE, non-negative by construction, and a sustained loop holds it high on
every cycle rather than on every other one. An oscillation that genuinely
alternated between opposing and agreeing is not a circulating loop and should
not be reported as one.
"""
from dataclasses import dataclass
from datetime import datetime
# CFG-03. THE ONE IN-CODE COPY OF THESE DEFAULTS. `config.yaml`'s `options:`
# block is the source of truth an operator edits and the Supervisor reads;
# `schema:` holds the bounds. This dict exists for the reason
# `maintenance.MAINT_OPTION_DEFAULTS` does - 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.
CIRC_OPTION_DEFAULTS = {
# --- THE THRESHOLD, AND IT IS READ OFF A CURVE ------------------------
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 924 closed-loop
# runs in which NOTHING is faulty - every unit executes the share the
# allocator gave it - over both families of stimulus that can produce
# legitimate opposition: single reversals (7 sizes x 5 write staggers) and a
# flapping load (7 periods x 4 amplitudes), each swept across the WHOLE
# range config.yaml accepts for the two settings that decide how fast the
# site can turn around, `gain` float(0.05,1.0) and `slew_w` int(50,5000).
# The worst CONTINUOUS opposition, as a function of the threshold:
#
# 100 W -> 505.0 s 300 W -> 10.0 s 700 W -> 5.0 s
# 200 W -> 445.0 s 350 W -> 10.0 s 1000 W -> 5.0 s
# 250 W -> 40.0 s 400 W -> 5.0 s 1700 W -> 0.0 s
#
# THE KNEE IS AT 400 W and it is sharp. Below ~250 W a flapping load holds
# the sensor above the threshold for MINUTES at a time - 505 s at 100 W -
# and no window shorter than the 900 s billing quarter could tell that from
# a fault. At 400 W and above the worst legitimate episode collapses to 5.0
# s, ONE control cycle, and stays there. So this number is not a sensitivity
# preference; it is the edge of the regime in which the guard is
# answerable at all.
#
# 700 W is 1.75x the knee, and the margin is bought for one named reason:
# 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 or faster second unit moves the transient's shape, and
# the headroom above the knee is what absorbs that without re-opening this
# card. Revisit it when SPIKE-04 lands, not before.
#
# What it costs: a circulating loop smaller than 350 W per unit is not
# reported. That is deliberate - at that scale the round-trip loss is a few
# watts and the wear is proportionate to it.
"circulation_w": 700,
# --- THE WINDOW -------------------------------------------------------
# Two lower bounds, one upper, and the chosen value clears all three.
#
# LOWER, from the rig: 5.0 s. The worst continuous legitimate opposition
# above 700 W across all 924 runs is exactly one control cycle - a
# reversing site whose second write lands a cycle after the first, which
# is what the serialised write path does on every reversal.
#
# LOWER, from the firmware: `inverter.LINK_STALE_S`, 30 s. A unit still
# executing a stale share for less than that is a LINK fault, and the
# add-on already has an answer for 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 that is already
# being handled, which is the SAFETY-15/SAFETY-19 failure exactly.
#
# UPPER, from the bill: 900 s, the quarter that sets the capacity tariff.
# A circulating loop must be caught and acted on well inside one.
#
# 75 s: fifteen control cycles - 15x the worst legitimate episode this rig
# can produce, 2.5x the firmware's own veto window, and 12x inside the
# quarter. ⚠️ IT IS DELIBERATELY NOT 60 OR 120. 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.
"circulation_hold_s": 75,
}
@dataclass(frozen=True)
class CirculationConfig:
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
min_w: float = float(CIRC_OPTION_DEFAULTS["circulation_w"])
hold_s: float = float(CIRC_OPTION_DEFAULTS["circulation_hold_s"])
def circulating_w(powers):
"""`sum(|B_i|) - |sum(B_i)|` over MEASURED per-inverter power, in W.
`powers` one measured power per inverter, in roster order, + = discharge.
A unit whose reading did not arrive is `None`.
Returns a non-negative float, or `None` when the answer is unknown.
⚠️ N<=1 IS IDENTICALLY ZERO AND RETURNS ZERO EVEN FOR A MISSING READING
(AC 5). With one term `sum(|B|) - |sum(B)| == |B| - |B| == 0` for EVERY
value of B, including a value nobody read. Returning `None` there would be
reporting an unknown where the algebra has no room for one, and it would put
a shipped one-inverter install permanently in the "cannot tell" state on a
quantity that cannot be anything but zero. Nothing changes at the shipped
roster; that is the compatibility promise this card makes.
⚠️ AND AT N>=2 A MISSING READING IS `None`, NOT ZERO, WHICH IS THE OPPOSITE
DIRECTION AND FOR A REASON. Zero would be this sensor reporting HEALTH from
an absence - the precise shape of the 2026-09-02 outage, where a subsystem
stopped answering and its silence read as calm for 6 h 25 m. An unknown
circulating figure is an unknown, it CLEARS the trip's window rather than
accumulating it (see `CirculationWatchdog.evaluate`), and the site is
already stopped for a missing per-unit reading anyway - `inputs_ok` requires
every unit's `batt`, and §11.1 rule 1 commands 0 W without consulting this.
"""
vals = list(powers)
if len(vals) <= 1:
return 0.0
if any(v is None for v in vals):
return None
vals = [float(v) for v in vals]
# ⚠️ max(0.0, ...) IS DEFENSIVE, AND IT IS AN EQUIVALENT MUTANT - SAID HERE
# RATHER THAN LEFT FOR THE NEXT MUTATION SWEEP TO REDISCOVER. The triangle
# inequality makes this non-negative mathematically, and in floating point
# it is non-negative too: for a same-sign roster `abs(v)` is the list itself
# up to one sign, so the two sums accumulate THE SAME MAGNITUDES IN THE SAME
# ORDER and are bit-identical; for a mixed-sign roster the two differ by
# 2*min(...), which is enormous beside any rounding. Searched, not assumed -
# 700 000 random rosters of 2 to 12 units across 10 orders of magnitude
# produced no negative result.
#
# So removing this line changes no observable and no check here would go
# red. It stays because the cost is one call and the failure it would
# prevent - a power sensor publishing -1e-16 W, which is the kind of value
# somebody eventually writes a `< 0` branch against - is out of proportion
# to it. Do not report its survival as a gap in the checks.
return max(0.0, sum(abs(v) for v in vals) - abs(sum(vals)))
def opposing(powers, min_w: float = 0.0):
"""The units pulling each way right now, as `(discharging, charging)` names.
Indices, not names - the caller owns the roster order. Used for the operator
sentence: "which two inverters are pushing against each other" is the first
thing somebody woken by this alarm needs, and reconstructing it from the
number alone is impossible.
`min_w` ignores units below a magnitude, so a unit resting at 3 W of
standby draw is not named as a participant in a 3 kW loop.
"""
pos = [i for i, v in enumerate(powers)
if v is not None and float(v) > float(min_w)]
neg = [i for i, v in enumerate(powers)
if v is not None and float(v) < -float(min_w)]
return pos, neg
class CirculationWatchdog:
"""ONE PER SITE, evaluated once per control cycle.
⚠️ ONE PER SITE, WHERE SAFETY-02's IS ONE PER ACTUATOR, AND THE ASYMMETRY IS
THE WHOLE ATTRIBUTION ARGUMENT. `SignWatchdog` concludes "THE UNIT I AM
DRIVING is pushing power the wrong way" - evidence about one actuator, so
the remedy is that actuator (arbiter.CLAIM_SCOPE calls `failsafe` a UNIT
claim for exactly this reason). Circulating power is not a property any one
inverter has. It is a property of a PAIR, it is defined by a sum across the
roster, and there is no fact of the matter about which of the two is the
offender: a unit ignoring its share and a sibling faithfully executing a
share computed to sum with it produce identical readings. So the quantity is
site-level, the watchdog is site-level, and the remedy below is site-level.
Latches, for `SignWatchdog`'s reason: the trip removes its own evidence. The
moment the site stops commanding, the loop it detected either persists (a
wedged unit, now visible at the meter) or vanishes (the add-on was one of
the two participants) - and in the second case a non-latching detector would
de-assert and start the whole thing again, three minutes at a time, forever.
"""
def __init__(self, cfg: CirculationConfig | None = None, *,
tripped: bool = False, reason: str = ""):
self.cfg = cfg or CirculationConfig()
self.over_since: datetime | None = None
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED - SAFETY-34, and it is the
# reason this card exists in the shape it does. `persistent_notification`
# is IN MEMORY: Home Assistant restarts and the card is gone, with the
# fault still running. That is precisely how the 2026-09-02 outage went
# unseen for 6 h 25 m while the detector had been correct for hours. The
# latch lives in `/data/state.json` - the add-on's only durable volume -
# and `main.py` RE-RAISES the notification from it on every start.
self.tripped = bool(tripped)
self.reason = reason if self.tripped else ""
# The worst figure seen during the current episode, for the operator
# sentence. Not persisted: it is colour on a reason string that IS.
self.peak_w = 0.0
def evaluate(self, circ_w, now: datetime) -> bool:
"""Advance the detector one cycle. Returns True while tripped.
`circ_w` this cycle's `circulating_w()` over MEASURED per-unit power,
or None when a reading is missing. None CLEARS the window
rather than accumulating it - see the module docstring, and
`SignWatchdog.evaluate` on the identical decision for a stale
meter. A frozen figure held against a moving one manufactures
a fault in about a window's worth of cycles.
`now` the caller's clock, so this is testable and replayable.
"""
if self.tripped:
# Latched. Do not re-evaluate: the trip removed the evidence.
return True
if circ_w is None or float(circ_w) <= self.cfg.min_w:
self.over_since = None
self.peak_w = 0.0
return False
circ_w = float(circ_w)
self.peak_w = max(self.peak_w, circ_w)
if self.over_since is None:
self.over_since = now
return False
held = (now - self.over_since).total_seconds()
if held < self.cfg.hold_s:
return False
self.tripped = True
self.reason = (
f"CIRCULATING_POWER: {circ_w:.0f} W measured going round between "
f"the inverters (peak {self.peak_w:.0f} W) for {held:.0f} s "
f"continuously - the meter cannot see it, because it nets to zero "
f"there (limits: over {self.cfg.min_w:.0f} W, held over "
f"{self.cfg.hold_s:.0f} s)")
return True
def held_s(self, now: datetime) -> float:
"""How long the current opposition has stood, for the status page.
Takes the clock rather than reading one, for `SignWatchdog.held_s`'s
reason: every observable on this object is a pure function of what it
was handed, and an unclocked function is one nothing can test.
"""
if self.over_since is None:
return 0.0
return (now - self.over_since).total_seconds()
def reset(self) -> None:
self.over_since = None
self.tripped = False
self.reason = ""
self.peak_w = 0.0
+297 -1
View File
@@ -38,6 +38,10 @@ from .arbiter import (
Claim, Resolution, actuator_for, resolve,
)
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .circulation import (
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
circulating_w, opposing,
)
from .clamp import (
REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, is_commandable,
)
@@ -249,6 +253,39 @@ class Controller:
self.freeze_wd = FreezeWatchdog(
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
# SAFETY-31. ONE PER SITE, and restored from the store like SAFETY-02's
# latch rather than defaulted like SAFETY-18's - the asymmetry is the
# same one the freeze watchdog's own note draws, read the other way.
# This one ACTS: a trip files a P_SAFETY SET that stops every inverter,
# so a restart that cleared it would come back up straight into the loop
# it had just stopped, which is SAFETY-02's measured three-minutes-at-a-
# time failure with a different quantity in front of it.
#
# ⚠️ SITE-LEVEL KEYS, NOT NAMESPACED. store.py carries the argument:
# circulating power is defined over the roster and is identically zero
# for any one unit, so there is no inverter to hang the latch on.
self.circ_wd = CirculationWatchdog(
CirculationConfig(
min_w=float(opts.get("circulation_w",
CIRC_OPTION_DEFAULTS["circulation_w"])),
hold_s=float(opts.get("circulation_hold_s",
CIRC_OPTION_DEFAULTS["circulation_hold_s"]))),
tripped=bool(store.data.get("circ_tripped", False)),
reason=str(store.data.get("circ_reason", "") or ""))
# SAFETY-34. Has the durable alarm been put back in front of the
# operator since this process started? `persistent_notification` is in
# memory and does NOT survive the Home Assistant restart that the store
# does, so the latch alone is not the alarm - re-raising it is.
# `run_control()` does that, once, before the first cycle.
self._circ_announced = False
if self.circ_wd.tripped:
_LOG.error(
"SAFETY-31 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 two "
"inverters were pushing against each other.",
self.circ_wd.reason or "(reason not recorded)")
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
# itself is the cheap half.
#
@@ -720,6 +757,35 @@ class Controller:
return None
return float(sum(float(v) for v in vals))
@property
def site_circulating_w(self):
"""SAFETY-31. `sum(|B_i|) - |sum(B_i)|` over MEASURED per-unit power.
⚠️ `inv.batt`, NOT `self.allocation`. THIS IS THE WHOLE CARD, and the
wrong version is the tempting one: the same formula over the allocation
record would be simpler, would never be None, and would be worth
nothing. ALLOC-01's sign-coherence gate (`allocator.REFUSE_SIGN`) already
refuses an allocation whose shares carry two signs, so a record-derived
sensor reads zero BY CONSTRUCTION, forever, and can only ever confirm
that the allocator agrees with itself. Every fault this card is for is a
divergence between what was commanded and what the hardware is doing - a
unit ignoring or lagging its share, a stale share still being executed,
or HW-01's power stage running its last instruction for 6 h 25 m with
the comms dead and the allocation record impeccable throughout.
Measured on the TEST-19 rig: with one unit wedged, the record stays
`ok` with both shares at -1250 W - a record-derived sensor reads 0 W -
while this one reads 2000 W.
`batt` became per-instance in DEV-02; before that there was one scalar
on the Controller and this property could not have been written.
None when any unit has not answered, and NOT zero - circulation.py says
why at length. The short version: zero would be this sensor reporting
health from an absence, which is the 2026-09-02 failure itself.
Identically 0.0 at the shipped roster of one (AC 5).
"""
return circulating_w([inv.batt for inv in self.inverters])
@property
def pack_floor_soc(self):
"""MINIMUM. The SoC that a discharge PERMISSION must be graded against.
@@ -1204,6 +1270,39 @@ class Controller:
# share of it. At the shipped roster of one the two are the same number.
site_standing = float(sum(standing.values()))
# --- 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
# `over_since` untouched across the gap and the window would resume from
# a start time that predates it, which is the ordering bug the sign
# watchdog's own note describes.
#
# ⚠️ THE INPUT IS MEASURED POWER, WHICH IS THE ENTIRE POINT OF THE CARD.
# `site_circulating_w` reads each `Inverter.batt` - that unit's own
# meter, holding register 0x0518. It is NOT `self.allocation`, and the
# property's docstring carries the argument: ALLOC-01's sign-coherence
# gate already makes the allocation record incapable of expressing
# circulating power, so a record-derived sensor is zero by construction
# and detects nothing. What this catches is circulation the allocator
# NEVER COMMANDED - a unit ignoring or lagging its share, a stale share
# still being executed, or HW-01's wedged power stage.
circ_w = self.site_circulating_w
was_circ = self.circ_wd.tripped
if self.circ_wd.evaluate(circ_w, now) and not was_circ:
# REASON FIRST, FLAG SECOND, and each `set` writes the file - the
# same atomicity ordering SAFETY-02 uses two blocks up, and for the
# same reason: a crash between the two must not persist a latch with
# no explanation for the operator who has to decide about it.
self.store.set("circ_reason", self.circ_wd.reason)
self.store.set("circ_tripped", True)
self._circ_announced = False
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason)
_LOG.error(
"SAFETY-31 TRIPPED - %s. Commanding 0 W on every inverter and "
"holding it until control is started again. This survives an "
"add-on restart AND a Home Assistant restart; restarting will "
"not clear it.", self.circ_wd.reason)
stale_after = float(self.o.get("stale_input_s", 15))
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
# does not become a fault; a LATCHED inverter must still reach the claim
@@ -1265,6 +1364,58 @@ class Controller:
"sign disagreement",
actuator=self.actuator_of("failsafe", inv)))
# --- 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
# and is answered rather than ignored.
#
# THE OBJECTION. HW-01 established that commanding a wedged inverter
# achieves nothing - on 2026-09-02 the comms subsystem failed and the
# power stage went on executing its last instruction for 6 h 25 m,
# through every command this add-on sent it. If the cause of the
# circulation is a unit that has stopped listening, then a command is
# not a remedy and pretending otherwise is theatre.
#
# THE ANSWER, AND IT IS WHY THIS IS STILL AN ACTION RATHER THAN A
# NOTIFICATION. A circulating loop needs TWO participants. In every
# cause on this card's list, exactly one of them is a unit that has
# stopped listening and THE OTHER ONE IS THIS CONTROLLER - faithfully
# commanding the sibling to a share computed to sum with a number the
# wedged unit is not actually producing. Stopping the commanding half
# does two things a notification cannot:
#
# 1. IT HALVES THE LOOP. The unit that is still listening stops being
# driven into the one that is not, so the round-tripping stops on
# the side this controller owns. That is real energy and real
# cycles off a real pack, and it needs no cooperation from the
# wedged unit to take effect.
# 2. IT MAKES THE RESIDUE VISIBLE. This is the more important half.
# Circulating power hides because it NETS TO ZERO AT THE METER.
# With this controller's contribution removed, whatever the wedged
# unit is still doing stops being cancelled and appears at P1 as a
# plain net flow - where SAFETY-02's sign watchdog (which gates on
# `abs(grid_w) > 300` and could never open at a netted zero), the
# peak guard and the capacity tariff can all see it. The fault
# stops being invisible. Measured on the TEST-19 rig: the card's
# own scenario reads +0 W at the meter with 6000 W going round, and
# the shipped SignWatchdog's predicate is false on every one of
# those cycles.
#
# WHAT IT DOES NOT DO, SAID PLAINLY: it does not stop the wedged
# inverter. Nothing this add-on can send will. If the loop persists at
# the meter after this trip, the remaining actuator is a human at the
# isolator, which is what the notification says in those words.
#
# ⚠️ AND IT IS A SITE CLAIM. `arbiter.CLAIM_SCOPE` carries the argument:
# circulating power is defined over the roster and is identically zero
# for any one unit, so there is no attribution to file this per-unit on -
# and parking the wrong one of the two would leave the loop running with
# the guard satisfied.
if self.circ_wd.tripped:
claims.append(Claim.set("circulation", P_SAFETY, 0.0,
"circulating power",
actuator=self.actuator_of("circulation")))
# SITE, both of them, and the two reasons are why. "stopped" is the
# operator switching control off for the whole site; "inputs missing" is
# the SITE's own telemetry gone - the meter this controller regulates
@@ -1794,6 +1945,16 @@ class Controller:
# is not, and test_freeze.py part 5 fails on exactly that.
await self.check_meter_frozen()
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
# Unconstrained where the two above are not: it grades
# `self.circ_wd.tripped`, which was advanced near the top of this method
# beside SAFETY-02 and which nothing since has touched. It is called on
# EVERY cycle rather than only on the cycle that trips, because its
# second job is re-raising the alarm after a restart into an existing
# trip - see its docstring on why that shares one code path with the
# fresh trip instead of getting its own.
await self.check_circulating()
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
# method assigned above and nothing since has touched.
@@ -2050,6 +2211,71 @@ class Controller:
f"you a lower bill, not the pack being spent."),
})
async def check_circulating(self) -> None:
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart.
⚠️ THE LATCH IS NOT THE ALARM. `circ_tripped` in `/data/state.json`
survives everything, but nothing looks at a JSON file at 3 a.m. What the
operator sees is a `persistent_notification`, and THAT IS IN MEMORY: a
Home Assistant restart deletes it while the fault runs on. This is not a
hypothetical - it is precisely how the 2026-09-02 outage went unseen for
6 h 25 m with the detector correct the whole time (SAFETY-34).
So the durable half and the visible half are joined HERE, and the join
is `_circ_announced`: a per-PROCESS flag, deliberately not persisted.
The latch says "this site is tripped"; the flag says "this process has
put that in front of a human". A restart clears the flag and keeps the
latch, so the notification is RE-RAISED on the first cycle after every
add-on start - which is also the first cycle after a Home Assistant
restart, because the add-on is restarted with it.
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE. A fresh trip and a restart into
an existing trip raise the alarm through these same lines. Two paths
would be two chances for the restart one to rot unnoticed, and the
restart one is the case nobody exercises by hand.
"""
if not self.circ_wd.tripped or self._circ_announced:
return
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
# every cycle for a broker that is down would be an alarm storm on the
# event bus on top of a site that is already stopped. The latch is
# already durable and the log line above already fired; this surface is
# best-effort by design.
self._circ_announced = True
names = self.units
pos, neg = opposing([inv.batt for inv in self.inverters],
min_w=self.circ_wd.cfg.min_w / 2.0)
who = ""
if pos and neg:
who = ("\n\nRight now " + ", ".join(names[i] for i in pos)
+ " is discharging while " + ", ".join(names[i] for i in neg)
+ " charges.")
# A fixed notification_id, and a DIFFERENT one from every other alarm
# this add-on raises - check_meter_frozen() gives the reasoning. A
# circulating loop is not a frozen meter, is not an out-of-range
# setpoint and is not a saturated peak guard.
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_circulating_power",
"title": "GoodWe: the inverters are pushing against each other",
"message": (
f"{self.circ_wd.reason}{who}\n\nTHE METER CANNOT SEE THIS. "
f"Circulating power nets to zero at P1, so the site reads idle "
f"while the packs are cycled through two conversion stages "
f"continuously. Every other guard in this add-on is a consumer "
f"of that net figure, which is why none of them fired.\n\n"
f"CONTROL HAS BEEN STOPPED - every inverter is commanded 0 W "
f"and held there. That removes this controller as one of the "
f"two participants, and it makes anything still happening "
f"VISIBLE at the meter instead of cancelled at it.\n\n"
f"⚠️ IF THE GRID READING DOES NOT MOVE AFTER THIS, one inverter "
f"is no longer listening to this add-on and no command will "
f"reach it - that has happened twice on this hardware, once for "
f"6 h 25 m. The remaining actuator is a human at the isolator.\n"
f"\nThis alarm survives a Home Assistant restart. Restarting "
f"will not clear it; start control from the add-on UI once you "
f"know why two inverters were opposing."),
})
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
@@ -2354,7 +2580,14 @@ class Controller:
# additive to its vocabulary rather than a change to it.
"status": ("failsafe" if any(i.sign_wd.tripped
for i in self.inverters)
or self.circ_wd.tripped
else "running" if self.auto else "stopped"),
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
# `site_circulating_w`. `publish()` drops None values, so a cycle
# with a missing per-unit reading leaves the last figure standing
# rather than publishing a zero that would read as health.
"circulation": (None if self.site_circulating_w is None
else round(self.site_circulating_w)),
}
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
@@ -2691,6 +2924,35 @@ class Controller:
f"{inv.sign_wd.cfg.min_w:.0f} W)"
+ (f" - opposed for {held:.0f} s now" if held 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
# here would invite the operator to clear "the other one".
_circ = self.site_circulating_w
if self.circ_wd.tripped:
out.append({"ok": False, "warn": False,
"text": "CIRCULATING POWER - " + self.circ_wd.reason
+ ". EVERY inverter is held at 0 W. The meter "
"cannot see this fault, which is why nothing "
"else reported it. Starting control clears "
"this; restarting the add-on does NOT, and "
"neither does restarting Home Assistant."})
elif len(self.inverters) <= 1:
# AC 5. At the shipped roster the quantity is identically zero for
# every value of every reading, so "armed" would be a claim to be
# watching something that cannot happen. Say what is true instead.
out.append({"ok": True, "warn": False,
"text": "circulating power: n/a at one inverter - "
"sum(|Bi|) - |sum(Bi)| is identically zero"})
else:
held = self.circ_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": "circulation watchdog: armed (trips after "
f"{self.circ_wd.cfg.hold_s:.0f} s over "
f"{self.circ_wd.cfg.min_w:.0f} W)"
+ (f" - now {_circ:.0f} W" if _circ else "")
+ (f", opposed for {held:.0f} s" if held else "")})
if not o.get("estop_fitted", False):
out.append({"ok": False, "warn": True,
"text": "no e-stop fitted: if this host dies the battery stays "
@@ -2708,7 +2970,17 @@ class Controller:
def status_payload(self) -> dict:
checks = self.checks()
bad = [c for c in checks if not c["ok"] and not c["warn"]]
if any(i.sign_wd.tripped for i in self.inverters):
if self.circ_wd.tripped:
# ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are
# latched P_SAFETY stops, so the site is at 0 W either way and the
# banner can only carry one sentence. This one goes first because it
# is the fault the operator has NO OTHER WAY of learning about: a
# sign disagreement is visible at the meter and on every dashboard
# in the house, and circulating power is visible nowhere at all.
level, banner = "bad", ("CIRCULATING POWER — the inverters were "
"pushing against each other. Site 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.")
@@ -2734,6 +3006,14 @@ class Controller:
rows = [
("Grid", w(self.grid)),
("Battery", w(self.site_batt_w)),
# SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is not
# derivable from. "Grid 0 W, Battery 0 W, Circulating 6000 W" is the
# whole card in three rows: both of the numbers an operator normally
# reads say the site is idle, and the third says 6 kW is going round
# between the packs. Only shown at N>=2 - at one inverter it is
# identically zero and a permanent "0 W" row would be noise.
*(() if len(self.inverters) <= 1
else ((("Circulating", w(self.site_circulating_w)),))),
("State of charge", "—" if self.pack_floor_soc is None
else f"{self.pack_floor_soc:g} %"),
("Commanded", w(self.target)),
@@ -2808,6 +3088,22 @@ class Controller:
# unit in particular.
self.store.set("sign_tripped", False)
self.store.set("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
# oppose. Only a human who has read the banner can say the site
# is clear.
#
# ⚠️ AND `_circ_announced` GOES WITH IT. It is the "has a human
# been shown this" flag; leaving it True through a clear would
# mean the NEXT trip in this process latched, stopped the site,
# and raised no notification at all.
if self.circ_wd.tripped:
self.circ_wd.reset()
self.store.set("circ_tripped", False)
self.store.set("circ_reason", "")
self.log_event("circulating-power trip cleared by operator")
self._circ_announced = False
if not self.auto:
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero().
# Zeroing `self.target` alone would leave each unit's last share
+13
View File
@@ -43,6 +43,19 @@ SENSORS = [
("grid", "goodwe_grid_power", "Grid power", "W", "power", "measurement", None),
("battery", "goodwe_battery_power", "Battery power", "W", "power", "measurement", None),
("soc", "goodwe_battery_soc", "Battery SoC", "%", "battery", "measurement", None),
# SAFETY-31. `sum(|Bi|) - |sum(Bi)|` over MEASURED per-inverter power: the
# power going round between the inverters, which the grid sensor above
# CANNOT SHOW because it nets to zero there. The two are published side by
# side on purpose - "grid 0 W, circulating 6000 W" is the whole phenomenon
# in two rows of a dashboard, and no arithmetic on the grid figure alone can
# ever produce the second one.
#
# ⚠️ Published as 0 at the shipped one-inverter roster, not omitted, and
# that is a deliberate difference from `p1_age` below. An absent entity is
# right for a watchdog input nothing is feeding; this is a quantity whose
# value is KNOWN at N=1 - identically zero, for every reading - and a
# dashboard row reading 0 W is the honest report of it.
("circulation", "goodwe_circulating_power", "Circulating power", "W", "power", "measurement", "mdi:sync-alert"),
("phase", "goodwe_maintenance_phase", "Maintenance phase", None, None, None, "mdi:battery-sync"),
("status", "goodwe_controller_status", "Controller status", None, None, None, "mdi:heart-pulse"),
# ⚠️ THE LIVE ENTITY IS `sensor.goodwe_p1_sample_age`, NOT what this
+19
View File
@@ -54,6 +54,23 @@ _LOG = logging.getLogger("goodwe.store")
# `migrate_unit_keys()` and `Controller._grade_store_migration()` carry the
# argument; the short version is that a latch is only worth persisting if it
# survives the version change an operator makes while staring at the banner.
#
# ⚠️ AND SAFETY-31's PAIR IS FLAT ON PURPOSE, WHICH IS THE OPPOSITE DECISION TO
# THE ONE ABOVE AND NEEDS ITS OWN SENTENCE. `circ_tripped` / `circ_reason` are
# NOT in `LEGACY_UNIT_KEYS` and must never be moved into a unit namespace.
# Circulating power is `sum(|B_i|) - |sum(B_i)|` - a quantity DEFINED over the
# whole roster, which is identically zero for any single unit. There is no fact
# of the matter about which of two opposing inverters is the offender: a unit
# ignoring its share and a sibling faithfully executing a share computed to sum
# with it read identically. Namespacing this latch would mean inventing an
# attribution the measurement cannot support, and would then let an operator
# clear "inverter B's" half of a site-wide loop. One site, one latch.
#
# It is persisted for SAFETY-34's reason, which this add-on has already paid
# for once: `persistent_notification` is IN MEMORY. Home Assistant restarts and
# 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.
DEFAULTS = {
"phase": "idle",
"phase_started": None,
@@ -61,6 +78,8 @@ DEFAULTS = {
"last_start_attempt": None,
"sign_tripped": False,
"sign_reason": "",
"circ_tripped": False,
"circ_reason": "",
}
# --------------------------------------------------------------------------- #
+41
View File
@@ -134,6 +134,25 @@ options:
# still inside the 900 s quarter that bills the capacity tariff.
meter_freeze_s: 300
# --- SAFETY-31: circulating power ------------------------------------------
# ⚠️ CFG-03. THESE TWO LINES ARE THE SOURCE OF TRUTH FOR THESE DEFAULTS, and
# `app/circulation.py`'s `CIRC_OPTION_DEFAULTS` is the ONE in-code copy - the
# Dockerfile does not put this file in the image, so the running add-on cannot
# read it. test_watchdog.py pins the copy by READING these lines, so a drift
# goes red in CI rather than shipping. Add a third option in three places -
# options:, schema:, CIRC_OPTION_DEFAULTS - and in no fourth.
#
# Circulating power is sum(|Bi|) - |sum(Bi)| over MEASURED per-inverter power:
# two inverters pushing against each other, which NETS TO ZERO AT THE METER
# and is therefore invisible to every other guard in this add-on. Both numbers
# below were measured on the TEST-19 two-master rig over 924 closed-loop runs
# in which nothing was faulty; circulation.py carries the curve they were read
# off and the argument for each. They are NOT sensitivity preferences.
#
# Both are ignored at one inverter, where the quantity is identically zero.
circulation_w: 700
circulation_hold_s: 75
# --- maintenance -----------------------------------------------------------
# ⚠️ CFG-03. THESE EIGHT LINES ARE THE SOURCE OF TRUTH FOR THE MAINTENANCE
# DEFAULTS. The Supervisor reads this file and the operator edits this file,
@@ -398,6 +417,28 @@ schema:
# app/freeze.py.
meter_freeze_s: int(60,3600)
# SAFETY-31. No "off" value, for SAFETY-02's and meter_freeze_s's reason:
# disarming a safety alarm is not a supported configuration.
#
# ⚠️ THE FLOOR OF 400 IS NOT A RECOMMENDATION, IT IS THE MEASURED EDGE OF THE
# REGIME IN WHICH THIS GUARD CAN BE ANSWERED FOR. On the TEST-19 rig, the
# worst CONTINUOUS opposition produced by a site where nothing is faulty - a
# flapping load reversing every control cycle, swept over the whole range this
# schema accepts for `gain` and `slew_w` - is 5.0 s at a 400 W threshold and
# above, 10.0 s at 300 W, 40.0 s at 250 W and 505.0 s at 100 W. Below the
# knee, legitimate operation holds the sensor over the threshold for MINUTES
# and no window shorter than the 900 s billing quarter could tell that from a
# fault. A configurable value under 400 W would be a false-alarm generator
# that this project's own doctrine (SAFETY-15, SAFETY-19) forbids shipping.
circulation_w: int(400,5000)
# The floor of 30 is `inverter.LINK_STALE_S`: below the firmware's own veto
# window a unit still executing a stale share is a LINK fault the add-on
# already handles by dropping it from the split, and firing inside that window
# would raise a second, louder alarm about a state already being managed. The
# ceiling of 900 is the quarter that bills the capacity tariff - a circulating
# loop must be caught and acted on well inside one.
circulation_hold_s: int(30,900)
# ⚠️ CFG-03 - THIS IS WHERE A RANGE LIVES, AND THE ONLY PLACE IT LIVES. The
# `options:` block above holds the DEFAULT; this block holds the BOUNDS, and
# `MAINT_OPTION_DEFAULTS` deliberately mirrors the former and not the latter -
+8 -3
View File
@@ -160,6 +160,11 @@ SITE_SOURCES = {
"arbitrage": "buys and sells at the grid against a day-ahead price",
"safety": "'stopped' is the site's switch, 'inputs missing' is the "
"site's own meter - neither is about a pack",
# SAFETY-31, and it is the entry most easily filed on the wrong side.
"circulation": "sum(|Bi|) - |sum(Bi)| is defined OVER THE ROSTER and is "
"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",
}
UNIT_SOURCES = {
"device": "one inverter's own reported min/max rating",
@@ -172,9 +177,9 @@ for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}")
check("all eight claim sources are classified, and only those eight",
check("all NINE claim sources are classified, and only those nine",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the four that are about the GRID CONNECTION are site claims",
check("the five 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))
@@ -195,7 +200,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 four "
"per-inverter sources route to the unit this controller drives, the five "
"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},
+43 -27
View File
@@ -2358,8 +2358,17 @@ check("...and so does the persistent notification, which outlives the page and "
# "deferred" is exactly the state in which a ninth source gets built by
# accident. Its revisit trigger is ALLOC-01, under which the reserve becomes a
# PER-INVERTER interval at the allocator rather than a ninth claim here.
# ⚠️ SAFETY-31 ADDED THE NINTH, DELIBERATELY, AND THIS IS THE PARAGRAPH THAT
# SENTENCE ASKS FOR. `circulation` is a SET at P_SAFETY on the SITE actuator,
# filed only while `circ_wd.tripped`. It is not the deferred loop-side reserve
# this tripwire was built to catch: it is not conditioned on a state of charge,
# it does not shape the loop, and it appears in NO cycle of the sweeps below -
# the dynamic halves of this part still observe exactly {device, supervised,
# 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.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop"}
"arbitrage", "maintenance", "loop", "circulation"}
# 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.
@@ -2400,7 +2409,7 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
"- a claim filed through a shape it cannot parse has to fail here rather "
"than be counted as nothing, which is how a scan like this goes quietly "
"vacuous", len(_appends) >= 10 and None not in _filed)
check("...and the sources that cycle can file are EXACTLY these eight. THIS is "
check("...and the sources that cycle can file are EXACTLY these nine. THIS is "
"what makes part 10 a tripwire: replay_loop() hand-builds the claim list, "
"so it cannot see a claim main.py adds - and a loop-side SoC reserve is a "
"NINTH source. Filing one takes this line red on the line it is written",
@@ -2530,19 +2539,21 @@ check("...and replay_loop() folding main.py's TWO P_SAFETY rating limits into "
# structurally impossible. The structure is `CLAIM_SCOPE`, so `CLAIM_SCOPE` is
# pinned to the AST-scanned set rather than trusted to agree with it.
check("every source main.py can file is CLASSIFIED site or per-inverter, and "
"nothing else is. A ninth source now goes red twice - once here for "
"having no classification, once above for being a ninth - and it takes an "
"nothing else is. A TENTH source now goes red twice - once here for "
"having no classification, once above for being a tenth - and it takes an "
"argument about what the claim is ABOUT to make either line green",
set(CLAIM_SCOPE) == CLAIM_SOURCES)
print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
if v == SCOPE_SITE))
+ " | per-inverter: "
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
check("...and the split is the one ARB-03 argued: the four about the GRID "
"CONNECTION are site claims and the four about a pack, a device or one "
"write are per-inverter",
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",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
== {"loop", "peak", "arbitrage", "safety"}
== {"loop", "peak", "arbitrage", "safety", "circulation"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance"})
@@ -2618,16 +2629,19 @@ _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 eight: `device`, "
"inverter and its OWN routing method splits the nine: `device`, "
"`supervised`, `failsafe` and `maintenance` bind the unit this controller "
"drives, `loop`, `peak`, `arbitrage` and `safety` stay on the grid "
"connection. Swap any one of them - route `peak` to the unit and the site "
"keeps no floor; route `maintenance` to the site and it can cross that "
"floor into `lo > hi` - and this line goes red",
"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",
_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})
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": 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)
@@ -2636,7 +2650,7 @@ check("...and the roster it was restored to is the shipped one, so nothing "
# IT RIGHT. `actuator_of("maintenance")` written at the `device` append routes a
# rating limit to whatever `maintenance` routes to, and at the shipped
# one-inverter roster BOTH answers are the site, so nothing above can see it.
# ⚠️ AND THE DYNAMIC CYCLE BELOW SEES ONLY THREE OF THE EIGHT DIRECT APPENDS.
# ⚠️ AND THE DYNAMIC CYCLE BELOW SEES ONLY THREE OF THE NINE DIRECT APPENDS.
# That was THIS PART committing this repository's own named failure - a check
# observing something other than what it claimed - inside the check written to
# prevent it. The first cut of 10g called the two-inverter cycle "the line a
@@ -2659,7 +2673,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 eight rather than about the three a fixture happens to reach.
# all nine rather than about the three a fixture happens to reach.
def append_actuator(node):
@@ -2677,7 +2691,7 @@ def append_actuator(node):
return ast.unparse(kw[0].value)
# The appends that BUILD their claim in place - the eight whose routing is
# The appends that BUILD their claim in place - the nine whose routing is
# chosen by hand, and the only ones where a typo is possible - against the two
# that append a claim built elsewhere in the method and carry no `actuator=`
# here at all.
@@ -2708,19 +2722,19 @@ _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 eight of `Controller.cycle()`'s DIRECT appends route through "
check("all NINE 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) == 8
len(_direct) == 9
and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE EIGHT THE LITERAL IT ROUTES THROUGH IS THE "
check("⚠️ AND AT EVERY ONE OF THE NINE THE LITERAL IT ROUTES THROUGH IS THE "
"SOURCE THE CLAIM IS FILED UNDER. THIS is the line a mis-typed "
"`actuator_of(...)` at a call site goes red on - all eight of them, on "
"the line the typo is written, at every roster, INCLUDING the five "
"`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 "
"appends no fixture in this repository reaches. "
"`actuator_of(\"peak\")` at the maintenance append is a different string "
"here even though it is the same VALUE at the shipped roster of one",
@@ -2731,7 +2745,7 @@ 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 eight appends are SCOPE_UNIT - `device`, "
"share. Five of the nine appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe` and maintenance's two - and all five pass one",
_unit_sources_unrouted == []
and len([r for r in _routing
@@ -2744,7 +2758,7 @@ check("...and the only appends this pin does NOT reach are the two that file a "
"arbitrage.claim() by test_arbitrage.py",
sorted(_indirect) == sorted(CLAIM_BUILDERS)
and len(_direct) + len(_indirect) == len(_appends))
check("...and between them the ten appends account for exactly the eight "
check("...and between them the eleven appends account for exactly the nine "
"sources 10f pins, so the routing pin and the source set are one scan "
"and neither can drift from the other",
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
@@ -2787,11 +2801,13 @@ 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 - five of the eight sources need a state "
"no check in this file reaches",
"reason the static pin exists - six of the nine 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",
{c.source for c in _cl2}
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
"arbitrage"})
"arbitrage", "circulation"})
# ⚠️ 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
+568
View File
@@ -537,6 +537,574 @@ check("a crash between the two writes leaves the REASON on disk",
check("...and never a latch with nothing to explain it",
not crashed_store.data.get(TRIPPED_KEY))
# =============================================================================
print()
print("=" * 72)
print("SAFETY-31: circulating power, which nets to zero at the meter")
print("=" * 72)
# ⚠️ READ THIS BEFORE ADDING A CHECK HERE. Half of this card was already built
# and it is NOT the half below. ALLOC-01 shipped the SIGN-COHERENCE GATE
# (`allocator.REFUSE_SIGN`, graded by test_alloc.py part 6): an allocation whose
# shares carry two signs is REFUSED, both units named, no shares returned. So
# the allocator can no longer COMMAND circulating power, and nothing here
# re-tests that.
#
# What is graded here is the MEASUREMENT half: circulating power the allocator
# NEVER COMMANDED. The two are complements, not duplicates - the gate refuses an
# allocation before it is written and cannot see one millisecond past that; this
# reads the hardware and does not care what was written.
from app.inverter import LINK_STALE_S # noqa: E402
from app.circulation import ( # noqa: E402
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
circulating_w, opposing,
)
print()
print("1. the sensor: sum(|Bi|) - |sum(Bi)| over MEASURED per-unit power")
check("both units discharging: the triangle equality holds and it is ZERO. "
"This is the ordinary case and the sensor must be silent in it",
circulating_w([1000.0, 2000.0]) == 0.0)
check("...both charging, likewise", circulating_w([-1000.0, -2000.0]) == 0.0)
check("...and a unit resting at exactly zero beside a working one is not "
"opposition", circulating_w([0.0, 2500.0]) == 0.0)
# ⚠️ ASSERTED AGAINST A LITERAL, NOT AGAINST `2 * min(abs(a), abs(b))`.
# MUTATION.md's rule 2: an assertion written in terms of the expression under
# test cannot tell "the right formula" from "the same wrong formula twice".
check("A charging 3000 W while B discharges 3000 W reads 6000 W - the card's "
"own scenario, and the figure is 2*min(|A|,|B|), the power going round "
"the loop through BOTH conversion stages",
circulating_w([-3000.0, 3000.0]) == 6000.0)
check("...unequal opposition is bounded by the smaller unit: -3000 against "
"+1000 is 2000 W, not 4000",
circulating_w([-3000.0, 1000.0]) == 2000.0)
print(f" [-3000, +3000] -> {circulating_w([-3000.0, 3000.0]):.0f} W "
f"[-3000, +1000] -> {circulating_w([-3000.0, 1000.0]):.0f} W "
f"[+1000, +2000] -> {circulating_w([1000.0, 2000.0]):.0f} W")
check("three units, one out of step: it is defined over the whole roster, not "
"over a pair", circulating_w([-2000.0, 1000.0, 1000.0]) == 4000.0)
print()
print(" AC 5: at N=1 it is IDENTICALLY zero, for every reading")
# ⚠️ SWEPT, NOT SAMPLED. "Identically zero" is a claim about EVERY value, and
# one call with one number cannot distinguish it from "zero for this number".
_n1 = [circulating_w([v]) for v in
(0.0, 1.0, -1.0, 2500.0, -2500.0, 5000.0, -5000.0, 1e9, -1e9)]
check("nine readings from -1e9 to +1e9 W, every one of them 0.0",
set(_n1) == {0.0})
check("...and an EMPTY roster is zero too, not a crash - `Controller.publish()` "
"calls this before anything has been read",
circulating_w([]) == 0.0)
check("⚠️ AND A MISSING READING AT N=1 IS STILL ZERO, WHICH IS THE OPPOSITE OF "
"THE N>=2 RULE BELOW AND IS DELIBERATE. With one term the algebra has no "
"room for an unknown: |B| - |B| is 0 for every B, INCLUDING one nobody "
"read. Returning None here would park the shipped one-inverter install in "
"a permanent 'cannot tell' on a quantity that cannot be anything else",
circulating_w([None]) == 0.0)
print()
print(" a missing reading at N>=2 is UNKNOWN, and never zero")
check("⚠️ None, NOT 0.0. Zero would be this sensor reporting HEALTH FROM AN "
"ABSENCE - which is the 2026-09-02 failure itself, where a subsystem "
"stopped answering and its silence read as calm for 6 h 25 m",
circulating_w([None, 3000.0]) is None
and circulating_w([-3000.0, None]) is None
and circulating_w([None, None]) is None)
print()
print(" it is NON-NEGATIVE, including where floating point would say otherwise")
_fp = [circulating_w([0.1, 0.2, 0.3]), circulating_w([1e-9, 2e-9]),
circulating_w([-0.1, -0.2, -0.30000000000000004])]
print(f" {_fp}")
check("a same-sign roster never publishes a negative number. ⚠️ THIS GRADES "
"THE PROPERTY, NOT THE `max(0.0, ...)` GUARD THAT DEFENDS IT, and the "
"difference is stated so a mutation sweep does not report a gap that is "
"not one: for a same-sign roster the two sums accumulate the same "
"magnitudes in the same order and are bit-identical, so the guard is an "
"EQUIVALENT MUTANT - searched over 700 000 random rosters without a "
"single negative. circulation.py says so at the line",
all(v >= 0.0 for v in _fp))
print()
print("2. CFG-03: config.yaml is the source of truth and there is ONE copy")
# ⚠️ READ OFF THE FILE, NOT TRANSCRIBED. The Dockerfile does not put config.yaml
# in the image, so the running add-on cannot read it and `CIRC_OPTION_DEFAULTS`
# has to exist - which makes it a place to drift. This is the same pin
# test_maintenance.py puts on MAINT_OPTION_DEFAULTS, for the same reason.
import re as _re # noqa: E402
from pathlib import Path as _Path # noqa: E402
_cfg_txt = (_Path(__file__).resolve().parent / "config.yaml").read_text(encoding="utf-8")
# ⚠️ SPLIT ON THE TOP-LEVEL KEY, NOT ON THE WORD. "schema:" appears inside the
# CFG-03 comment above these very options ("options:, schema:, ..."), and
# splitting on the bare word cut the file in the middle of a comment - the
# options block then contained no options, the regex found none, and this pin
# compared an EMPTY dict against an empty expectation. That is MUTATION.md's
# third failure mode exactly: the assertion ran, the sentence was true, and the
# data could not reach the failure it names. Caught by the printed line.
_cfg_opts, _cfg_schema = _cfg_txt.split("\nschema:", 1)
_from_yaml = {k: int(v) for k, v in
_re.findall(r"^ (circulation_\w+):\s*(\d+)\s*$", _cfg_opts, _re.M)}
print(f" config.yaml options: {_from_yaml}")
print(f" CIRC_OPTION_DEFAULTS: {CIRC_OPTION_DEFAULTS}")
check("both options are declared in config.yaml's options: block and the "
"in-code mirror agrees with it key for key and value for value",
_from_yaml == CIRC_OPTION_DEFAULTS and len(_from_yaml) == 2)
_bounds = dict(_re.findall(r"^ (circulation_\w+):\s*int\((\d+),(\d+)\)\s*$",
_cfg_schema, _re.M)
and [(m[0], (int(m[1]), int(m[2]))) for m in
_re.findall(r"^ (circulation_\w+):\s*int\((\d+),(\d+)\)\s*$",
_cfg_schema, _re.M)])
print(f" schema bounds: {_bounds}")
check("...and BOTH have a schema range, so the Supervisor refuses an "
"out-of-range value rather than the add-on discovering it at runtime",
set(_bounds) == set(CIRC_OPTION_DEFAULTS))
check("⚠️ AND THE THRESHOLD'S FLOOR IS THE MEASURED KNEE, NOT A ROUND NUMBER. "
"On the TEST-19 rig the worst CONTINUOUS opposition a site where NOTHING "
"is faulty can produce is 5.0 s at 400 W, 10.0 s at 300 W, 40.0 s at "
"250 W and 505.0 s at 100 W. Below the knee, ordinary operation holds "
"this sensor over the threshold for MINUTES and no window shorter than "
"the 900 s billing quarter could tell that from a fault - so a "
"configurable value under 400 W would be a false-alarm generator, and "
"the schema does not offer one",
_bounds["circulation_w"][0] == 400)
check("...and the window's floor is the firmware's own veto window, "
"`inverter.LINK_STALE_S`, because below it a unit still executing a "
"stale share is a LINK fault the add-on already handles by dropping it "
"from the split - and its ceiling is the 900 s quarter that bills the "
"capacity tariff, inside which this must be caught and acted on",
_bounds["circulation_hold_s"] == (int(LINK_STALE_S), 900))
check("the shipped defaults sit inside their own schema ranges",
all(_bounds[k][0] <= v <= _bounds[k][1]
for k, v in CIRC_OPTION_DEFAULTS.items()))
check("⚠️ AND NEITHER IS A ROUND NUMBER, WHICH IS AC 3 READ LITERALLY. 700 W "
"is 1.75x the measured knee - the headroom is bought for inverter B's "
"ramp and lag being PLACEHOLDERS until SPIKE-04 measures them - and 75 s "
"is 15x the worst legitimate episode and 2.5x the firmware veto. 60, 100, "
"120, 180, 300, 500 and 1000 are all values a guess would have landed on",
CIRC_OPTION_DEFAULTS["circulation_w"] not in (100, 500, 1000, 2000)
and CIRC_OPTION_DEFAULTS["circulation_hold_s"] not in (30, 60, 120, 180, 300))
print()
print("3. the trip: sustained opposition fires, the slew transient does not")
def circ_drive(series, cfg=None, step=5.0):
"""Feed a list of circulating figures, one per control cycle."""
wd = CirculationWatchdog(cfg or CirculationConfig())
at = None
for i, v in enumerate(series):
if wd.evaluate(v, T0 + timedelta(seconds=i * step)) and at is None:
at = i * step
return wd, at
_CFG = CirculationConfig()
_sustained, _at = circ_drive([2000.0] * 40)
check("2000 W of measured opposition, held, trips", _sustained.tripped)
check(f"...and at {_CFG.hold_s:.0f} s, not before - the window is a window, "
"not a delay somebody rounded", _at == _CFG.hold_s)
check("the reason gives the operator the figure, the peak and the duration, "
"and says in words why nothing else reported it",
"2000" in _sustained.reason and "75 s" in _sustained.reason
and "nets to zero" in _sustained.reason.lower())
print(f" e.g. {_sustained.reason!r}")
print()
print(" ⚠️ AND NOT ON THE TRANSIENT, WHICH IS THE FALSE-ALARM CASE")
# ⚠️ THE SHAPES BELOW ARE THE RIG'S, NOT INVENTED. Measured on TEST-19 over 924
# closed-loop runs in which every unit executed the share it was given: the
# worst continuous opposition above 700 W is 5.0 s - ONE control cycle - at a
# peak of 1700 W. That is the serialised write path: on a reversing cycle B's
# new number lands up to a whole cycle after A's, both slew through zero at
# 800 W/s behind a 10 s lag, and between the two crossings they genuinely
# oppose. A guard that trips on that is a false-alarm generator, and this
# project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19).
_one_cycle = [0.0] * 5 + [1700.0] + [0.0] * 30
_t1, _ = circ_drive(_one_cycle)
check("the worst single transient the rig can produce - 1700 W for ONE control "
"cycle - does not trip", not _t1.tripped)
_repeated = ([0.0] * 3 + [1700.0]) * 20
_t2, _ = circ_drive(_repeated)
check("...and neither does it REPEATED every fourth cycle for 400 s, which is "
"a load flapping across zero all afternoon. The window clears between "
"them, so nothing accumulates", not _t2.tripped)
_almost = [1500.0] * 14 + [0.0] + [1500.0] * 14
_t3, _ = circ_drive(_almost)
check("...and opposition that stands for 70 s, clears for one cycle, then "
"stands again for 70 s does NOT trip: 140 s of trouble, but never 75 s "
"CONTINUOUSLY, which is what the guard actually claims to measure",
not _t3.tripped)
_below = [_CFG.min_w] * 60
_t4, _ = circ_drive(_below)
check(f"...and exactly {_CFG.min_w:.0f} W forever does not trip - the "
"comparison is strictly greater, so the threshold is a threshold rather "
"than an off-by-one", not _t4.tripped)
_just_over, _at5 = circ_drive([_CFG.min_w + 1.0] * 40)
check("...while one watt above it does, held long enough. The pair is what "
"makes the boundary a measured line rather than a direction",
_just_over.tripped and _at5 == _CFG.hold_s)
print()
print(" a MISSING reading clears the window rather than accumulating it")
_gap, _ = circ_drive([2000.0] * 10 + [None] + [2000.0] * 10)
check("⚠️ SAFETY-02 MAKES THE SAME DECISION FOR A STALE METER, AND FOR THE SAME "
"REASON: a frozen figure held against a moving one manufactures a fault "
"in about a window's worth of cycles. 50 s over, one unknown, 50 s over "
"again is not 100 s of opposition", not _gap.tripped)
print()
print(" the latch: it does not re-evaluate, because the trip removes the evidence")
_latched, _ = circ_drive([2000.0] * 40 + [0.0] * 40)
check("still tripped after the opposition has gone - and the opposition ALWAYS "
"goes, because the trip commands 0 W everywhere and two inverters at zero "
"cannot oppose. A non-latching version would de-assert and start the "
"whole thing again, three cycles at a time, forever", _latched.tripped)
_r = CirculationWatchdog(_CFG, tripped=True, reason="restored from the store")
check("...and one restored as tripped stays tripped without being re-driven",
_r.evaluate(0.0, T0) and _r.reason == "restored from the store")
_r.reset()
check("...until it is reset, which is the operator's press and nothing else",
not _r.tripped and _r.reason == "" and _r.held_s(T0) == 0.0)
print()
print(" `opposing()` names the two sides, for the sentence the operator reads")
check("indices of who is discharging and who is charging",
opposing([-3000.0, 3000.0]) == ([1], [0]))
check("...and a unit idling below the floor is not named as a participant in "
"somebody else's loop", opposing([-3000.0, 3000.0, 3.0], min_w=350.0)
== ([1], [0]))
print()
print("4. END TO END, AND THIS IS AC 2 - the sensor is MEASURED, so it sees")
print(" circulation the allocator NEVER COMMANDED")
# ⚠️ THIS IS THE CHECK MUTATION.md's THIRD FAILURE MODE IS ABOUT, so it is
# built the way that section says to build one. The claim is "X differs from Y"
# - the MEASURED sensor differs from a sensor computed off the allocation
# record - and X and Y are ACTUALLY MADE DIFFERENT here, with the difference
# asserted as its own precondition and PRINTED, before anything is concluded
# from it. DEV-02 shipped a check comparing a number against itself; ALLOC-01
# shipped a generator that found 0 escapes where a wider one found 39 991.
from app.inverter import Inverter # noqa: E402
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~10 days out and SPIKE-04
# measures it. Its rating here is a HYPOTHESIS, named as one, exactly as
# test_peakguard.py's helper names its own.
B_RATING_HYPOTHESIS = 3000.0
class _CircHass(_Hass):
"""A house at rest, and two inverters MEASURED to be fighting each other.
⚠️ THE MEETER READS ZERO AND THAT IS THE WHOLE POINT. grid = load - sum(B),
and with A at -3000 W and B at +3000 W the sum is zero, so P1 reads exactly
what an idle site reads. Every existing guard in this add-on is a consumer
of that number.
"""
def __init__(self, a_w=-3000.0, b_w=3000.0):
super().__init__()
self.a_w, self.b_w = a_w, b_w
async def number(self, entity, invert=False):
return {"sensor.grid": 0.0,
"sensor.soc": 50.0, "sensor.soc_b": 50.0,
"sensor.batt": self.a_w, "sensor.batt_b": self.b_w}.get(entity)
def _two_inverter(hass, store, *, hold_s=1, min_w=700, **over):
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 5000, "gain": 0.6,
"circulation_w": min_w, "circulation_hold_s": hold_s,
"auto_start": True}
opts.update(over)
c = Controller(opts, hass, store, _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=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:
inv.dev_min = inv.dev_min if inv.dev_min is not None else -5000.0
inv.dev_max = inv.dev_max if inv.dev_max is not None else 5000.0
return c
async def _circ_e2e():
hass = _CircHass()
c = _two_inverter(hass, _MemStore())
seen = []
for _ in range(14):
await c.read_inputs()
await c.cycle()
seen.append(dict(circ=c.site_circulating_w, grid=c.grid,
target=c.target, alloc=c.allocation,
tripped=c.circ_wd.tripped))
await asyncio.sleep(0.25)
return c, hass, seen
_c, _chass, _seen = asyncio.run(_circ_e2e())
_first = _seen[0]
_alloc = _first["alloc"]
_shares = dict(_alloc.shares)
_meas = [inv.batt for inv in _c.inverters]
print(f" MEASURED per-inverter power : {dict(zip(_c.units, _meas))}")
print(f" ALLOCATION RECORD : ok={_alloc.ok} {_alloc.explain()}")
print(f" meter (P1) : {_first['grid']:+.0f} W")
print(f" sensor over MEASURED power : {circulating_w(_meas):.0f} W")
print(f" sensor over the RECORD : "
f"{circulating_w(list(_shares.values())):.0f} W")
# --- THE PRECONDITION, AS ITS OWN CHECK -------------------------------------
# ⚠️ MUTATION.md RULE 1. If a later edit makes the record and the measurement
# agree again, THIS goes red rather than the claim below going quietly vacuous.
check("PRECONDITION: the allocation record and the measured power are "
"ACTUALLY DIFFERENT in this fixture. The record is coherent - it is `ok`, "
"its shares carry one sign, and they sum to the site target - while the "
"hardware is measured at -3000 W and +3000 W. Without this line the two "
"claims below could both be true of a fixture in which nothing differed",
_alloc.ok is True
and len({(s > 0) - (s < 0) for s in _shares.values() if s}) <= 1
and sorted(_meas) == [-3000.0, 3000.0]
and set(_shares.values()) != set(_meas))
check("ALLOC-01's gate is SATISFIED here, and that is the point rather than an "
"accident: the allocator refused nothing, because it commanded nothing "
"wrong. `REFUSE_SIGN` cannot fire on a record whose shares agree in sign",
_alloc.refused is None)
check("⚠️ SO A SENSOR COMPUTED FROM THE ALLOCATION RECORD READS EXACTLY 0 W. "
"That is the cheap version of this card, and it is worth nothing: "
"ALLOC-01's sign-coherence gate already guarantees the record is "
"sign-coherent, so a record-derived sensor is zero BY CONSTRUCTION and "
"can only ever confirm that the allocator agrees with itself",
circulating_w(list(_shares.values())) == 0.0)
check("⚠️ AND THE SHIPPED SENSOR READS 6000 W ON THE SAME CYCLE, because it is "
"derived from `Inverter.batt` - each unit's OWN measurement. This is the "
"whole card: it detects circulation THE ALLOCATOR NEVER COMMANDED - a "
"unit ignoring or lagging its share, a stale share still being executed, "
"or HW-01's power stage running its last instruction for 6 h 25 m with "
"the comms dead and the record impeccable throughout",
_c.site_circulating_w == 6000.0)
check("...and the METER READS ZERO through all of it, which is why no existing "
"guard fires. SAFETY-02 gates on abs(grid_w) > 300 W and that gate never "
"opens; the peak guard and the capacity tariff both bill grid exchange",
_first["grid"] == 0.0
and not any(i.sign_wd.tripped for i in _c.inverters))
print()
print(" ...and the trip ACTS: a site-wide stop, not a notification")
check("the watchdog tripped", _c.circ_wd.tripped)
check("the site is commanded 0 W", _c.target == 0.0)
check("...and 0 W actually reached BOTH inverters, not just the one somebody "
"decided was to blame",
_chass.writes and _chass.writes[-1] == 0.0
and all(i.target == 0.0 for i in _c.inverters))
check("...and it got there through the `circulation` claim, by name, rather "
"than by some other guard happening to stop the site",
"circulation" in _c.reason)
print(f" status line: {_c.reason!r}")
check("the status page says so in words, and says the meter cannot see it",
any("CIRCULATING POWER" in ck["text"] and "meter cannot see" in ck["text"]
for ck in _c.checks()))
check("the banner leads with it - above the sign failsafe, because a sign "
"disagreement is visible on every dashboard in the house and this is "
"visible nowhere at all",
"CIRCULATING POWER" in _c.status_payload()["banner"])
_pub = {}
_c.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub.update(v),
"close": lambda _s: None})()
_c.publish()
print(f" published: circulation={_pub.get('circulation')} "
f"grid={_pub.get('grid')} status={_pub.get('status')}")
check("⚠️ AND IT IS PUBLISHED AS ITS OWN SENSOR BESIDE THE GRID FIGURE. "
"`grid 0 W` and `circulating 6000 W` on the same dashboard is the whole "
"phenomenon in two rows, and NO arithmetic on the grid figure alone can "
"ever produce the second one",
_pub.get("circulation") == 6000 and _pub.get("grid") == 0.0)
check("...and the controller-status sensor reports the failsafe state",
_pub.get("status") == "failsafe")
# ⚠️ AND THE UNKNOWN CASE, WHICH A SWEEP FOUND UNGRADED. A per-unit reading that
# did not arrive makes the site figure None, and `MqttPublisher.publish()` DROPS
# None values - so the entity keeps its last figure and the dashboard shows a
# stale number rather than a fresh zero. Publishing `round(x or 0.0)` instead
# survived the first mutation battery: it turns "I do not know" into "0 W", i.e.
# health manufactured from an absence, on the one surface an operator actually
# watches. Same defect as the sensor's own None rule, one layer out.
_c.inverters[1].batt = None
_pub_unknown = {}
_c.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub_unknown.update(
{k: x for k, x in v.items() if x is not None}),
"close": lambda _s: None})()
_c.publish()
print(f" with inverter-b's reading missing, publish() offers: "
f"circulation={_pub_unknown.get('circulation', '<omitted>')!r}")
check("⚠️ WITH ONE UNIT'S READING MISSING THE SENSOR IS NOT PUBLISHED AT ALL, "
"so the entity holds its last figure instead of being handed a 0 that "
"would read as health. `publish()` drops None, and this is what makes "
"that drop deliberate rather than incidental",
"circulation" not in _pub_unknown)
_c.inverters[1].batt = 3000.0
print()
print("5. AC 5: at ONE inverter the sensor is identically zero and NOTHING changes")
async def _n1():
hass = _CircHass(a_w=-3000.0, b_w=0.0)
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 5000, "gain": 0.6,
"circulation_w": 700, "circulation_hold_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
for _ in range(14):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.2)
return c
_c1 = asyncio.run(_n1())
print(f" roster={_c1.units} measured batt={_c1.inverters[0].batt:+.0f} W "
f"sensor={_c1.site_circulating_w}")
check("the shipped roster of one, with the single inverter measured at "
"-3000 W - a magnitude that would trip instantly at N=2 - reads exactly "
"0.0 W", _c1.site_circulating_w == 0.0)
check("...so the watchdog never trips, over 14 cycles at a 1 s window",
not _c1.circ_wd.tripped)
check("...no `circulation` claim is ever filed, so the arbitration is "
"byte-identical to what shipped", "circulation" not in _c1.reason)
check("...and the status page says n/a rather than claiming to watch something "
"that cannot happen - an 'armed' row for an identically-zero quantity is "
"a guard advertising work it is not doing",
any("circulating power: n/a at one inverter" in ck["text"]
for ck in _c1.checks()))
_pub1 = {}
_c1.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub1.update(v),
"close": lambda _s: None})()
_c1.publish()
check("...and it publishes 0, not None: the value is KNOWN at N=1, and a "
"dashboard row reading 0 W is the honest report of it",
_pub1.get("circulation") == 0)
print()
print("6. SAFETY-34: the alarm survives a Home Assistant restart")
# ⚠️ THE LATCH IS NOT THE ALARM, AND THE 2026-09-02 OUTAGE IS WHY THAT SENTENCE
# IS HERE. `persistent_notification` is IN MEMORY: Home Assistant restarts and
# the card is gone while the fault runs on. That outage went unseen for 6 h 25 m
# with the detector correct throughout. So the durable half (the store) and the
# visible half (the notification) have to be joined, and this grades the join.
async def _circ_restart():
shared = _MemStore()
h1 = _CircHass()
a = _two_inverter(h1, shared)
for _ in range(14):
await a.read_inputs()
await a.cycle()
await asyncio.sleep(0.25)
# THE RESTART. Same store, brand new Controller and Hass - which is exactly
# what a Home Assistant restart does to this add-on. The house has stopped
# circulating in the meantime, so nothing in the new process's own
# measurements could re-derive the trip.
h2 = _CircHass(a_w=0.0, b_w=0.0)
b = _two_inverter(h2, shared)
for _ in range(3):
await b.read_inputs()
await b.cycle()
await asyncio.sleep(0.05)
return a, b, shared, h1, h2
_a, _b, _shared, _h1, _h2 = asyncio.run(_circ_restart())
print(f" store after the trip: circ_tripped="
f"{_shared.data.get('circ_tripped')!r} "
f"circ_reason={str(_shared.data.get('circ_reason'))[:48]!r}...")
check("the trip is written to /data - the add-on's only durable volume - as a "
"flag AND a reason",
_shared.data.get("circ_tripped") is True
and "CIRCULATING_POWER" in str(_shared.data.get("circ_reason")))
check("⚠️ THE REBUILT CONTROLLER COMES UP TRIPPED, and its own measurements say "
"the site is quiet - 0 W on both units - so this can ONLY have come from "
"the store. A latch that lives in memory comes back armed, into the "
"fault, with no operator involved",
_b.circ_wd.tripped and _b.site_circulating_w == 0.0)
check("...carrying the reason with it, so the operator who has to decide "
"whether to clear it can read WHY",
"CIRCULATING_POWER" in _b.circ_wd.reason)
check("...and it is still commanding 0 W on the far side of the restart",
_b.target == 0.0 and "circulation" in _b.reason)
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"]
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"]
print(f" notifications raised: {len(_notes1)} before the restart, "
f"{len(_notes2)} after it")
check("the alarm was raised in the first process", len(_notes1) >= 1)
check("⚠️ AND RE-RAISED IN THE SECOND ONE. THIS IS THE CARD'S THIRD "
"DELIVERABLE. `persistent_notification` does not survive a Home Assistant "
"restart, so a latch alone would leave a stopped site with NOTHING in "
"front of the operator - the detector correct, the state file correct, "
"and the screen blank. That is how 2026-09-02 went unseen for 6 h 25 m",
len(_notes2) >= 1)
check("...under a fixed notification_id of its own, so a second episode "
"REPLACES the first rather than stacking, and it is not confused with "
"the frozen meter, the clamp or the saturated peak guard",
all(c[2]["notification_id"] == "goodwe_circulating_power"
for c in _notes1 + _notes2)
and len({c[2]["notification_id"] for c in _notes1 + _notes2}) == 1)
_msg = _notes2[0][2]["message"]
check("...and the message tells the operator the three things only this alarm "
"can: that the meter cannot see it, that control has been stopped, and "
"that if the grid reading does not move afterwards one inverter is no "
"longer listening and a human at the isolator is the last actuator",
"METER CANNOT SEE" in _msg and "CONTROL HAS BEEN STOPPED" in _msg
and "isolator" in _msg)
check("...and it does not raise the same card on every cycle - a per-PROCESS "
"flag, deliberately not persisted, is what makes 'has a human been shown "
"this' a different question from 'is this site tripped'",
len(_notes2) == 1)
print()
print(" the operator's press is the only way out, and it clears BOTH halves")
async def _circ_clear():
shared = _MemStore()
c = _two_inverter(_CircHass(), shared)
for _ in range(14):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.25)
was = c.circ_wd.tripped
await c.handle_action("auto_toggle") # stop
await c.handle_action("auto_toggle") # start - the deliberate press
return c, shared, was
_cc, _cstore, _was = asyncio.run(_circ_clear())
check("it really was tripped before the press", _was)
check("a deliberate START clears the latch in memory and on disk together - "
"the condition ALWAYS goes away on its own, because the trip commands "
"0 W everywhere and two inverters at zero cannot oppose, so clearing on "
"the condition would clear on nothing",
not _cc.circ_wd.tripped
and _cstore.data.get("circ_tripped") is False
and _cstore.data.get("circ_reason") == "")
check("⚠️ AND THE 'HAS A HUMAN SEEN THIS' FLAG IS CLEARED WITH IT. Left "
"standing through a clear, the NEXT trip in this process would latch, "
"stop the site, and raise no notification at all",
_cc._circ_announced is False)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")