DEV-02: per-instance inverter objects, so nothing is graded against a sibling
checks / unit (pull_request) Successful in 4m15s

Every piece of per-device state in the Controller was a scalar, and several of
them are safety inputs. app/inverter.py introduces an `Inverter` that owns its
entities, its rating, its device range, its SignWatchdog, its last_write and
its last_beat, under store keys namespaced by unit name. The Controller holds a
list of them and keeps only what is genuinely site-level: the P1 reading, the
site integrator, and the tariff quarter.

WHAT THE SCALARS WERE SILENTLY ASSERTING

  dev_min/dev_max   one pair, read from one setpoint_entity. A second
                    inverter's rating was NEVER READ AT ALL, so its clamp, its
                    freeze rail and the peak guard's achievable_w were the
                    first one's numbers.
  soc               one reading into the maintenance FSM, SAFETY-24's pack
                    floor and arbitrage's bounds. An average lets the emptier
                    pack be driven below its own floor.
  sign_wd           ONE SignWatchdog, whose own docstring says "one instance
                    per actuator", latched under FLAT store keys - so a trip
                    caused by B latched A across restarts and one operator
                    press cleared both.
  last_beat         one deadline and one serialised hass.call(), so a hung
                    write to A ate B's 30 s layer-1 budget.

actual_w BECAME SITE LEVEL WHILE THE READINGS BECAME PER-INSTANCE, and those
are opposite directions on purpose. compute()'s anti-windup test is
`abs(prev_w - actual_w) > saturation_w` with prev_w the SITE target, so feeding
it one inverter's power compares a site number against a unit number, trips the
saturation freeze permanently and leaves a standing grid error. Measured on
admin/goodwe's two-master rig (tools/alloc_sim.py --splits, 2 kW load, 40
cycles), worst |delta| between splits at the meter:

    gain    one unit's power      the site sum
    0.2           510 W                0 W
    0.4           250 W                0 W
    0.6            50 W                0 W

That was ALLOC-01's disclosed finding; test_alloc.py part 8 kept its stimulus
and now grades the other row.

THE STORE MIGRATION IS GRADED, NOT MERELY PERFORMED. sign_tripped/sign_reason
move to unit/<name>/... once, verified by READING BACK what was written, and
the version marker is set only when nothing that carried state was lost. The
flat keys stay in the file so a rollback still finds its latch. And __init__
compares the pre-migration flat latch against what unit A actually came up
with: if the latch did not survive it is RE-ARMED from the legacy value and
shouted about. A latch that silently fails to migrate is a disarmed safety
guard on an install whose state file says it is protected - which is strictly
worse than never persisting it, because now there is evidence saying it is
fine. test_inverter.py part 2 breaks the migration and watches the guard fire.

HEARTBEATS ARE INDEPENDENT AND THE BUDGET IS DERIVED. Each inverter owns its
deadline, the refreshes go out concurrently, and each write is bounded by
WRITE_BUDGET_S = 4 s. The inequality is graded rather than asserted: 4 +
heartbeat_s's schema maximum of 25 < 30, validate.FIRMWARE_INGEST_VETO_S.
Verified by injecting a transport that accepts and never answers.

live_units() ANSWERS HONESTLY. A unit whose every write has been rejected for a
whole layer-1 window is not commandable; the allocator drops it, the survivors
carry its share, the action log names it, and the dropped unit is commanded
0 W rather than left standing on its last share. A roster entry with NO
setpoint_entity is deliberately not handled there - that is a configuration
that cannot be delivered, and it reaches main.py's refusal branch, which is how
that branch keeps a live trigger after this card removed its old one.

AT N=1 NOTHING MOVES. The removed scalars survive as read/write properties of
the sole inverter, so every existing replay runs unmodified - and they REFUSE
at N>=2 rather than answering for the first inverter, which is the tripwire on
a consumer that was never made per-instance.

EVIDENCE. 19 files green. test_inverter.py is new (82 checks). Eighteen
mutations were applied to the shipped modules on a purged tree via
tools/mutation_run.sh and each was observed RED. Two of them SURVIVED first
time and the checks were changed rather than the table: the serialised dispatch
and the shared last_beat both passed while part 6 gathered its own writes and
computed its own `due` list. send_heartbeats() was extracted from run_control()
so the shipped dispatch is reachable by a check at all.

INVERTER B STILL DOES NOT EXIST. build_roster() returns exactly one inverter
from the flat options and is the one function CFG-04 changes; every two-unit
figure here is architecture, and the second rating in the checks is marked a
hypothesis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-02 14:50:43 +02:00
co-authored by Claude Opus 5
parent 3030f72b9e
commit e306c74cb8
10 changed files with 3027 additions and 325 deletions
+58 -19
View File
@@ -672,29 +672,68 @@ check("...and the broadcast export overshoot at gain 0.6 - the half that has no
feedback="unit")[1:]) <= -400.0
and min(g for _t, g in close_loop(0.6, 0.5)[1:]) >= 0.0)
# ⚠️ THE ONE THAT DOES NOT HOLD, AND IT IS REPORTED RATHER THAN HIDDEN.
# ⚠️ THE ONE THAT DID NOT HOLD, AND DEV-02 IS WHAT CLOSED IT. This block was
# written as a DISCLOSURE - "the allocator is not the cause, and it is DEV-02's
# to close" - and it is kept, with its stimulus intact, because the defect it
# describes is exactly the thing a regression would restore. What has changed is
# only which of the two rows the SHIPPED add-on is on.
#
# THE DEFECT. There was one `batt_entity`, so at a roster of two `actual_w` was
# ONE unit's power used as a site measurement - the SENSING half of the same
# N=1 identity ALLOC-01 removed from the COMMANDING half. `compute()`'s
# anti-windup test is `abs(prev_w - actual_w) > saturation_w` with `prev_w` the
# SITE target, so that comparison put a site number against a unit number,
# tripped the saturation freeze permanently and left a standing grid error.
#
# MEASURED, on admin/goodwe's own two-master rig rather than only here
# (`tools/alloc_sim.py --splits --feedback unit`, 2 kW load, 40 cycles):
#
# gain 0.2 worst |delta| between splits 510 W
# gain 0.4 250 W
# gain 0.6 50 W
# gain 0.8 and above 0 W
#
# and with `--feedback site` - the counterfactual, which is what DEV-02 makes
# the shipped sensing - every split is bit-identical at every one of those
# gains. Both rows are still run below, because the comparison is the evidence.
_unit_fb = {s: close_loop(0.6, s, feedback="unit")
== close_loop(0.6, 0.5, feedback="unit") for s in _splits}
print(f" with the loop fed ONE inverter's power instead of the site's, at "
f"gain 0.6: " + ", ".join(f"{int(s*100)}/{int((1-s)*100)}="
f"{'same' if v else 'DIFFERS'}"
for s, v in _unit_fb.items()))
check("⚠️ SPLIT-INVARIANCE IS A PROPERTY OF THE LOOP'S INPUTS, NOT ONLY OF THE "
"ALLOCATOR, AND THE SHIPPED ADD-ON BREAKS IT FROM THE OTHER END. There is "
"ONE `inverter_power` sensor, so at a roster of two `actual_w` is ONE "
"unit's power used as a site measurement - the SENSING half of the same "
"N=1 identity this card removes from the COMMANDING half. Feed the loop "
"that and the aggregate trace becomes a function of the split. The "
"allocator is not the cause: its shares sum to S in every one of these "
"runs. Measured on the real rig too (tools/alloc_sim.py --splits "
"--gain 0.6), and it is DEV-02's to close",
not all(_unit_fb.values()))
check("...and the counterfactual is what says the sensor is the cause rather "
"than the allocator: change that ONE input to the site total and every "
"split is bit-identical again, at every gain",
print(f" with the loop fed ONE inverter's power - the PRE-DEV-02 sensing - "
f"at gain 0.6: " + ", ".join(f"{int(s*100)}/{int((1-s)*100)}="
f"{'same' if v else 'DIFFERS'}"
for s, v in _unit_fb.items()))
_worst_unit = max(
max(abs(a[1] - b[1]) for a, b in zip(close_loop(g, s, feedback="unit"),
close_loop(g, 0.5, feedback="unit")))
for g in (0.2, 0.4, 0.6) for s in _splits)
print(f" worst split-dependence under that sensing, gains 0.2-0.6: "
f"{_worst_unit:.0f} W at the meter")
check("⚠️ THE DEFECT IS STILL REPRODUCIBLE, WHICH IS WHAT MAKES THE FIX BELOW "
"MEAN SOMETHING. Fed ONE inverter's power the aggregate trace at the "
"meter is a function of the split, by hundreds of watts below gain 1.0. "
"The allocator is not the cause - its shares sum to S in every one of "
"these runs - and a check that had DELETED this row when the sensing was "
"fixed would leave the next reader unable to tell a working loop from an "
"untested one",
not all(_unit_fb.values()) and _worst_unit > 100.0)
check("⚠️ AND DEV-02 PUT THE SHIPPED ADD-ON ON THE OTHER ROW: `actual_w` is now "
"the SITE SUM of the per-instance battery readings "
"(`Controller.site_batt_w`, pinned at main.py's call site by "
"test_inverter.py part 5), and every split is bit-identical again at "
"every gain. This is the line that goes red if anyone hands the loop one "
"unit's power again - which is the tempting shape, because it is what a "
"single `batt_entity` gives you",
all(close_loop(g, s, feedback="site") == close_loop(g, 0.5,
feedback="site")
for g in (0.2, 0.4, 0.6, 1.0) for s in _splits))
for g in (0.05, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0) for s in _splits))
check("...and the fix is not vacuous in the other direction either: the SHARES "
"still differ between splits under site feedback, so 'bit-identical at "
"the meter' is a statement about the aggregate and not about an allocator "
"that stopped allocating",
close_loop(0.6, 0.9, feedback="site")
== close_loop(0.6, 0.5, feedback="site")
and plan(2000.0, TWO, weights={A: 0.9, Bee: 0.1}).shares
!= plan(2000.0, TWO, weights={A: 0.5, Bee: 0.5}).shares)
# ==============================================================================