checks / unit (pull_request) Successful in 4m6s
ARB-03 left `allocate()` as a documented seam that passed through at N<=1 and
DECLINED at N>=2, with the reasoning recorded at the line: the obvious
placeholder - hand the site target to each inverter - IS the ARCH-01 fault, and
"a placeholder that cannot be told apart from the real thing by any guard we
have is worse than no placeholder". This fills the seam.
WHY A BROADCAST IS A FAULT AND NOT AN INEFFICIENCY. The control primitive is
meter emulation: the master reports a FABRICATED grid reading and the inverter
regulates itself to it, so one number means "the battery moves X W" to this
add-on and "the grid is at X W" to the inverter - an identity that holds only at
N=1. Hand two inverters the same target and each supplies it IN FULL, so the
loop gain at the meter becomes N times the per-unit gain. Reproduced on
admin/goodwe's two-master rig against this tree: -400 W export overshoot at
gain 0.6, and at gain 1.0 - which config.yaml accepts as its documented maximum
- a permanent 4000 W peak-to-peak limit cycle, commands alternating
1000/2000/1000/0 W, with NOT ONE GUARD FIRING. The sign watchdog is evaluated on
every cycle and never trips, because the disagreement flips on alternate ticks
and never holds its 180 s window.
WHAT WAS BUILT. `app/allocator.py`, host-side and strictly above the driver
seam. It consumes intervals and a number and nothing else - no claims, no
priorities, and no measurement, because an allocator that read P1 would be a
SECOND integrator on the site measurement.
* ENVELOPE CLAMP, from ARB-03 stage 1's `UnitBounds`, read PER TICK. `plan()`
is a pure function and the module holds no state, so there is nowhere for a
construction-time envelope to be cached.
* WATER-FILL by weight, with the surplus of a full unit moving to the others.
Weights express PREFERENCE, envelopes express PERMISSION: a zero weight is
honoured while another unit has headroom and overridden when it does not,
because the SUM is the safety property and stranding capacity behind a
comfort choice manufactures a standing error at the meter.
* LARGEST-REMAINDER ROUNDING. The target is rounded ONCE, at the top; every
sum below that line is integer arithmetic, so it cannot drift. Measured: a
per-unit round() drifts -125 W over 50 000 ticks at three inverters.
* THE ALLOCATION RECORD, with `unmet_w`, `dropped` and a per-unit reason.
`ok` is one boolean for SAFETY-28 to gate on, and `unmet_w` is deliberately
NOT fused into it - a saturated inverter must not park the whole site.
THE STABILITY PROPERTY IS STRUCTURAL. With sum(a_i) = 1 the gain at the meter is
sum(a_i * g_i), a convex combination of the per-unit gains, so it lies in
[min g_i, max g_i] FOR ANY SPLIT. That is what makes the weights a comfort
choice rather than a tuning parameter, and it is why a bad split cannot
destabilise the site. Graded directly, and measured end to end: 90/10, 50/50 and
100/0 give a bit-identical aggregate trace at the meter, and a gain sweep to 1.0
at every split has a flat tail at all 84 combinations.
THE TWO REFUSALS main.py NOW MAKES, AND WHY THERE ARE TWO. `allocated=False` no
longer means "no allocator"; it means no safe split EXISTS - a mis-routed claim,
an empty envelope set, or a split that could only be made by commanding one pack
to charge while another discharges. Separately, and this guard is the price of
making allocate() succeed rather than something inherited, main.py refuses to
act on a valid split it cannot DELIVER: there is one `setpoint_entity`, so
writing the site target to the one inverter it can reach would over-command that
inverter by exactly the other's share. Commanding a partial site is a worse
instruction than commanding nothing. DEV-02 gives it the second writer.
scope_violations() IS NOW CONSUMED, AND AS A REFUSAL. ARB-03 left it logging
without changing the resolution. A mis-routed claim means the number about to be
divided may itself be wrong, and dividing a number that may be wrong is worse
than not dividing it - the same answer clause 3 already gives a contradiction.
It is NOT promoted into an envelope: a unit-scoped claim that landed on the site
names no unit, so promoting it would mean guessing one, or applying one
inverter's rating to a sibling that does not have it.
AT N<=1 IT IS A PASS-THROUGH, BYTE-IDENTICAL: the share is the site target, as a
float, unrounded and unclamped. All 17 existing suites replay unedited.
test_peakguard.py part 10h and test_arbiter.py part 7 were RE-AIMED, not
deleted. PR #40's reviewer proved that refusal branch was unreachable and
untested once; removing its trigger without giving it a new one would have
rebuilt the hole. Part 10h now drives three real cycles - a deliverable split, a
dead sibling, and every master stale - and grades all three.
KNOWN AND NOT CLOSED HERE: at a roster of two the loop's `actual_w` is one
inverter's power used as a site measurement, because the add-on binds a single
`inverter_power` sensor. That is the SENSING half of the same N=1 identity this
card removes from the COMMANDING half, and it makes the aggregate trace
split-dependent below gain 1.0. Measured with its counterfactual in test_alloc.py
part 8 and tools/alloc_sim.py --splits. It belongs to DEV-02.
Verified: all 18 suites green in the CI image. 16 mutations run through
tools/mutation_run.sh on a purged tree; 13 red, and the 3 survivors are recorded
in test_alloc.py part 12 with the reason each cannot be caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
279 lines
12 KiB
Python
279 lines
12 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
|
|
|
|
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` feeds `compute()`
|
|
ONE inverter's power as `actual_w`, and its docstring says why: the shipped
|
|
add-on binds a single `inverter_power` sensor, so an operator who wires up a
|
|
second inverter has a loop that sees unit A only, and a rig that fed the sum
|
|
would be KINDER than the deployment. That is correct for grading the
|
|
broadcast fault and it is exactly the variable this experiment has to move,
|
|
because it is the one input to the loop that is a function of the SPLIT.
|
|
|
|
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. It is not evidence about the shipped add-on; it is the
|
|
counterfactual that says WHICH of the two candidate causes explains the
|
|
split-dependence that `--splits` measures at gain 0.6. The add-on does not
|
|
have this sensor yet. DEV-02 is where it would come from.
|
|
"""
|
|
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="unit",
|
|
help="what the loop is fed as actual_w: one "
|
|
"inverter's power (the shipped add-on) or "
|
|
"the site total (the counterfactual)")
|
|
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())
|