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
+158 -4
View File
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
print()
print("ARB-03 2. the split: twelve sources, each classified where it is BUILT")
print("ARB-03 2. the split: thirteen sources, each classified where it is BUILT")
# The classification is graded against what each claim is ABOUT, restated here
# so that the table in arbiter.py and the expectation in a test are not the same
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
@@ -193,16 +193,20 @@ UNIT_SOURCES = {
"refused": "THIS battery's own reading gave nothing against THIS unit's "
"own standing command, so that direction is closed on THIS "
"unit and its share goes to the others. Filed at N>1 only",
# OPT-11, added on purpose (test_peakguard.py part 10f says why).
"mode": "THIS battery's mode, chosen by its owner. A soft unit limit "
"that stage 1 keeps apart from the hard ones and stage 3 "
"releases when a claim above P_MODE decides the site target",
}
for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}")
check("all TWELVE claim sources are classified, and only those twelve",
check("all THIRTEEN claim sources are classified, and only those thirteen",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the six that are about the GRID CONNECTION are site claims",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
check("the six that are about a PACK, a DEVICE or one write are per-inverter",
check("the seven that are about a PACK, a DEVICE or one write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
check("a source nobody classified cannot be routed at all - actuator_for() "
"raises rather than defaulting to the site, because defaulting to the "
@@ -220,7 +224,7 @@ check("with one inverter every source routes to the site, per-inverter ones "
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("...and with none configured at all, likewise",
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("with TWO the identity breaks and the split becomes real: the six "
check("with TWO the identity breaks and the split becomes real: the seven "
"per-inverter sources route to the unit this controller drives, the six "
"site sources stay on the grid connection",
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
@@ -540,6 +544,156 @@ check("...and a correctly routed cycle is unaffected, which is what says this "
resolve(SITE_SIDE, units=TWO).allocated is True)
print()
print("OPT-11 9. the mode layer: a unit `hi` never meets the site `lo`")
# A battery's mode is a SOFT unit limit at P_MODE (35). Stage 1 keeps it apart
# from the hard limits; stage 3 applies it only while the site target was
# decided at or below P_MODE. So the peak guard's site floor (P_PEAK) releases
# every mode and SAFETY-25 section 0a's cross-inverter contradiction - 0 W in a
# billed period - still cannot be written down.
from app.arbiter import MODE_SOURCE, P_MODE # noqa: E402
from app.allocator import MODE_FAULT, MODE_RELEASED # noqa: E402
def _two(loop_w, *, peak_lo=None, modes=None, rating=2000.0, arb=None):
"""A two-inverter cycle's claims: ratings, each unit's mode, the loop,
optionally the peak floor and an arbitrage set."""
cl = []
for u in TWO:
cl.append(Claim.limit("supervised", P_SAFETY, -rating, rating,
"max_w", actuator=u))
for u, (lo, hi) in (modes or {}).items():
cl.append(Claim.limit(MODE_SOURCE, P_MODE, lo, hi, "mode", actuator=u))
cl.append(Claim.set("loop", P_LOOP, loop_w))
if arb is not None:
cl.append(Claim.set("arbitrage", P_ARBITRAGE, arb))
if peak_lo is not None:
cl.append(Claim.limit("peak", P_PEAK, lo=peak_lo))
return cl
check("P_MODE sits between arbitrage and the peak guard, so a trade stays "
"inside each mode and the peak floor releases them all",
P_ARBITRAGE < P_MODE < P_PEAK and P_MODE < P_MAINT_SHAPE)
_off_b = {"inverter-b": (0.0, 0.0)}
r = resolve(_two(-500.0, peak_lo=3000.0, modes=_off_b), units=TWO)
print(f" peak floor 3000 W, loop asks -500 W, B off: {r.explain()}, "
f"deciding {r.deciding_priority}, shares {r.shares}")
check("⭐ at N=2 a mode `hi` of 0 W on B and the peak guard's site `lo` of "
"3000 W do NOT contradict: no 0 W stop, the floor decides S (priority "
"50), the mode is released, and the shares still sum to S",
not r.contradiction and r.allocated and r.target_w == 3000.0
and r.deciding_priority == P_PEAK
and sum(r.shares.values()) == 3000 and r.allocation.unmet_w == 0)
_forbidden = [c if c.source != MODE_SOURCE
else Claim.limit(MODE_SOURCE, P_MODE, c.lo, c.hi,
actuator=ACTUATOR_SITE)
for c in _two(-500.0, peak_lo=3000.0, modes=_off_b)]
_rf = site_resolve([c for c in _forbidden if c.actuator == ACTUATOR_SITE])
check("...and the SAME mode filed as a SITE limit is exactly the fault the "
"layer exists to keep out: stage 2 then reads `lo` 3000 > `hi` 0 and "
"stops the site at 0 W (so the check above sees the difference)",
_rf.contradiction and _rf.target_w == 0.0)
check("...and resolve() refuses that mis-filed mode at N=2 (a UNIT claim on "
"the site), rather than dividing a number that may be wrong",
resolve(_forbidden, units=TWO).allocated is False)
# The sweep: every mode range that holds 0 W, every loop ask, every floor.
_bad = []
_n = 0
_spans = [(0.0, 0.0), (-2000.0, 0.0), (0.0, 2000.0), (-2000.0, 2000.0),
(-700.0, 300.0)]
for _ma in _spans:
for _mb in _spans:
for _loop in (-5000.0, -1200.0, 0.0, 800.0, 3900.0):
for _peak in (None, 0.0, 1500.0, 3000.0, 4000.0):
_n += 1
_r = resolve(_two(_loop, peak_lo=_peak,
modes={"inverter-a": _ma,
"inverter-b": _mb}), units=TWO)
_al = _r.allocation
_sh = list(_r.shares.values())
if (_r.contradiction or not _r.allocated
or sum(_sh) + _al.unmet_w != _al.sum_w
or (any(x > 0 for x in _sh) and any(x < 0 for x in _sh))
or (_peak is not None and _r.target_w < _peak)):
_bad.append((_ma, _mb, _loop, _peak, _r.explain(),
_al.explain()))
for x in _bad[:5]:
print(f" BAD {x}")
check(f"over {_n} two-inverter cycles (5x5 mode ranges holding 0 W, 5 loop "
f"asks, 5 peak floors) no cycle contradicts, refuses, loses a watt of "
f"the sum, splits the sign, or goes under the peak floor",
not _bad and _n == 625)
check("deciding_priority: the loop alone decides at 10",
resolve(_two(800.0), units=TWO).deciding_priority == P_LOOP)
check("...a trade decides at 30, so the modes still apply to it",
resolve(_two(800.0, arb=-1500.0), units=TWO).deciding_priority
== P_ARBITRAGE)
check("...a peak floor that does not bind leaves the loop deciding at 10",
resolve(_two(3500.0, peak_lo=1000.0), units=TWO).deciding_priority
== P_LOOP)
check("...and a peak floor that binds decides at 50",
resolve(_two(500.0, peak_lo=1000.0), units=TWO).deciding_priority
== P_PEAK)
_r35 = resolve(_two(3000.0, modes=_off_b), units=TWO)
check("with the loop deciding, B's Off mode holds: B gets 0 W and A carries "
"what it can, the rest reported as unmet",
_r35.shares == {"inverter-a": 2000, "inverter-b": 0}
and _r35.allocation.unmet_w == 1000)
_ub = pre_resolve([Claim.limit("supervised", P_SAFETY, -2000, 2000),
Claim.limit(MODE_SOURCE, P_MODE, 0.0, 0.0)])
check("stage 1 keeps the mode APART from the hard limits: the unit's own "
"interval is still its rating and the mode travels beside it",
(_ub.lo, _ub.hi, _ub.mode_lo, _ub.mode_hi, _ub.mode_priority)
== (-2000, 2000, 0.0, 0.0, P_MODE) and not _ub.contradiction)
_ub2 = pre_resolve([Claim.limit("supervised", P_SAFETY, -2000, 2000),
Claim.limit(MODE_SOURCE, P_MODE, 500.0, 2000.0)])
check("...so a mode that does not hold 0 W cannot make the unit contradict "
"itself at stage 1; stage 3 drops it as a software fault instead",
not _ub2.contradiction
and allocate(1000.0, {"inverter-a": _ub2, "inverter-b": UnitBounds()})[1]
and resolve(_two(1000.0, modes={"inverter-a": (500.0, 2000.0)}),
units=TWO).allocation.mode_faults
== (("inverter-a", MODE_FAULT),))
_ubm = pre_resolve([Claim.limit("supervised", P_SAFETY, -2000, 2000),
Claim.set("maintenance", P_MAINTENANCE, 2500.0, "drain"),
Claim.limit(MODE_SOURCE, P_MODE, 0.0, 0.0)])
check("...and a unit's own maintenance SET (60) gates the mode out, as it "
"gates every lower limit: maintenance outranks the mode",
_ubm.mode_priority is None and _ubm.pinned_by == "maintenance")
print()
print("OPT-11 10. at N=1 the mode bounds the site target, by the same rule")
_one = [Claim.limit("supervised", P_SAFETY, -5000, 5000, "max_w"),
Claim.limit(MODE_SOURCE, P_MODE, 0.0, 0.0, "off")]
_r1 = resolve(_one + [Claim.set("loop", P_LOOP, 1200.0)], units=ONE)
check("the loop deciding: an Off battery's site target is 0 W, named as the "
"mode's doing, with no contradiction",
_r1.target_w == 0.0 and not _r1.contradiction
and _r1.bound_by == "mode(off)" and _r1.allocated)
_r1p = resolve(_one + [Claim.set("loop", P_LOOP, 1200.0),
Claim.limit("peak", P_PEAK, lo=2000.0)], units=ONE)
check("...a binding peak floor releases it (2000 W, no contradiction), "
"which at N=1 would otherwise be `lo` 2000 > `hi` 0 and a 0 W stop",
_r1p.target_w == 2000.0 and not _r1p.contradiction
and _r1p.deciding_priority == P_PEAK)
_r1a = resolve(_one + [Claim.set("arbitrage", P_ARBITRAGE, -3000.0)],
units=ONE)
check("...a trade (30) stays inside it", _r1a.target_w == 0.0)
_r1f = resolve([Claim.limit(MODE_SOURCE, P_MODE, 100.0, 900.0),
Claim.set("loop", P_LOOP, -1200.0)], units=ONE)
check("...and a mode range without 0 W is dropped and reported, never "
"obeyed: the target stays the loop's -1200 W",
_r1f.target_w == -1200.0 and _r1f.mode_fault is not None)
check("...and with no mode claim at all, stage 2's answer is untouched "
"(the N=1 path with battery modes off)",
resolve([Claim.set("loop", P_LOOP, 1200.0)], units=ONE).target_w
== 1200.0)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")