checks / unit (pull_request) Successful in 8m31s
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
296 lines
14 KiB
Python
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())
|