SAFETY-31: measure circulating power, which nets to zero at the meter
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:
2026-09-04 01:56:23 +02:00
co-authored by Claude Opus 5
parent b6f41f736c
commit 7ef3d23245
12 changed files with 1813 additions and 33 deletions
+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