Each battery gets an MQTT-discovered Mode select and Use switch, only while
battery_modes_enabled is true. Commands go through handle_action(), the
/action path; refusals are logged once. Retained commands are never applied.
Turning modes off removes exactly the configs announced (kept in the store).
With modes off the announcement is byte-identical to ed739d4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
110 lines
4.5 KiB
Python
110 lines
4.5 KiB
Python
"""OPT-11 P1: each battery's mode and on/off switch, as limits and weights.
|
|
|
|
Pure: no I/O, no clock. main.py reads the store, calls these, and files the
|
|
result as a `mode` claim (arbiter.P_MODE) and as the charge weights.
|
|
DECISIONS/OPT-11-battery-modes.md has the record; OPT-11-design section 1 has
|
|
the modes.
|
|
|
|
Two rules hold for every mode, and they are what keeps modes safe:
|
|
|
|
1. A mode range always holds 0 W. A mode may forbid a direction, never force
|
|
one, so batteries never move power in opposite directions because of a mode
|
|
(allocator.unit_interval() drops a range without 0 as a software fault).
|
|
2. A mode is a limit, not a weight. A weight of 0 does not keep a battery out:
|
|
allocator._fill()'s last-resort pass still uses it. So "may not" is a range,
|
|
and weights only set the order among batteries that may.
|
|
|
|
P1 has no price runs and no forecast (P3, P4). So both modes give power to the
|
|
house at any hour, and the only mode that closes a direction is Off. What P1
|
|
changes is the solar order: while the site charges, Leftover solar batteries
|
|
weigh 0, so they get solar only once the Solar first batteries are full or at
|
|
their power limit. Selling (the owner's 2026-09-27 answer) is a per-battery
|
|
choice that arrives with the price runs in P3; `PackMode.sell` and the
|
|
`pack_sell` store key are reserved for it and read by nothing yet.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from .store import unit_key
|
|
|
|
SOLAR_FIRST = "solar_first"
|
|
LEFTOVER_SOLAR = "leftover_solar"
|
|
CHEAP_HOURS = "cheap_hours" # reserved for P3; not accepted in P1
|
|
|
|
# The modes a battery may be set to in this release, in the order the page
|
|
# lists them. The first is the default when the store has no value.
|
|
MODES = (SOLAR_FIRST, LEFTOVER_SOLAR)
|
|
DEFAULT_MODE = SOLAR_FIRST
|
|
|
|
# Store keys, per battery: `unit/<name>/<key>` (store.unit_key()).
|
|
USE_KEY = "pack_use"
|
|
MODE_KEY = "pack_mode"
|
|
SELL_KEY = "pack_sell" # reserved for P3
|
|
|
|
|
|
def enabled(opts) -> bool:
|
|
"""Battery modes are on only when the option is exactly true. One answer,
|
|
read by the controller and by the MQTT announcement."""
|
|
return opts.get("battery_modes_enabled") is True
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PackMode:
|
|
"""One battery's choice: on or off, its mode, and (P3) selling."""
|
|
use: bool = True
|
|
mode: str = DEFAULT_MODE
|
|
sell: bool = False # reserved for P3, never read in P1
|
|
|
|
@property
|
|
def key(self) -> str:
|
|
"""The page's word for this state: a mode, or `off`."""
|
|
return self.mode if self.use else "off"
|
|
|
|
|
|
def read(data: dict, name: str) -> PackMode:
|
|
"""Battery `name`'s choice from the store's data. A missing or unknown
|
|
value falls back to the default (on, Solar first), never to Off: a store
|
|
written by a later version must not switch a battery off here."""
|
|
use = data.get(unit_key(name, USE_KEY), True)
|
|
mode = data.get(unit_key(name, MODE_KEY), DEFAULT_MODE)
|
|
return PackMode(use=use is not False,
|
|
mode=mode if mode in MODES else DEFAULT_MODE,
|
|
sell=data.get(unit_key(name, SELL_KEY)) is True)
|
|
|
|
|
|
def mode_range(pm: PackMode, rating_w: float) -> tuple:
|
|
"""`(lo, hi)` in W for this battery's mode claim. Always holds 0 W.
|
|
|
|
Off asks for 0 W both ways. In P1 both modes may charge and give power up
|
|
to the battery's rating: Leftover solar holds back its power only in the
|
|
planned expensive hours, and there are none before P3.
|
|
"""
|
|
if not pm.use:
|
|
return 0.0, 0.0
|
|
r = abs(float(rating_w))
|
|
return -r, r
|
|
|
|
|
|
def charge_weights(base, modes: dict, target_w: float):
|
|
"""The allocator's weights with the solar order applied (design 3.5).
|
|
|
|
`base` is packshare.weights()' answer (None for "all 1.0"). While the site
|
|
charges (`target_w < 0`), a Leftover solar battery weighs 0 and a Solar
|
|
first battery keeps its weight, so the fill gives the Solar first batteries
|
|
everything they can take before the last-resort pass reaches the others.
|
|
Giving power keeps `base` (P3 adds the expensive-run order).
|
|
|
|
When no battery that is on would keep a positive weight (all Leftover
|
|
solar, or every Solar first one at 0), `base` is returned unchanged, so the
|
|
order among equals stays OPT-09's rather than an equal split.
|
|
"""
|
|
if float(target_w) >= 0.0 or not modes:
|
|
return base
|
|
names = sorted(modes)
|
|
w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names}
|
|
out = {n: (0.0 if modes[n].mode == LEFTOVER_SOLAR else w[n])
|
|
for n in names}
|
|
if not any(out[n] > 0.0 for n in names if modes[n].use):
|
|
return base
|
|
return out
|