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
Owner

What this builds, and what ALLOC-01 already covered

Half of this card was already shipped and this PR does not re-implement it.
ALLOC-01 AC 7 is 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.

ALLOC-01 — the COMMAND half this PR — the MEASUREMENT half
sees the shares about to be handed out each inverter's own measured power (0x0518)
does refuses the allocation stops the site, latches, alarms durably
covers circulation the allocator would have commanded circulation nobody commanded

They are complements. 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.

The problem

Inverter A charging at 3 kW while 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. Nothing existing can see it, and that is structural:
every guard here consumes the net figure. SAFETY-02 gates on
abs(grid_w) > 300 and that gate never opens at a netted zero; the peak guard
and the capacity tariff bill grid exchange. Circulating power is exactly the
component of per-inverter power the sum destroys.

The sensor — and why it is MEASURED

circulating_w = Σ|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.

⚠️ From Inverter.batt, 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 could only confirm the allocator agrees with itself — the
third failure mode MUTATION.md names. What this catches is a unit ignoring or
lagging its share, a stale share still executed, a maintenance/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 record impeccable
throughout. batt became per-instance in DEV-02.

AC 2, verified by making one unit ignore its share on the TEST-19 rig:

  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

AC 1, the card's own scenario on the same rig — A −3000 W, B +3000 W, zero
house load:

  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 X≠Y check is built the way MUTATION.md requires: the precondition that
the record and the measurement actually differ is its own check, and printed.

What "acted on" means

Claim.set("circulation", P_SAFETY, 0.0) on the SITE actuator — every
inverter to 0 W, latched until an operator starts control.

The objection is correct: HW-01 established a wedged inverter cannot be
commanded at all. The answer: 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, needing
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, said plainly: it does not stop the wedged inverter.
Nothing this add-on can send will. 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 — a unit ignoring its share and a sibling
faithfully executing a share computed to sum with it
read identically, so
filing per-unit would invent an attribution and could park the only unit still
listening. Same argument for the flat store latch.

AC 3 — the window is from the rig, not a round number

924 closed-loop runs in which nothing is faulty, over both families of
legitimate opposition (7 reversals × 5 write staggers; 7 flap periods × 4
amplitudes), swept across the whole range the schema accepts for gain and
slew_w. Worst continuous legitimate opposition:

threshold 100 W 250 W 300 W 400 W 700 W 1700 W
worst continuous 505.0 s 40.0 s 10.0 s 5.0 s 5.0 s 0.0 s

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. That 5.0 s is one control cycle:
writes are serialised, so on a reversing cycle B's number lands up to a cycle
after A's and the two genuinely oppose between their zero crossings. Nothing
is wrong, and the guard does not fire — 0 of 924 runs trip.

  • circulation_w: 700 — 1.75× the knee. The margin is bought for one named
    reason: inverter B does not exist yet; its rig ramp and lag are A's,
    copied, measured=False. SPIKE-04 is the revisit trigger.
  • circulation_hold_s: 75 — 15× the worst legitimate episode, 2.5× the
    firmware's own LINK_STALE_S veto, 12× inside the quarter. Deliberately not
    60 or 120, and a check asserts it is none of the values a guess lands on.

AC 5 — N=1 unchanged

|B| − |B| == 0 for every reading including one nobody took. No claim filed,
arbitration byte-identical, status page says n/a rather than claiming to watch
something that cannot happen.

SAFETY-34 — the alarm survives an HA restart

persistent_notification is in memory, which is how the 2026-09-02 outage
went unseen for 6 h 25 m with the detector correct throughout. The latch is
persisted to /data; 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.

AC 4 — verified by breaking it

18 mutations, all RED on named checks. Each on a freshly copied tree with no
__pycache__, mounted :ro — the structural immunity MUTATION.md records.
Byte deltas on the record; three are +0 B, the risky delta, and are marked.

  • M10 — the sensor rewritten to read self.allocation.shares — takes AC 2
    red
    . That is the card.
  • M15/M16/M17 (+0 B): scope flipped to SCOPE_UNIT, schema floor 400→100,
    default 700→500. All red.
  • M4 is an equivalent mutant, argued not claimed: the max(0.0, …)
    guard is unreachable — same-sign rosters accumulate identical magnitudes in
    identical order, and 700 000 random rosters produced no negative. Recorded so
    a later sweep does not report a gap that is not one.
  • M19 was a genuine SURVIVOR, found and closed: nothing graded what
    publish() does with a missing reading, so turning "I don't know" into
    "0 W" on the surface an operator watches went unnoticed — the sensor's own
    None rule defeated one layer out. Check added, mutation re-run, red.

Full ledger: DECISIONS/SAFETY-31-circulating-power.md.

Tripwires updated deliberately

arbiter.CLAIM_SCOPE gains its ninth source, so test_peakguard.py 10f/10g
and test_arbiter.py's scope table went red on the line the append was
written
— the tripwires working. Updated with the argument their own comments
demand, not by editing the number. The dynamic sweeps are unaffected:
circulation appears in no cycle of them, because nothing in those fixtures
circulates.

Full 19-file suite green.

What this does NOT close

  • Inverter B's ramp and lag are placeholders. No figure here is a
    measurement of inverter B.
  • A wedged inverter still cannot be stopped by this add-on.
  • A loop below ~350 W per unit is not reported (deliberate).
  • New finding, out of scope: with a unit wedged, the loop's anti-windup
    freeze fires and leaves a standing grid error (−3750 W on the rig) that
    nothing alarms on. Real, observed while producing these numbers, and it wants
    a card of its own.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## What this builds, and what ALLOC-01 already covered **Half of this card was already shipped and this PR does not re-implement it.** [[ALLOC-01]] AC 7 is 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. | | **ALLOC-01 — the COMMAND half** | **this PR — the MEASUREMENT half** | |---|---|---| | sees | the shares about to be handed out | each inverter's **own measured power** (0x0518) | | does | **refuses** the allocation | **stops the site**, latches, alarms durably | | covers | circulation the allocator would have **commanded** | circulation **nobody commanded** | They are complements. 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. ## The problem Inverter A charging at 3 kW while 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. **Nothing existing can see it, and that is structural**: every guard here consumes the *net* figure. SAFETY-02 gates on `abs(grid_w) > 300` and that gate never opens at a netted zero; the peak guard and the capacity tariff bill grid exchange. Circulating power is exactly the component of per-inverter power the sum destroys. ## The sensor — and why it is MEASURED `circulating_w = Σ|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. ⚠️ **From `Inverter.batt`, 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 could only confirm the allocator agrees with itself — the third failure mode `MUTATION.md` names. What this catches is a unit ignoring or lagging its share, a stale share still executed, a maintenance/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 record impeccable throughout. `batt` became per-instance in DEV-02. **AC 2, verified by making one unit ignore its share** on the TEST-19 rig: ``` 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 ``` **AC 1, the card's own scenario** on the same rig — A −3000 W, B +3000 W, zero house load: ``` 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 X≠Y check is built the way `MUTATION.md` requires: the precondition that the record and the measurement **actually differ** is its own check, and printed. ## What "acted on" means > `Claim.set("circulation", P_SAFETY, 0.0)` on the **SITE** actuator — every > inverter to 0 W, latched until an operator starts control. **The objection is correct**: HW-01 established a wedged inverter cannot be commanded at all. **The answer**: 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**, needing 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, said plainly:** it does not stop the wedged inverter. Nothing this add-on can send will. 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 — *a unit ignoring its share* and *a sibling faithfully executing a share computed to sum with it* read **identically**, so filing per-unit would invent an attribution and could park the only unit still listening. Same argument for the flat store latch. ## AC 3 — the window is from the rig, not a round number **924 closed-loop runs in which nothing is faulty**, over both families of legitimate opposition (7 reversals × 5 write staggers; 7 flap periods × 4 amplitudes), swept across the **whole** range the schema accepts for `gain` and `slew_w`. Worst **continuous** legitimate opposition: | threshold | 100 W | 250 W | 300 W | **400 W** | 700 W | 1700 W | |---|---:|---:|---:|---:|---:|---:| | worst continuous | **505.0 s** | 40.0 s | 10.0 s | **5.0 s** | 5.0 s | 0.0 s | **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. That 5.0 s is one control cycle: writes are serialised, so on a reversing cycle B's number lands up to a cycle after A's and the two genuinely oppose between their zero crossings. **Nothing is wrong, and the guard does not fire — 0 of 924 runs trip.** * `circulation_w: 700` — 1.75× the knee. The margin is bought for one named reason: **inverter B does not exist yet**; its rig ramp and lag are A's, copied, `measured=False`. SPIKE-04 is the revisit trigger. * `circulation_hold_s: 75` — 15× the worst legitimate episode, 2.5× the firmware's own `LINK_STALE_S` veto, 12× inside the quarter. Deliberately not 60 or 120, and a check asserts it is none of the values a guess lands on. ## AC 5 — N=1 unchanged `|B| − |B| == 0` for every reading including one nobody took. No claim filed, arbitration byte-identical, status page says *n/a* rather than claiming to watch something that cannot happen. ## SAFETY-34 — the alarm survives an HA restart `persistent_notification` is **in memory**, which is how the 2026-09-02 outage went unseen for 6 h 25 m with the detector correct throughout. The latch is persisted to `/data`; 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. ## AC 4 — verified by breaking it **18 mutations, all RED on named checks.** Each on a freshly copied tree with no `__pycache__`, mounted `:ro` — the structural immunity `MUTATION.md` records. Byte deltas on the record; **three are `+0 B`**, the risky delta, and are marked. * **M10** — the sensor rewritten to read `self.allocation.shares` — takes **AC 2 red**. That is the card. * **M15/M16/M17** (`+0 B`): scope flipped to `SCOPE_UNIT`, schema floor 400→100, default 700→500. All red. * **M4** is an **equivalent mutant, argued not claimed**: the `max(0.0, …)` guard is unreachable — same-sign rosters accumulate identical magnitudes in identical order, and 700 000 random rosters produced no negative. Recorded so a later sweep does not report a gap that is not one. * **M19 was a genuine SURVIVOR, found and closed**: nothing graded what `publish()` does with a missing reading, so turning *"I don't know"* into *"0 W"* on the surface an operator watches went unnoticed — the sensor's own `None` rule defeated one layer out. Check added, mutation re-run, red. Full ledger: `DECISIONS/SAFETY-31-circulating-power.md`. ## Tripwires updated deliberately `arbiter.CLAIM_SCOPE` gains its **ninth** source, so `test_peakguard.py` 10f/10g and `test_arbiter.py`'s scope table went red **on the line the append was written** — the tripwires working. Updated with the argument their own comments demand, not by editing the number. The dynamic sweeps are unaffected: `circulation` appears in **no** cycle of them, because nothing in those fixtures circulates. **Full 19-file suite green.** ## What this does NOT close * Inverter B's ramp and lag are **placeholders**. No figure here is a measurement of inverter B. * A wedged inverter still cannot be stopped by this add-on. * A loop below ~350 W per unit is not reported (deliberate). * **New finding, out of scope:** with a unit wedged, the loop's anti-windup freeze fires and leaves a standing grid error (−3750 W on the rig) that nothing alarms on. Real, observed while producing these numbers, and it wants a card of its own. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-04 01:57:16 +02:00
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
admin merged commit b30797e1f9 into release/1.0 2026-09-04 02:40:51 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#44