#!/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 python3 tools/alloc_sim.py --spill # OPT-11: surplus solar, by mode 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 spill(sitesim, control, arbiter, allocator, packmode, gain=0.6, cycles=30, load_w=1000.0, pv_w=9000.0): """OPT-11 P1: surplus solar spills from Solar first to Leftover solar. The rig's two plants and the add-on's own `compute()`, `resolve()`, `loop_bound()` and `packmode.charge_weights()`, with the claims main.py files for this state: each unit's rating, each unit's mode, the loop, and OPT-09's `pack` limit (`lo = 0`) once battery A is full. A is Solar first and B Leftover solar. Three phases of `cycles` each: A has room; A is full; then B is switched off. The loop is fed the site total (DEV-02). Returns (rows, verdicts). """ from plantsim import INVERTER_A, INVERTER_B, Inverter, Site names = ("inverter-a", "inverter-b") specs = [("A", INVERTER_A), ("B", INVERTER_B)] invs = [Inverter.from_spec(n, dict(sp, **sitesim.INSTANT)) for n, sp in specs] site = Site(invs, load=load_w, pv=pv_w) tun = control.Tuning(gain=gain, max_w=sum(sp["rating_w"] for _n, sp in specs), slew_w=sitesim.EXPERIMENT_SLEW_W) C = arbiter.Claim prev, i_w, sat = 0.0, None, 0 rows = [] phases = (("A has room", False, True), ("A full", True, True), ("A full, B off", True, False)) for label, a_full, b_on in phases: modes = {names[0]: packmode.PackMode(True, packmode.SOLAR_FIRST), names[1]: packmode.PackMode(b_on, packmode.LEFTOVER_SOLAR)} for _c in range(cycles): claims = [] for u, (_n, sp) in zip(names, specs): r = sp["rating_w"] claims.append(C.limit("supervised", arbiter.P_SAFETY, -r, r, actuator=u)) lo, hi = packmode.mode_range(modes[u], r) claims.append(C.limit("mode", arbiter.P_MODE, lo, hi, actuator=u)) if a_full: claims.append(C.limit("pack", arbiter.P_SAFETY, lo=0.0, actuator=names[0])) bound = allocator.loop_bound( {u: arbiter.pre_resolve([c for c in claims if c.actuator == u]) for u in names}) d = control.compute(prev, site.grid, site.battery, tun, sat, i_w, bound=bound) prev, i_w, sat = d.target_w, d.i_w, d.sat_count claims.append(C.set("loop", arbiter.P_LOOP, d.target_w)) res = arbiter.resolve( claims, units=names, weights=lambda t: packmode.charge_weights(None, modes, t)) for inv, u in zip(invs, names): inv.command(float(res.shares[u])) site.settle(sitesim.CYCLE_S, dt=0.05) rows.append(dict(phase=label, target=d.target_w, grid=site.grid, a=res.shares[names[0]], b=res.shares[names[1]], unmet=res.allocation.unmet_w, frozen=d.frozen, reason=d.reason, bound=bound)) return rows def spill_report(rows, cycles): print("=== OPT-11 P1: surplus solar, A Solar first, B Leftover solar ===") ok = True for label in dict.fromkeys(r["phase"] for r in rows): ph = [r for r in rows if r["phase"] == label] last = ph[-1] frozen = sum(r["frozen"] for r in ph) print(f" {label:15} A {last['a']:+6.0f} W B {last['b']:+6.0f} W " f"grid {last['grid']:+7.0f} W loop bound " f"[{last['bound'][0]:.0f}, {last['bound'][1]:.0f}] " f"unmet {last['unmet']} W frozen cycles {frozen} " f"({last['reason']})") ok &= frozen == 0 and all(r["a"] <= 0 and r["b"] <= 0 for r in ph) if label == "A has room": ok &= last["a"] == -5000 and last["b"] < 0 elif label == "A full": ok &= last["a"] == 0 and last["b"] == -5000 else: ok &= last["a"] == 0 and last["b"] == 0 and last["unmet"] == 0 print(f" -> spill {'HOLDS' if ok else 'FAILS'}: A fills first, the rest " f"spills to B, a full A and an Off B send the surplus to the grid, " f"and the loop never freezes on watts no battery may take") return ok 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("--spill", 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 or a.spill): 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) print() if a.spill or a.all: packmode = sitesim.load_addon("app.packmode", "alloc_sim") held &= spill_report(spill(sitesim, control, arbiter, allocator, packmode), 30) return 0 if held else 1 if __name__ == "__main__": sys.exit(main())