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
+30 -11
View File
@@ -2653,9 +2653,26 @@ check("...and so does the persistent notification, which outlives the page and "
#
# If you are here because you added a TWELFTH, that argument is what this line
# wants from you, not an edit.
# ⚠️ SAFETY-73 ADDED `refused` (a P_SAFETY unit limit on a measured refusal).
# ⚠️ OPT-11 ADDED `mode`, ON PURPOSE, AND IT IS NOT THE DEFERRED RESERVE EITHER.
# `mode` is `Claim.limit("mode", P_MODE, lo, hi)` on ONE inverter's actuator:
# the owner's per-battery choice (on/off, Solar first, Leftover solar), filed
# only with `battery_modes_enabled` on (default off). It is not conditioned on
# a state of charge, and it cannot bound the site against the peak floor:
# 1. stage 1 keeps it APART from the hard limits (UnitBounds.mode_*), so it
# never enters a `lo > hi` test, not even the unit's own;
# 2. stage 3 applies it only while the site target was decided at or below
# P_MODE (35), so the guard's floor (P_PEAK, 50) RELEASES it, and at N=1
# `resolve()` applies it to the site by the same rule, after stage 2;
# 3. its range always holds 0 W, so it can forbid a direction and never
# force one (a range without 0 is dropped as a software fault).
# test_arbiter.py part 9 grades the no-contradiction claim at N=2, over 625
# cycles with the floor, and part 10 at N=1. The sweeps below never reach it:
# their fixtures leave battery modes off, so they still see {device,
# supervised, loop}. DECISIONS/OPT-11-battery-modes.md.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop", "circulation",
"sign_site", "pack", "refused"}
"sign_site", "pack", "refused", "mode"}
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
# that a THIRD such indirection reads as an unrecognised append rather than as a
# silent gap in the scan.
@@ -2697,7 +2714,7 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
"- a claim filed through a shape it cannot parse has to fail here rather "
"than be counted as nothing, which is how a scan like this goes quietly "
"vacuous", len(_appends) >= 10 and None not in _filed)
check("...and the sources that cycle can file are EXACTLY these TWELVE. THIS "
check("...and the sources that cycle can file are EXACTLY these THIRTEEN. THIS "
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
"list, so it cannot see a claim main.py adds - and a loop-side SoC "
"reserve is a new source. Filing one takes this line red on the line it "
@@ -2849,7 +2866,7 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance", "pack",
"refused"})
"refused", "mode"})
# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a
# real cycle files is a site claim. Observed on the claims main.py's own resolve
@@ -2947,6 +2964,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
_routed == {"device": "inverter-a", "supervised": "inverter-a",
"failsafe": "inverter-a", "maintenance": "inverter-a",
"pack": "inverter-a", "refused": "inverter-a",
"mode": "inverter-a",
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
@@ -3028,13 +3046,13 @@ _unit_sources_unrouted = [
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all THIRTEEN of `Controller.cycle()`'s DIRECT appends route through "
check("all FOURTEEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once",
len(_direct) == 13
len(_direct) == 14
and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
@@ -3051,15 +3069,15 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
"site-scoped sources and is the whole defect for a per-inverter one: a "
"`device` limit built inside the roster loop and routed without `inv` "
"files inverter B's rating on inverter A, where it binds the wrong "
"share. Eight of the thirteen appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack` and "
"SAFETY-73's two `refused` - and all eight pass one. "
"share. Nine of the fourteen appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack`, "
"SAFETY-73's two `refused` and OPT-11's `mode` - and all nine pass one. "
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
"the site, beside `circulation`, because the number that tripped it is "
"the site target",
_unit_sources_unrouted == []
and len([r for r in _routing
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 8)
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 9)
check("...and the only appends this pin does NOT reach are the two that file a "
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
"`actuator=` at the call site to mis-type. Their routing is pinned where "
@@ -3068,7 +3086,8 @@ check("...and the only appends this pin does NOT reach are the two that file a "
"arbitrage.claim() by test_arbitrage.py",
sorted(_indirect) == sorted(CLAIM_BUILDERS)
and len(_direct) + len(_indirect) == len(_appends))
check("...and between them the fifteen appends account for exactly the twelve "
check("...and between them the sixteen appends account for exactly the "
"thirteen "
"sources 10f pins, so the routing pin and the source set are one scan "
"and neither can drift from the other",
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
@@ -3129,7 +3148,7 @@ check("...and naming those three is not an accident of this fixture but the "
{c.source for c in _cl2}
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
"arbitrage", "circulation", "sign_site",
"refused"})
"refused", "mode"})
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
# WHOSE NUMBERS the bound is built from - and those are two different defects