OPT-11 P1 review: a mode never beats the peak floor; SAFETY-03 kept at the rail
checks / unit (pull_request) Successful in 11m4s
infra-pin / fresh (pull_request) Successful in 2s

- arbiter: a mode now gives way only as far as a site limit above P_MODE
  needs (_unit_modes at N>1, _site_mode at N=1). Before, the modes were
  released only when the peak floor bound S, so a trade won above the floor
  kept them and placed less than the floor (an Off battery at N=1 went to 0 W
  under a 2000 W floor), and a capped grid charge released an Off battery.
  While released, held batteries weigh 0 and carry only the shortfall.
- control: a side of the loop bound that reaches the rail is left to the
  rail, so SAFETY-03's i_clamped still fires with every battery on.
- opt11_identity: add maintenance scenarios and a 6000-case arbiter digest
  (peak floor, trades, maintenance, refusals); a5dddad digests pinned.
- tests: test_arbiter part 11, test_control (g), test_modes (0/1 refused,
  loop bound counts live units only). DOCS and DECISIONS say the new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
This commit is contained in:
glenn schrooyen
2026-09-28 19:41:36 +02:00
co-authored by Claude Opus 5.5
parent d69fd8fbfd
commit ffc9a4d721
10 changed files with 365 additions and 40 deletions
+3 -3
View File
@@ -304,9 +304,9 @@ 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
(`b.mode_lo`, `b.mode_hi`) narrows them unless `deciding_priority` is
above the mode's own priority (arbiter._unit_modes() passes None when the
modes hold, and the yielding limit's priority when they give way). `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
+108 -20
View File
@@ -87,9 +87,9 @@ P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
# loop would otherwise cancel every charge the moment it created import.
P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose
# OPT-11. A battery's mode (Solar first, Leftover solar, Off): a SOFT unit
# limit, kept apart from the hard ones and applied only while the site target
# is decided at or below this priority. Above arbitrage, so a trade stays inside
# each battery's mode; below the peak guard, which releases every mode.
# limit, kept apart from the hard ones. Above arbitrage, so a trade stays inside
# each battery's mode; below the peak guard, to which a mode gives way only as
# far as the floor needs (`_unit_modes()`, `_site_mode()`).
# DECISIONS/OPT-11-battery-modes.md.
P_MODE = 35
P_LOOP = 10 # ordinary grid-following
@@ -250,8 +250,8 @@ CLAIM_SCOPE = {
# main.py: `Claim.limit("mode", P_MODE, lo, hi, ...)`, OPT-11. THIS
# battery's mode, chosen per battery by its owner. A unit claim that never
# enters a `lo > hi` test with a site limit: stage 1 keeps it apart from the
# hard limits, and stage 3 applies it only when the site target was decided
# at or below P_MODE. At N=1 it routes to the site like every source, and
# hard limits, and it gives way to a site limit above P_MODE only as far as
# that limit needs (`_unit_modes()`). At N=1 it routes to the site, and
# `resolve()` takes it out of stage 2 for the same reason.
"mode": SCOPE_UNIT,
}
@@ -434,9 +434,10 @@ class Resolution:
# `explain()` names.
winner_priority: int | None = None
# OPT-11. The higher of the winning set's priority and the priority of the
# limit that bound `target_w`. Stage 3 applies each unit's mode only when
# this is at or below that mode's priority (P_MODE), so a peak floor that
# binds S (P_PEAK) releases every mode and never meets a mode `hi`.
# limit that bound `target_w`. Whether the modes hold is decided from the
# limits themselves (`_site_mode()`, `_unit_modes()`): a mode gives way
# only as far as a limit above it (the peak floor) needs, so a mode `hi`
# never meets a site `lo`.
deciding_priority: int | None = None
# OPT-11 at N=1: why the site's mode layer was dropped this tick, or None.
# At N>1 the same fact is on `allocation.mode_faults`, per unit.
@@ -676,7 +677,7 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
# `claims`, so this is the shipped arbiter on the shipped list.
out = site_resolve(site_claims)
if site_modes:
_site_mode(out, site_modes)
_site_mode(out, site_modes, site_claims)
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
#
@@ -709,23 +710,67 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
# nothing. See `packshare.weights()` on the residual this leaves (the fill's
# own direction can differ from the target's sign under a pin, in which case
# the weighting is a worse preference and still cannot be unsafe).
# OPT-11: whether each unit's mode holds this tick (`_unit_modes()`).
# Before the weights, because a limit above the modes may move the target.
gate, held = _unit_modes(out, site_claims, bounds, live)
if callable(weights):
weights = weights(out.target_w)
if held:
weights = {u: (0.0 if u in held else
(1.0 if weights is None else weights.get(u, 1.0)))
for u in bounds}
out.shares, out.allocated, out.allocation = allocate_record(
out.target_w, bounds, weights=weights, live=live,
scope_errors=out.scope_errors, site_priority=out.winner_priority,
tick=tick, deciding_priority=out.deciding_priority)
tick=tick, deciding_priority=gate)
return out
def _site_mode(out: Resolution, modes: list) -> None:
def _limits_above(site_claims: list, prio: int) -> tuple:
"""OPT-11: `(lo, lo_src, hi, hi_src)` over the site LIMITs above `prio`.
These are what a mode may never take the site target outside of: the peak
guard (P_PEAK), maintenance shaping (P_MAINT_SHAPE) and the hard limits.
"""
lo, hi = -inf, inf
lo_src = hi_src = None
for c in site_claims:
if c.kind != LIMIT or c.priority <= prio:
continue
if c.lo > lo:
lo, lo_src = c.lo, c
if c.hi < hi:
hi, hi_src = c.hi, c
return lo, lo_src, hi, hi_src
def _yield_to(out: Resolution, t1: float, above: tuple):
"""OPT-11: the target `t1` the modes allow, moved only as far as the limits
above the mode need (`_limits_above()`). Returns the claim that moved it,
or None when `t1` already satisfies them, and sets `out.target_w`."""
lo, lo_src, hi, hi_src = above
t2 = max(lo, min(hi, t1))
out.target_w = float(t2)
if t2 == t1:
return None
binder = lo_src if t2 == lo else hi_src
out.bound_by = (f"{binder.source}({binder.reason})" if binder.reason
else binder.source)
out.deciding_priority = max(out.deciding_priority or binder.priority,
binder.priority)
return binder
def _site_mode(out: Resolution, modes: list, site_claims: list) -> None:
"""OPT-11 at N=1: the one battery's mode bounds the SITE target, after stage 2.
The unit rule of `allocator.unit_interval()` on the site: the mode applies
only when stage 2 decided at or below its priority, and a range without 0 W
is a software fault that drops the mode (`out.mode_fault`). A clamp of one
number into a range that holds 0 cannot contradict anything, so the peak
floor still wins by releasing the mode, as at N>1.
A SET above the mode (a safety stop) decides alone. Otherwise the target is
clamped into the mode range and then back into the site limits above the
mode, so the mode gives way only as far as those limits need: a peak floor
of 2000 W over an Off battery asks it for 2000 W, whether a trade or the
loop won stage 2, and a charge the peak guard merely caps stays at 0 W. A
range without 0 W is a software fault that drops the mode
(`out.mode_fault`). Clamping one number cannot contradict anything.
"""
out.considered = out.considered + [f"{c.source}:{c.kind}" for c in modes]
lims = [c for c in modes if c.kind == LIMIT]
@@ -737,10 +782,53 @@ def _site_mode(out: Resolution, modes: list) -> None:
if not (lo <= 0.0 <= hi):
out.mode_fault = f"mode range [{lo:.0f}, {hi:.0f}] leaves out 0 W"
return
if out.deciding_priority is not None and out.deciding_priority > prio:
if out.winner_priority is not None and out.winner_priority > prio:
return
target = max(lo, min(hi, out.target_w))
if target != out.target_w:
before = out.target_w
t1 = max(lo, min(hi, before))
if _yield_to(out, t1, _limits_above(site_claims, prio)) is None \
and t1 != before:
c = lims[0]
out.bound_by = f"{c.source}({c.reason})" if c.reason else c.source
out.target_w = float(target)
def _unit_modes(out: Resolution, site_claims: list, bounds: dict, live):
"""OPT-11 at N>1: `(gate, held)` for stage 3.
`gate` is the `deciding_priority` handed to the allocator: None applies
every unit's mode, a number above P_MODE releases them. `held` is the set
of units whose mode holds them back in the target's direction; while the
modes are released they weigh 0, so they carry only what the other units
cannot (the allocator's last-resort pass).
The rule matches N=1: a SET above the mode decides alone. Otherwise the
target is clamped into what the units can place with their modes
(`allocator.loop_bound()`) and back into the site limits above the mode.
If that point is still placeable with the modes, they hold and the target
is unchanged (what they cannot place is reported unmet, as before). If a
limit above the mode needs more, the target moves only that far, and the
modes are released for it.
"""
mode_units = [u for u, b in bounds.items()
if getattr(b, "mode_priority", None) is not None]
if not mode_units or out.contradiction:
return out.deciding_priority, set()
prio = min(bounds[u].mode_priority for u in mode_units)
if out.winner_priority is not None and out.winner_priority > prio:
return out.deciding_priority, set()
clo, chi = allocator.loop_bound(bounds, live)
t1 = max(clo, min(chi, out.target_w))
before = out.target_w
binder = _yield_to(out, t1, _limits_above(site_claims, prio))
if binder is None:
out.target_w = float(before)
return None, set()
held = set()
for u in mode_units:
b = bounds[u]
nlo, nhi, note = allocator.unit_interval(b)
if note is not None:
continue
if (nhi < b.hi) if out.target_w > 0 else (nlo > b.lo):
held.add(u)
return binder.priority, held
+11 -3
View File
@@ -17,6 +17,7 @@ exists because its absence produced a specific, observed failure.
"""
import math
from math import inf
from dataclasses import dataclass
# ---------------------------------------------------------------------------
@@ -500,6 +501,14 @@ def compute(
# main.py never builds one: every term of `loop_bound()` holds 0).
if bound is not None and not (bound[0] <= 0.0 <= bound[1]):
bound = None
# ...and a side of it that reaches the rail is left to the rail, so that
# SAFETY-03's `i_clamped` still fires when the loop winds into it. With
# every battery on, the bound IS the rail, and the law is exactly as it was.
if bound is not None:
_rail = min(tuning.max_w, tuning.max_w if tuning.integrator_max_w is None
else tuning.integrator_max_w)
bound = (float(bound[0]) if bound[0] > -_rail else -inf,
float(bound[1]) if bound[1] < _rail else inf)
# --- saturation, WITH the duration term -------------------------------
# Command and actual diverging means the inverter cannot follow - it is at
@@ -603,9 +612,8 @@ def compute(
i_w = max(moved, i_w)
# OPT-11: the integrator does not wind past what the modes let the
# batteries place. Applied BEFORE the integrator bound below, so a loop
# held by a mode reads as `mode-bound` and not as SAFETY-03's winding into
# the rail (`i_clamped`). That bound still bites where it is the tighter.
# batteries place. Only a side tighter than the rail is left in `bound`
# (above), so this never takes SAFETY-03's `i_clamped` away from the rail.
if bound is not None:
held = max(float(bound[0]), min(float(bound[1]), i_w))
if held != i_w:
+3 -3
View File
@@ -3692,9 +3692,9 @@ class Controller:
# Filed only with battery modes on, and never on a battery in any
# maintenance phase: maintenance at 60/40 outranks the mode. The range
# always holds 0 W (packmode.mode_range()). pre_resolve() keeps it
# apart from the hard limits, and the allocator applies it only while
# the site target is decided at or below P_MODE, so the peak guard's
# site floor (P_PEAK) releases it and never meets it in a `lo > hi`.
# apart from the hard limits, and it gives way to the peak guard's
# site floor (P_PEAK) only as far as the floor needs, so the two never
# meet in a `lo > hi` (arbiter._unit_modes()).
if self.modes_on:
for inv, result in zip(self.inverters, maint_results):
if result.phase != IDLE: