- Cheap hours only fills to the ceiling with or without a forecast, as the design (section 1) says. Skipping the sun's share cost 0.31 EUR on real 2026 prices (29.72 against P3's 29.41) and saved nothing on the P3 set. - A forecast reading from before local midnight is stale: until the integration refreshes, "left today" is still yesterday's 0 and "tomorrow" is really today. - Forecast entity ids must be sensor.[a-z0-9_]+ (they go into the /api/states/<id> path). - House profile: a stored day that is not a date, or is after today, is dropped instead of kept forever. - Text: the forecast line says "for the rest of today"; the learning line no longer promises "tomorrow" (a plan needs one full day). - Tests for four surviving review mutants (hour sample floor, the dear run after the cheap run, share by room, daytime use) and the fixes above. - Replay tool: `--fc zero` (a forecast of no sun). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
5.4 KiB
Python
130 lines
5.4 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
|
|
|
|
# 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
|