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
+176 -26
View File
@@ -37,9 +37,11 @@ import aiohttp
from . import arbitrage
from .arbiter import (
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY,
Claim, Resolution, actuator_for, resolve,
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_MODE, P_PEAK, P_SAFETY,
Claim, Resolution, actuator_for, pre_resolve, resolve,
)
from . import allocator
from . import packmode
from . import control as _ctl
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .delivery import (
@@ -67,7 +69,8 @@ from .hass import HomeAssistant
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
arbitrate as maint_arbitrate,
)
from .mqtt import (MqttPublisher, announced_units, optional_keys, unit_slugs,
unit_state_key)
@@ -894,6 +897,13 @@ class Controller:
self._peak_saturated = False
self._peak_floor_limited = False
self._maint_shape_suspended = False
# OPT-11. Battery modes, off by default so an install that has not
# turned them on behaves exactly as before (SAFETY-10 section 1). Each
# battery's mode and on/off switch live in the store, not in options,
# so a change applies at once (`pack_use`, `pack_mode` actions).
self.modes_on = opts.get("battery_modes_enabled") is True
# The units whose mode fault is logged already (edge-logged).
self._mode_faults = set()
# [(pack name, why)] for packs that are due and waiting their turn.
# Empty at a roster of one, where nothing can defer anything.
self._maint_deferred = []
@@ -1953,7 +1963,103 @@ class Controller:
that have nothing to do with the allocator, and a weighting held from
construction prefers a pack that has since emptied.
"""
return packshare.weights(self.pack_energy, target_w)
base = packshare.weights(self.pack_energy, target_w)
if not self.modes_on:
return base
# OPT-11: the solar order. While the site charges, Leftover solar
# batteries weigh 0, so Solar first batteries fill first. A comfort
# choice like the rest of this method: permission is the mode claim.
return packmode.charge_weights(base, self.pack_modes(), target_w)
def pack_mode(self, inv) -> "packmode.PackMode":
"""OPT-11. This battery's on/off switch and mode, read from the store."""
return packmode.read(getattr(self.store, "data", None) or {}, inv.name)
def pack_modes(self) -> dict:
return {inv.name: self.pack_mode(inv) for inv in self.inverters}
def loop_bound(self, claims):
"""OPT-11. `(lo, hi)`: what the batteries may place if the loop decides.
The sum over live units of each unit's hard limits with its mode
applied (`allocator.loop_bound()`), from THIS cycle's unit claims. At
one inverter every claim is on the site, so it is the mode range
itself, or None when no mode is filed (maintenance) or the range
leaves out 0 W (a fault `resolve()` reports).
"""
if len(self.inverters) <= 1:
modes = [c for c in claims if c.source == "mode"]
if not modes:
return None
lo = max(c.lo for c in modes)
hi = min(c.hi for c in modes)
return (lo, hi) if lo <= 0.0 <= hi else None
bounds = {u: pre_resolve([c for c in claims if c.actuator == u])
for u in self.units}
return allocator.loop_bound(bounds, live=self.live_units())
def _mode_fault_log(self, resolution) -> None:
"""OPT-11: say once, per battery, that its mode was dropped as a fault.
A mode range without 0 W would force a direction, which no mode may do,
so it is never applied (allocator.unit_interval()). main.py never
builds one; this is the line that says so if it ever does.
"""
alloc = resolution.allocation
now = {u for u, _ in getattr(alloc, "mode_faults", ())}
if resolution.mode_fault is not None and self.inverters:
now.add(self.inverters[0].name)
for inv in self.inverters:
if inv.name in now and inv.name not in self._mode_faults:
_LOG.error("Battery mode ignored for %s. Software fault "
"MODE_RANGE.", inv.name)
self.log_event(f"battery mode ignored on {inv.name}, a "
f"software fault", hint=messages.hint(
"mode_fault", unit=inv.name))
self._mode_faults = now
def _pack_action(self, what, unit, value, now) -> dict:
"""OPT-11: switch one battery on or off, or set its mode.
Store state, not an option, so it applies on the next cycle with no
restart. Written to the action log. Switching a battery off also stops
its maintenance cycle (the owner's 2026-09-27 answer). Refused, with a
plain reason, while battery modes are off or for an unknown battery or
value.
"""
if not self.modes_on:
return {"error": messages.pack_refused("off")}
inv = next((i for i in self.inverters if i.name == unit), None)
if inv is None:
return {"error": messages.pack_refused("unit")}
if what == "pack_use":
if not isinstance(value, bool):
return {"error": messages.pack_refused("value")}
inv.store_set(packmode.USE_KEY, value)
self.log_event(f"{inv.name} switched {'on' if value else 'off'} "
f"by operator",
hint=messages.hint("pack_on" if value
else "pack_off", unit=inv.name))
if not value:
self._maint_stop_off(self.maints[self.inverters.index(inv)],
now)
else:
if value not in packmode.MODES:
return {"error": messages.pack_refused("value")}
inv.store_set(packmode.MODE_KEY, value)
self.log_event(f"{inv.name} mode set to {value.replace('_', ' ')} "
f"by operator",
hint=messages.hint("pack_mode", unit=inv.name))
pm = self.pack_mode(inv)
return {"unit": inv.name, "use": pm.use, "mode": pm.mode}
def _maint_stop_off(self, machine, now) -> None:
"""OPT-11: stop this battery's maintenance cycle because it is off."""
if machine.phase == IDLE:
return
for msg in machine.abort(now, "battery switched off"):
self.log_event(msg, hint=messages.hint("maint_off",
unit=machine.name))
@property
def why_words(self) -> str:
@@ -2696,9 +2802,27 @@ class Controller:
# Calling `tick()` without the answer this produced is the
# two-packs-draining-at-once defect with the interlock present but not
# wired, which is why the plan is built here and not inside the loop.
maint_plan = maint_arbitrate(self.maints, now)
# OPT-11: a battery switched off starts no cycle and is left out of the
# slot, so it cannot hold the slot from a battery that is on. One that
# is mid-cycle is stopped, with a log line (the owner's 2026-09-27
# answer). The switch itself stops it at once (`pack_use`); this is
# the same rule for a store that says Off at start-up.
_off = ({inv.name for inv in self.inverters
if not self.pack_mode(inv).use} if self.modes_on else set())
for inv, machine in zip(self.inverters, self.maints):
if inv.name in _off:
self._maint_stop_off(machine, now)
_on = [m for inv, m in zip(self.inverters, self.maints)
if inv.name not in _off]
_plan_on = iter(maint_arbitrate(_on, now) if _off
else maint_arbitrate(self.maints, now))
maint_plan = [None if inv.name in _off else next(_plan_on)
for inv, _m in zip(self.inverters, self.maints)]
maint_results = []
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
if blocked is None:
maint_results.append(MaintResult(IDLE, False, None, False, []))
continue
res = machine.tick(now, inv.soc, blocked_by=blocked)
for msg in res.events:
self.log_event(msg, hint=messages.hint("maintenance",
@@ -3564,6 +3688,23 @@ class Controller:
actuator=self.actuator_of("maintenance",
inv)))
# --- OPT-11: each battery's mode, a soft unit limit at P_MODE ---------
# 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`.
if self.modes_on:
for inv, result in zip(self.inverters, maint_results):
if result.phase != IDLE:
continue
pm = self.pack_mode(inv)
lo, hi = packmode.mode_range(pm, inv.max_w)
claims.append(Claim.limit("mode", P_MODE, lo, hi, pm.key,
actuator=self.actuator_of("mode",
inv)))
# --- the grid-following loop -----------------------------------------
# ⚠️ SAFETY-10: `or self.shadow`. The loop is the leg a shadow run most
# needs and the one that is switched off by `auto_start: false`, so
@@ -3588,6 +3729,28 @@ class Controller:
self._unit_refusals()
_probing = {inv.name: self._unit_probe(inv)
for inv in self.inverters if inv.refused}
# --- SAFETY-73: each refusing unit's own P_SAFETY claim ---------------
# A hard unit limit that always intersects (OPT-11's P_MODE layer sits
# inside it): closed in the refused direction, so the allocator hands
# the share to the others within their own limits. During its probe it
# is a pin at PROBE_W and the others carry the rest. Filed here, before
# the loop, so OPT-11's loop bound sees it. Unit claims only, and still
# the last unit claims filed, so every unit's claim order is as before.
for inv in self.inverters:
if not inv.refused:
continue
if _probing.get(inv.name):
claims.append(Claim.set("refused", P_SAFETY,
inv.refused * _ctl.PROBE_W,
_ctl.PROBING,
actuator=self.actuator_of("refused",
inv)))
else:
claims.append(Claim.limit("refused", P_SAFETY,
*_refused_interval(inv.refused),
_ctl.REFUSED,
actuator=self.actuator_of("refused",
inv)))
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware
@@ -3649,6 +3812,9 @@ class Controller:
refused=self.batt_refused if len(self.inverters) == 1
else self._site_refused(),
probe=probe,
# OPT-11: None unless battery modes are on, which is the law
# exactly as it was.
bound=self.loop_bound(claims) if self.modes_on else None,
)
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
@@ -3757,26 +3923,6 @@ class Controller:
# ability to DELIVER rests on this argument - a weight cannot stop a
# unit being commanded and cannot move a failing unit's load to its
# sibling. The `pack` claim filed above is the half that constrains.
# --- SAFETY-73: each refusing unit's own P_SAFETY claim ---------------
# A hard unit limit that always intersects (OPT-11's P_MODE layer sits
# inside it): closed in the refused direction, so the allocator hands
# the share to the others within their own limits. During its probe it
# is a pin at PROBE_W and the others carry the rest.
for inv in self.inverters:
if not inv.refused:
continue
if _probing.get(inv.name):
claims.append(Claim.set("refused", P_SAFETY,
inv.refused * _ctl.PROBE_W,
_ctl.PROBING,
actuator=self.actuator_of("refused",
inv)))
else:
claims.append(Claim.limit("refused", P_SAFETY,
*_refused_interval(inv.refused),
_ctl.REFUSED,
actuator=self.actuator_of("refused",
inv)))
_live = self.live_units()
while True:
resolution = resolve(claims, units=self.units, live=_live,
@@ -3796,6 +3942,8 @@ class Controller:
*_refused_interval(1 if claims[k].value > 0 else -1),
_ctl.REFUSED, actuator=claims[k].actuator)
self.allocation = resolution.allocation
if self.modes_on:
self._mode_fault_log(resolution)
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
# which is then written like any other cycle at
@@ -5743,8 +5891,10 @@ class Controller:
out["home_error"] = True
return out
async def handle_action(self, what: str):
async def handle_action(self, what: str, *, unit=None, value=None):
now = datetime.now(timezone.utc)
if what in ("pack_use", "pack_mode"):
return self._pack_action(what, unit, value, now)
if what == "auto_toggle":
self.auto = not self.auto
self.store.set("auto", self.auto)