#!/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())