Cheap and expensive hours as runs of consecutive firm price slots
(priceruns.py); each battery's role per tick from its mode (modetrade.py):
grid charging for Leftover solar / Cheap hours only, never Solar first;
holding for the expensive runs; selling per battery (pack_sell) above the
spread and the floor. The site trade is sized inside the modes (loop bound)
and the peak guard's headroom; a run edge reseeds the loop. Cheap hours only
and selling are enabled on the Batteries screen and in the HA select
(appended). The site Why row no longer says "held back" when the peak limit
pushes power up (F2). Modes off: byte-identical to 7a00f12 on the trade path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
4.5 KiB
Python
114 lines
4.5 KiB
Python
"""OPT-11: 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.
|
|
|
|
`mode_range()` is the switch alone (Off is [0, 0]). P3 adds the price runs:
|
|
`modetrade.decide()` narrows each battery's range by its role this tick
|
|
(hold for the expensive hours, charge from the grid, sell), and main.py files
|
|
the intersection. Selling is a per-battery choice (`pack_sell`, the owner's
|
|
2026-09-27 answer), for Leftover solar and Cheap hours only.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from .store import unit_key
|
|
|
|
SOLAR_FIRST = "solar_first"
|
|
LEFTOVER_SOLAR = "leftover_solar"
|
|
CHEAP_HOURS = "cheap_hours" # OPT-11 P3
|
|
|
|
# The modes a battery may be set to, in the order the page and the HA select
|
|
# list them. The first is the default when the store has no value. A new mode
|
|
# is appended, never inserted: automations send the HA labels (P2).
|
|
MODES = (SOLAR_FIRST, LEFTOVER_SOLAR, CHEAP_HOURS)
|
|
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" # OPT-11 P3: sell in the sell run
|
|
|
|
|
|
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 selling."""
|
|
use: bool = True
|
|
mode: str = DEFAULT_MODE
|
|
sell: bool = False
|
|
|
|
@property
|
|
def can_sell(self) -> bool:
|
|
"""Selling applies to the price modes only; Solar first never sells."""
|
|
return self.mode in (LEFTOVER_SOLAR, CHEAP_HOURS)
|
|
|
|
@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 switch. Always holds 0 W.
|
|
|
|
Off asks for 0 W both ways; On allows the rating both ways. main.py files
|
|
this intersected with the role range from `modetrade.decide()`.
|
|
"""
|
|
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`; `modetrade.weights()` adds the 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 in (LEFTOVER_SOLAR, CHEAP_HOURS)
|
|
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
|