Files
glenn schrooyenandClaude Opus 5 e306c74cb8
checks / unit (pull_request) Successful in 4m15s
DEV-02: per-instance inverter objects, so nothing is graded against a sibling
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
2026-09-02 14:50:43 +02:00

293 lines
13 KiB
Python

#!/usr/bin/env python3
"""ALLOC-01's acceptance evidence, on admin/goodwe's OWN two-master rig.
python3 tools/alloc_sim.py --table # broadcast vs allocator, side by side
python3 tools/alloc_sim.py --splits # AC 2: split-invariance at the meter
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
python3 tools/alloc_sim.py --all
# DEV-02: the split-invariance finding ALLOC-01's review handed on, and the
# sensing change that closed it. The two rows are the evidence.
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback unit # 250 W apart
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback site # identical
WHY THIS IS A SCRIPT AND NOT A `test_*.py`
==========================================
It needs admin/goodwe checked out beside this repository, and the add-on's CI
job has no credentials for that repository - it is private and the job's token
is minted for this one. A `test_*.py` here is globbed and RUN by
.gitea/workflows/checks.yml, so a file that needs the sibling repo would either
fail the gate or - worse, and this is what test_freeze.py does today - pass
SILENTLY having checked nothing. So the rig evidence lives here, is quoted in
the pull request, and test_alloc.py grades the same properties in CI against a
plant it models itself and says it models.
WHAT IS REAL IN HERE
====================
Everything except the allocator's inputs. `sitesim.run_site()` is used
UNMODIFIED: the add-on's own `compute()` closes the loop, the firmware's own
encoder puts each command on each unit's wire, `plantsim.Plant` is the battery
model, and `watchdog.SignWatchdog` is the shipped detector. The ONLY thing this
file changes is `sitesim.allocate`, which it wraps so that a new mode name
routes to `app.arbiter.allocate()` - the shipped allocator, imported, never
transcribed - and delegates every other mode to the original. That wrapper is
the whole diff between the RED run and the GREEN run, which is what makes the
comparison worth anything.
⚠️ NOTHING IN admin/goodwe IS MODIFIED. `sim/` triggers a workflow that takes
the physical rig's lock; this file reads that repository and writes nothing to
it.
⚠️ INVERTER B'S PARAMETERS ARE PLACEHOLDERS. `plantsim.INVERTER_B` carries
`measured=False` - it is A's numbers copied, because inverter B does not exist
yet (SPIKE-04 measures it). Every verdict below that involves B is a verdict
about the ARCHITECTURE, not about that inverter, and the runs say so.
"""
import argparse
import math
import os
import pathlib
import sys
HERE = pathlib.Path(__file__).resolve().parent
ADDON = HERE.parent.parent # the goodwe-addon checkout root
def find_sim():
"""admin/goodwe's `sim/` directory, or None - and the caller says which.
$GOODWE_SIM first, the same override test_freeze.py takes, then the sibling
checkouts that actually occur. A missing sibling is not a failure of the
allocator and must never be reported as one.
"""
cands = []
if os.environ.get("GOODWE_SIM"):
cands.append(pathlib.Path(os.environ["GOODWE_SIM"]))
for up in (ADDON.parent, ADDON.parent.parent):
cands += [up / "goodwe" / "sim", up / "goodwe-session" / "goodwe" / "sim"]
for c in cands:
if (c / "sitesim.py").exists():
return c
return None
def load(sim):
"""Import the rig, pointed at THIS checkout, and graft our allocator in."""
sys.path[:0] = [str(sim)]
# inject.py resolves the add-on repo, and refuses to guess between two
# checkouts. Naming it removes the guess: the tree under test is the tree
# this file ships in, whatever else is lying around the disk.
os.environ.setdefault("GOODWE_ADDON_REPO", str(ADDON))
import inject, sitesim # noqa: E402
arbiter = sitesim.load_addon("app.arbiter", "alloc_sim")
allocator = sitesim.load_addon("app.allocator", "alloc_sim")
original = sitesim.allocate
def patched(target_w, n, mode):
if not mode.startswith("alloc01"):
return original(target_w, n, mode)
# "alloc01" or "alloc01:90/10" - the weights, which AC 2 says must not
# change the aggregate trace at the meter.
names = [f"inverter-{c}" for c in "ab"[:n]]
parts = mode.split(":", 1)
if len(parts) == 2:
ws = [float(x) for x in parts[1].split("/")]
else:
ws = [1.0] * n
bounds = {u: arbiter.UnitBounds(lo=-r, hi=r)
for u, r in zip(names, patched.ratings)}
shares, ok = arbiter.allocate(
target_w, bounds, weights=dict(zip(names, ws)))
if not ok:
# A refusal on this rig would be a bug in the experiment, not a
# finding: nothing here is stale, mis-routed or pinned. Fail loud.
raise SystemExit(f"alloc_sim: the allocator REFUSED at {target_w} W "
"- no scenario below should reach a refusal")
return [float(shares[u]) for u in names]
# The envelope handed to the allocator each tick: the rating each MODELLED
# inverter actually carries, read off plantsim rather than typed here, so a
# change to INVERTER_A - or a --b-rating override - moves the envelope with
# it instead of leaving this file quietly wrong. ⚠️ INVERTER_B's IS A
# PLACEHOLDER; nothing here has measured it.
from plantsim import INVERTER_A, INVERTER_B
patched.ratings = [INVERTER_A["rating_w"], INVERTER_B["rating_w"]]
sitesim.allocate = patched
return inject, sitesim, arbiter, allocator
def run(sitesim, control, watchdog, mode, gain, **kw):
return sitesim.run_site(control, watchdog, n=2, gain=gain, mode=mode,
label=f"N=2 gain {gain} {mode}", **kw)
def run_site_feedback(sitesim, control, mode, gain, load_w=2000.0, cycles=40,
cycle_s=None, dt=0.05):
"""`sitesim.run_site()` WITH EXACTLY ONE LINE CHANGED, and it is named here.
⚠️ THIS IS A COPY AND IT IS A COPY ON PURPOSE. `run_site` lives in
admin/goodwe, which this file reads and never writes, and it feeds
`compute()` ONE inverter's power as `actual_w` because that is what the
add-on gave it: a single `batt_entity`, so an operator who wired up a second
inverter had a loop that saw unit A only. A rig that fed the sum would have
been KINDER than the deployment.
So this differs from `run_site` in one expression - `site.battery`, the
site's total inverter power, instead of `inverters[0].plant.actual` - and in
nothing else.
⚠️ AND SINCE DEV-02 THAT ONE EXPRESSION IS THE SHIPPED ONE, WHICH TURNS THIS
FUNCTION FROM A COUNTERFACTUAL INTO THE MODEL. `Controller.site_batt_w` is
the SUM of the per-instance battery readings and is what main.py hands
`compute()`; `--feedback site` is therefore the add-on as it now stands, and
`--feedback unit` is the PRE-DEV-02 sensing, kept because the comparison is
the evidence. Measured here, 2 kW load, 40 cycles: fed one unit's power the
aggregate trace at the meter is split-dependent by 510 W at gain 0.2, 250 W
at 0.4 and 50 W at 0.6; fed the site total every split is bit-identical at
every gain. `run_site` still models the old sensing and must not be
"corrected" to the new one - the row it produces is what the fix is measured
against.
"""
from plantsim import INVERTER_A, INVERTER_B, Inverter, Site
cycle_s = sitesim.CYCLE_S if cycle_s is None else cycle_s
specs = [("A", INVERTER_A), ("B", INVERTER_B)]
inverters = [Inverter.from_spec(n, dict(sp, **sitesim.INSTANT))
for n, sp in specs]
site = Site(inverters, load=load_w)
loop = sitesim.Loop(control, gain)
samples = []
for c in range(cycles):
grid = site.grid
d = loop.cycle(grid, site.battery) # <- THE ONE CHANGED LINE
for inv, share in zip(inverters,
sitesim.allocate(d.target_w, len(inverters), mode)):
inv.command(share)
site.settle(cycle_s, dt=dt)
samples.append(dict(cycle=c, grid_before=grid, target=d.target_w,
grid=site.grid, batt=site.battery, reason=d.reason,
sat=d.sat_count, frozen=d.frozen, clamped=d.clamped,
i_clamped=d.i_clamped,
shares=[inv.plant.cmd for inv in inverters]))
return sitesim.Run(f"site-feedback gain {gain} {mode}", samples, inverters,
mode, gain, None)
SPLITS = ["alloc01:50/50", "alloc01:90/10", "alloc01:10/90",
"alloc01:99/1", "alloc01:1/0"]
def table(sitesim, control, watchdog, cycles, load_w):
print("=== AC 3 / the ARCH-01 experiment: broadcast vs allocation ===")
print(f"{'run':28} {'tail p2p':>10} {'worst export':>13} commands")
rows = []
for gain in (0.6, 1.0):
for mode in ["broadcast", "even_split"] + SPLITS:
r = run(sitesim, control, watchdog, mode, gain,
load_w=load_w, cycles=cycles)
rows.append((gain, mode, r))
print(f"gain {gain} {mode:20} {r.p2p():10.0f} "
f"{r.worst_export(1):13.0f} "
f"{[int(x) for x in r.targets(4)]}")
return rows
def splits(sitesim, control, watchdog, cycles, load_w, gain=1.0,
feedback="unit"):
"""AC 2. A 90/10 and a 50/50 split, and the AGGREGATE trace at the meter."""
print(f"=== AC 2 / split-invariance at gain {gain}, loop feedback "
f"= {feedback} ===")
base = None
ok = True
for mode in SPLITS:
r = (run(sitesim, control, watchdog, mode, gain,
load_w=load_w, cycles=cycles) if feedback == "unit"
else run_site_feedback(sitesim, control, mode, gain,
load_w=load_w, cycles=cycles))
trace = [round(g, 6) for g in r.grids()]
cmds = [round(t, 6) for t in r.targets()]
if base is None:
base, base_mode = trace, mode
print(f" {mode:16} reference: grid {[int(g) for g in trace[:6]]} ...")
else:
same = trace == base
ok &= same
print(f" {mode:16} {'IDENTICAL' if same else 'DIFFERS'} to "
f"{base_mode} worst |delta| "
f"{max(abs(a - b) for a, b in zip(trace, base)):.6f} W")
# And the shares are NOT the same - otherwise the invariance is vacuous.
s = r.samples[-1]["shares"]
print(f" last shares {[int(x) for x in s]}, "
f"sum {sum(s):.0f} vs site command {cmds[-1]:.0f}")
print(f" -> split-invariance {'HOLDS' if ok else 'FAILS'}")
return ok
def sweep(sitesim, control, watchdog, cycles, load_w):
"""AC 3. Gain swept to config.yaml's documented maximum, at every split."""
print("=== AC 3 / gain sweep to 1.0 at every split ===")
gains = [0.05, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0]
print(f"{'mode':20}" + "".join(f"{g:>9}" for g in gains))
worst = {}
for mode in ["broadcast"] + SPLITS:
line = f"{mode:20}"
for g in gains:
r = run(sitesim, control, watchdog, mode, g,
load_w=load_w, cycles=cycles)
p = r.p2p()
worst[mode] = max(worst.get(mode, 0.0), p)
line += f"{p:9.0f}"
print(line + f" worst {worst[mode]:.0f} W p2p")
print(" (tail peak-to-peak grid swing, W. A limit cycle is a tail that "
"does not go to zero.)")
return worst
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--table", action="store_true")
ap.add_argument("--splits", action="store_true")
ap.add_argument("--sweep", action="store_true")
ap.add_argument("--all", action="store_true")
ap.add_argument("--cycles", type=int, default=40)
ap.add_argument("--load", type=float, default=2000.0)
ap.add_argument("--gain", type=float, default=1.0)
ap.add_argument("--feedback", choices=("unit", "site"),
default="site",
help="what the loop is fed as actual_w: the site total "
"(the shipped add-on since DEV-02, the default here) "
"or one inverter's power (the PRE-DEV-02 sensing, "
"kept because the comparison is the evidence)")
a = ap.parse_args()
if not (a.table or a.splits or a.sweep):
a.all = True
sim = find_sim()
if sim is None:
print("alloc_sim: admin/goodwe is not checked out beside this "
"repository, so the two-master rig is unavailable. NOTHING has "
"been measured. Set $GOODWE_SIM to its sim/ directory.")
return 2
print(f"rig: {sim}")
inject, sitesim, arbiter, allocator = load(sim)
control = sitesim.load_addon("app.control", "alloc_sim")
watchdog = sitesim.load_addon("app.watchdog", "alloc_sim")
print(f"add-on under test: {os.environ['GOODWE_ADDON_REPO']}")
print("⚠️ inverter B's rating/ramp/lag are PLACEHOLDERS (plantsim."
"INVERTER_B, measured=False) - SPIKE-04 measures them\n")
if a.table or a.all:
table(sitesim, control, watchdog, a.cycles, a.load)
print()
if a.splits or a.all:
splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
a.feedback)
print()
if a.sweep or a.all:
sweep(sitesim, control, watchdog, a.cycles, a.load)
return 0
if __name__ == "__main__":
sys.exit(main())