"""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//` (store.unit_key()). USE_KEY = "pack_use" MODE_KEY = "pack_mode" SELL_KEY = "pack_sell" # OPT-11 P3: sell in the sell run # Owner decision 2026-09-29 (option A): selling is off until it can be judged # on the price the grid pays. While False, `read()` answers `sell=False` # whatever the store holds, the page shows no switch and `pack_sell` is # refused; the sell code in modetrade/priceruns stays, unreachable. SELLING_ENABLED = False # The modes that may charge from the grid in a cheap run with no day plan. # Leftover solar takes solar only then (owner decision 2026-09-29, option A). GRID_CHARGE_MODES = (CHEAP_HOURS,) # OPT-11 P4: with a fresh solar forecast, the day plan (dayplan.py) lets # Leftover solar charge from the grid, by the shortfall only. Cheap hours only # ignores the plan (fills to the ceiling); Solar first never grid-charges. PLAN_CHARGE_MODES = (LEFTOVER_SOLAR,) 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. False for every battery while SELLING_ENABLED is False.""" return SELLING_ENABLED and 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=SELLING_ENABLED and 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