OPT-11 P1: per-battery mode limits, on/off, solar order
checks / unit (pull_request) Successful in 10m59s
infra-pin / fresh (pull_request) Successful in 1s

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>
This commit is contained in:
Claude dev agent
2026-09-28 18:51:29 +02:00
parent a5dddadf45
commit d69fd8fbfd
25 changed files with 1873 additions and 64 deletions
+93 -1
View File
@@ -5,6 +5,7 @@
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.
@@ -245,12 +246,98 @@ def sweep(sitesim, control, watchdog, cycles, load_w):
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)
@@ -261,7 +348,7 @@ def main():
"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):
if not (a.table or a.splits or a.sweep or a.spill):
a.all = True
sim = find_sim()
@@ -288,6 +375,11 @@ def main():
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