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
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""OPT-11: with battery modes off, is every command byte-identical to before?
python3 tools/opt11_identity.py # prints one digest per scenario
Drives the REAL `Controller` (read_inputs + cycle: the freshness gate, the
arbiter, the allocator, OPT-09's pack claim, SAFETY-73's refusal claims) in
simulated time, and hashes, per cycle, everything a command depends on: the
site target, each inverter's written value, the loop's reason, integrator and
saturation count, and the allocation record. It uses only the Controller API
that release/1.0 a5dddad already had, so the SAME file runs on the old tree and
the new one. test_modes.py part 1 holds the new tree to the digests this file
printed on a5dddad (they are written there, with that provenance).
Scenarios: N=1 and N=2, a house that steps, a surplus of solar, a battery
reaching its cut-out level and, at N=2, a battery that refuses (SAFETY-73) and
is probed.
"""
import asyncio
import hashlib
import os
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
TICK = 5.0
BASE = datetime(2026, 9, 20, 11, 0, tzinfo=timezone.utc)
class _Store:
def __init__(self):
from app.store import DEFAULTS
self.data = dict(DEFAULTS)
def set(self, k, v):
self.data[k] = v
def get_time(self, k):
v = self.data.get(k)
return datetime.fromisoformat(v) if v else None
def set_time(self, k, when):
self.data[k] = when.isoformat() if when else None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
def house(t):
"""A house that steps, then a solar surplus, then an evening load."""
if t < 300:
return 800.0
if t < 900:
return 3200.0
if t < 1500:
return -2600.0 # more solar than the house uses
if t < 2100:
return 1400.0
return 4200.0
class _Hass:
"""One meter, `units` batteries that follow their command unless `cut`."""
def __init__(self, units, soc, cut, load=house):
self.units, self.soc, self.cut, self.load = units, soc, cut, load
self.cmd = {u: 0.0 for u in units}
self.t = 0.0
def power(self, u):
c = self.cmd[u]
if self.cut(u, self.t) and c >= -4.0:
return -4.0
return float(round(c))
def grid(self):
return self.load(self.t) - sum(self.power(u) for u in self.units)
async def limits(self, e):
return (-5000.0, 5000.0, 1.0)
async def call(self, *a):
return True
async def number(self, e, invert=False):
if e == "sensor.grid":
return self.grid()
u = e[-1]
if e.startswith("sensor.soc"):
return self.soc(u, self.t)
if e.startswith("sensor.batt"):
return self.power(u)
return None
async def set_number(self, e, v):
self.cmd[e[-1]] = float(v)
return True
def _options(n, extra):
common = {"meter_entity": "sensor.grid", "meter_source": "off",
"gain": 0.6, "slew_w": 400, "target_grid_w": -10,
"deadband_w": 15, "step_w": 10, "heartbeat_s": 10,
"stale_input_s": 15, "auto_start": True}
if n == 1:
common.update({"setpoint_entity": "number.sp_a",
"soc_entity": "sensor.soc_a",
"batt_entity": "sensor.batt_a", "max_w": 5000,
"bms_soc_floor": 10})
else:
common["inverters"] = [
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
"max_w": 2500, "bms_soc_floor": 10} for u in "ab"[:n]]
common.update(extra or {})
return common
def _soc(u, t):
# A empties toward its cut-out level over the run; B stays half full.
return max(8.0, 60.0 - t / 40.0) if u == "a" else 50.0
def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
soc=_soc, cut=None, before=None):
"""Cycle a real Controller; returns (rows, controller). `before(c, t)`
runs before each cycle, `on_cycle(c, t)` after it."""
units = tuple("ab"[:n])
if cut is None:
def cut(u, t):
return n > 1 and u == "a" and 1600.0 <= t < 2300.0
async def go():
hass = _Hass(units, soc, cut, load)
c = Controller(_options(n, extra), hass, store or _Store(), _Pub(),
stop=asyncio.Event())
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt = m.datetime
clock = [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
rows = []
try:
t = 0.0
while t < dur:
hass.t = clock[0] = t
if before is not None:
r = before(c, t)
if asyncio.iscoroutine(r):
await r
await c.read_inputs(now_mono=t)
await c.cycle()
alloc = getattr(c, "allocation", None)
rows.append((t, c.target, tuple(sorted(hass.cmd.items())),
c.loop_reason, round(float(c.i_w), 6),
c.sat_count, c.reason,
alloc.explain() if alloc is not None else None))
if on_cycle is not None:
on_cycle(c, t)
t += TICK
finally:
m.datetime = real_dt
return rows, c
return asyncio.run(go())
def digest(rows) -> str:
return hashlib.sha256(repr(rows).encode()).hexdigest()
SCENARIOS = {
"n1": dict(n=1),
"n2": dict(n=2),
# The option present and false: what an install that saved the new
# option without turning it on runs. The old tree ignores the key.
"n1_off": dict(n=1, extra={"battery_modes_enabled": False}),
"n2_off": dict(n=2, extra={"battery_modes_enabled": False}),
}
def digests() -> dict:
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
if __name__ == "__main__":
if "--summary" in sys.argv:
for k, v in SCENARIOS.items():
rows = run(**v)[0]
print(f"{k}: {len(rows)} cycles, loop reasons "
f"{sorted({r[3] for r in rows})}, arbiter "
f"{sorted({r[6].split(' ->')[0] for r in rows})}")
for k, v in digests().items():
print(f"{k} {v}")