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
+106 -4
View File
@@ -98,6 +98,12 @@ DROP_STALE = "stale link"
DROP_CONTRADICTION = "contradicts itself"
DROP_NO_INTEGER = "no integer setpoint in its interval"
# OPT-11: why a unit's MODE layer was not applied this tick. Only the first is
# a fault; the other two are the rule working.
MODE_FAULT = "mode range leaves out 0 W"
MODE_RELEASED = "mode released by a higher claim"
MODE_OUTRANKED = "hard limit leaves no room for the mode"
REFUSE_SCOPE = "mis-routed claim"
REFUSE_EMPTY = "no unit available"
REFUSE_SIGN = "opposite directions"
@@ -213,6 +219,10 @@ class Allocation:
# that calls `plan()` for its arithmetic gets None and does not care;
# main.py stamps every record it puts on the write path.
tick: int | None = None
# OPT-11: ((unit, why), ...) for each unit whose mode range left out 0 W
# this tick. That mode was dropped (see `unit_interval()`); main.py logs it
# as a software fault. Empty on every tick without a mode.
mode_faults: tuple = ()
@property
def ok(self) -> bool:
@@ -290,6 +300,87 @@ class Allocation:
return out
def unit_interval(b, deciding_priority=None):
"""OPT-11. `(lo, hi, note)`: the interval a unit is allocated into.
The hard limits (`b.lo`, `b.hi`) always apply. The unit's MODE range
(`b.mode_lo`, `b.mode_hi`) narrows them only when the site target was
decided at or below the mode's own priority - `deciding_priority` None
means no higher claim was named, so the mode applies. `note` says why the
mode did not apply, or is None when it did or there is none:
* MODE_FAULT: the mode range leaves out 0 W. A mode may forbid a direction
and never force one, so this is a software fault and the mode is dropped.
It is what keeps REFUSE_SIGN unreachable from a mode: the fill below
moves every free unit away from 0 in ONE direction, and it starts from 0
only while every interval holds 0.
* MODE_RELEASED: a higher claim (the peak floor) decided the target.
* MODE_OUTRANKED: the hard limits and the mode do not overlap. The hard
limits win, as they always do.
A unit without mode fields (`mode_priority` absent or None) gets its hard
limits, unchanged: that is every unit when battery modes are off.
"""
lo, hi = b.lo, b.hi
prio = getattr(b, "mode_priority", None)
if prio is None:
return lo, hi, None
mlo, mhi = b.mode_lo, b.mode_hi
if not (mlo <= 0.0 <= mhi):
return lo, hi, MODE_FAULT
if deciding_priority is not None and deciding_priority > prio:
return lo, hi, MODE_RELEASED
nlo, nhi = max(lo, mlo), min(hi, mhi)
if nlo > nhi:
return lo, hi, MODE_OUTRANKED
return nlo, nhi, None
class _Narrowed:
"""A unit's bounds with its interval replaced by `unit_interval()`'s."""
__slots__ = ("lo", "hi")
def __init__(self, lo, hi):
self.lo, self.hi = lo, hi
def loop_bound(bounds, live=None):
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
The sum, over live units that do not contradict themselves, of each unit's
interval with its mode applied (`unit_interval()` with no higher claim). A
pinned unit adds the span from 0 to its pin, so a probe or a maintenance
pin stays reachable. Every term holds 0, so the sum does too.
`compute()` keeps the site target inside this, so the loop never asks for
watts no battery may give: the allocator would report them as unmet and the
saturation freeze would park the target on them (SAFETY-73 section 1).
Pure, like `plan()`: it reads numbers and intervals and nothing else.
"""
lo_sum, hi_sum = 0.0, 0.0
for u in sorted(bounds):
b = bounds[u]
if live is not None and u not in live:
continue
if getattr(b, "contradiction", False):
continue
pv = getattr(b, "pinned_w", None)
if getattr(b, "gate", None) is not None and pv is not None:
lo, hi = _int_envelope(b)
p = float(pv) if lo > hi else float(_clamp_int(round(float(pv)),
lo, hi))
lo_sum += min(0.0, p)
hi_sum += max(0.0, p)
continue
lo, hi, _ = unit_interval(b)
lo, hi = _int_envelope(_Narrowed(lo, hi))
if lo > hi:
continue
lo_sum += min(0.0, lo)
hi_sum += max(0.0, hi)
return lo_sum, hi_sum
def _int_envelope(b):
"""A unit's interval, narrowed to the integers a 1 W register can hold.
@@ -385,7 +476,8 @@ def _fill(order, weights, cap, amount):
def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
site_priority=None, site_key="site", tick=None) -> Allocation:
site_priority=None, site_key="site", tick=None,
deciding_priority=None) -> Allocation:
"""Divide the site grid target `target_w` across the units in `bounds`.
`bounds` unit name -> that unit's interval for THIS TICK (arbiter stage 1,
@@ -411,6 +503,9 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
`site_priority` the priority of the SITE claim that chose `target_w`. It is
what a unit's own winning SET is weighed against - see PINS.
`site_key` the name to use when there are no units at all.
`deciding_priority` OPT-11: the priority that decided `target_w`
(`Resolution.deciding_priority`). Each unit's mode range applies
only at or below it - see `unit_interval()`. None applies them.
`tick` an opaque token identifying the tick this record is FOR, handed
straight back on the record. SAFETY-28's write gate compares it
against the tick it is writing on, so that a record cannot be
@@ -497,6 +592,7 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
dropped = []
ilo, ihi = {}, {}
considered = []
mode_faults = []
for u in units:
b = bounds[u]
if live is not None and u not in live:
@@ -512,7 +608,12 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
# has no interval to allocate into.
dropped.append((u, DROP_CONTRADICTION))
continue
lo, hi = _int_envelope(b)
# OPT-11: the mode layer, when it applies, narrows the hard limits.
# A unit with no mode gets exactly its hard limits, as before.
nlo, nhi, note = unit_interval(b, deciding_priority)
if note == MODE_FAULT:
mode_faults.append((u, MODE_FAULT))
lo, hi = _int_envelope(_Narrowed(nlo, nhi))
if lo > hi:
# An interval that contains no integer. The register is 1 W, so
# there is no number this unit could be commanded that both bounds
@@ -526,7 +627,7 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
return Allocation(
target_w=float(target_w), sum_w=sum_w, shares={}, plans=(),
dropped=tuple(dropped), unmet_w=sum_w, quantisation_w=quant,
refused=why, tick=tick)
refused=why, tick=tick, mode_faults=tuple(mode_faults))
# --- REFUSAL 2: nobody is available ------------------------------------
if not considered:
@@ -659,4 +760,5 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
return Allocation(target_w=float(target_w), sum_w=sum_w, shares=shares,
plans=tuple(plans), dropped=tuple(dropped),
unmet_w=int(unmet), quantisation_w=quant, tick=tick)
unmet_w=int(unmet), quantisation_w=quant, tick=tick,
mode_faults=tuple(mode_faults))