Files
goodwe-addon/goodwe_controller/app/packmode.py
T
adminandClaude Opus 5.5 b310282729
checks / unit (pull_request) Successful in 11m12s
infra-pin / fresh (pull_request) Successful in 2s
OPT-11 P2: HA entities for battery modes
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>
2026-09-28 23:12:04 +02:00

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