OPT-11 P1: per-battery mode limits, on/off, solar order
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:
+176
-26
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user