SAFETY-31: measure circulating power, which nets to zero at the meter
checks / unit (pull_request) Successful in 4m28s
checks / unit (pull_request) Successful in 4m28s
Inverter A charging at 3 kW while inverter B discharges at 3 kW nets ~0 W at
P1. The site reads idle while both packs are cycled through two round-trip
conversion losses continuously, for no benefit, and the wear is real.
NOTHING EXISTING CAN SEE IT, AND THAT IS STRUCTURAL. Every guard in this
add-on is a consumer of the NET figure at the grid connection: SAFETY-02's
sign watchdog gates on abs(grid_w) > 300 and the gate never opens at a netted
zero; the peak guard and the capacity tariff both bill grid exchange.
Circulating power is precisely the component of per-inverter power that the
sum destroys, so no threshold on any existing detector could reach it.
WHAT ALLOC-01 ALREADY COVERED, AND IS NOT RE-IMPLEMENTED HERE. Its AC 7
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. That is the COMMAND half - the allocator
can no longer command circulating power. This commit is the MEASUREMENT half:
circulation the allocator NEVER COMMANDED.
app/circulation.py the sensor and the trip
main.py site_circulating_w, the evaluation, the claim, the alarm
arbiter.py `circulation` classified SCOPE_SITE, with the argument
store.py a flat, site-level durable latch
mqtt.py sensor.goodwe_circulating_power
config.yaml two options, two schema ranges, CFG-03 discipline
THE SENSOR: sum(|Bi|) - |sum(Bi)|. Zero when the units pull the same way,
2*min(|Ba|,|Bb|) when they oppose. DERIVED FROM MEASURED PER-INVERTER POWER -
Inverter.batt, register 0x0518 - and never from the allocation record. The
cheap version would be simpler and worth nothing: ALLOC-01's gate already
guarantees the record is sign-coherent, so a record-derived sensor reads zero
BY CONSTRUCTION and can only confirm that the allocator agrees with itself,
which is the third failure mode MUTATION.md names. What this catches is 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. batt became
per-instance in DEV-02; before that this could not have been written.
VERIFIED BY MAKING ONE UNIT IGNORE ITS SHARE, on the TEST-19 two-master rig:
S = -2500 W shares a = -1250 W b = -1250 W <- record ok, ONE sign, sums
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 scenario, A -3000 W against B +3000 W at zero house load:
the meter reads +0 W, the sensor reads 6000 W, and the shipped SignWatchdog's
predicate is FALSE on 30 of 30 cycles.
WHAT "ACTED ON" MEANS - decided, and recorded in DECISIONS/. The trip files
Claim.set("circulation", P_SAFETY, 0.0) on the SITE actuator: every inverter
to 0 W, latched. HW-01 established that commanding a wedged inverter achieves
nothing, 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 (1) halves the
loop, which needs no cooperation from the wedged unit, and (2) - the more
important half - makes the residue VISIBLE at the meter instead of cancelled
at it, where every ordinary 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. SITE-scoped because the quantity 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 filing it per-unit would
invent an attribution and could park the only unit still listening.
THE WINDOW IS FROM THE RIG, NOT A ROUND NUMBER (AC 3). 924 closed-loop runs in
which NOTHING is faulty, over both families of legitimate opposition (7
reversals x 5 write staggers; 7 flap periods x 4 amplitudes), swept across 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 - below it, ordinary operation holds the sensor over the line for
minutes and no window inside the 900 s billing quarter could tell that from a
fault. circulation_w 700 is 1.75x the knee, the headroom bought for inverter
B's ramp and lag being PLACEHOLDERS until SPIKE-04 measures them.
circulation_hold_s 75 clears three bounds: 15x the worst legitimate episode,
2.5x the firmware's own LINK_STALE_S veto, 12x inside the quarter. A momentary
opposition while one unit slews through zero is LEGITIMATE - writes are
serialised, so B's number lands up to a cycle after A's - and 0 of the 924
runs trip.
THE ALARM SURVIVES AN HA 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 and the notification is RE-RAISED on the first cycle after
every start, through the same code path as a fresh trip - two paths would be
two chances for the restart one to rot unnoticed.
AT N=1 NOTHING CHANGES (AC 5). |B| - |B| is 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 (AC 4). 18 mutations, all RED on named checks, each on
a freshly copied tree with no __pycache__ mounted :ro. M10 - the sensor
rewritten to read self.allocation.shares - is the headline: it takes AC 2 red.
Three mutations are +0 B, the risky delta, and are marked as such. One
equivalent mutant (the max(0.0,...) guard) is ARGUED rather than claimed:
searched over 700 000 rosters without a negative. One genuine SURVIVOR was
found and closed - publish() turning an unknown into a published 0 W, the
sensor's own None rule defeated one layer out. Full ledger in DECISIONS/.
arbiter.CLAIM_SCOPE gains its ninth source, so test_peakguard.py's 10f and 10g
tripwires and test_arbiter.py's scope table went red on the line the append
was written. Updated deliberately, with the argument, as their own comments
require. Full 19-file suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user