A battery's mode is a soft unit limit (source `mode`, P_MODE 35) kept
apart from the hard limits in pre_resolve(). The allocator applies it
only while the site target is decided at or below 35 (new
Resolution.deciding_priority), so the peak floor releases it and a mode
`hi` never meets the site `lo`. At N=1 resolve() applies the same rule
to the site target after stage 2. A mode range without 0 W is dropped
as a software fault, so REFUSE_SIGN stays unreachable from a mode.
compute() gets the loop bound (sum over live units of hard limits with
modes applied), so the loop never asks for watts no battery may give
and the saturation freeze never parks on them. While charging, Leftover
solar weighs 0 so Solar first fills first. Store keys pack_use and
pack_mode per battery, set by two new /action values; Off stops and
skips maintenance with a log line. Minimal Batteries cards (EN/NL).
Off by default (battery_modes_enabled); off, commands are byte-identical
to a5dddad at N=1 and N=2 (test_modes.py part 1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
388 lines
18 KiB
Python
388 lines
18 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
|
|
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())
|