Files
goodwe-addon/goodwe_controller/tools/alloc_sim.py
T
glenn schrooyenandClaude Opus 5.5 f4f72211a9
checks / unit (pull_request) Successful in 8m31s
SAFETY-60/62: operator tools refuse input they cannot read
predeploy_check.py and rehearse_roster.py now assert a positive property of
their input (a JSON object, most of whose top-level keys are known option or
store keys) and refuse anything else with the shape named. The `ha apps info
--raw-json` wrapper, a truncated or list-wrapped state file and a wrapped
config no longer produce "CLEARED" / "NO FINDINGS" with exit 0.

Also: a clean --no-live-entities rehearsal exits 3, not 0; alloc_sim.py
exits 1 when split-invariance FAILS; decode_bus_capture.py takes several
paths and exits 1 on a file that is mostly not frames.

Graded by the new test_tool_inputs.py; DOCS.md says to pass .data.options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:27:50 +02:00

296 lines
14 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
Exit 0 only if every split-invariance run HOLDS; 1 if one FAILS; 2 if no rig.
# 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()
held = True
if a.splits or a.all:
held = 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 held else 1
if __name__ == "__main__":
sys.exit(main())