A battery's mode is a soft unit limit (source `mode`, P_MODE 35) kept
apart from the hard limits in pre_resolve(). The allocator applies it
only while the site target is decided at or below 35 (new
Resolution.deciding_priority), so the peak floor releases it and a mode
`hi` never meets the site `lo`. At N=1 resolve() applies the same rule
to the site target after stage 2. A mode range without 0 W is dropped
as a software fault, so REFUSE_SIGN stays unreachable from a mode.
compute() gets the loop bound (sum over live units of hard limits with
modes applied), so the loop never asks for watts no battery may give
and the saturation freeze never parks on them. While charging, Leftover
solar weighs 0 so Solar first fills first. Store keys pack_use and
pack_mode per battery, set by two new /action values; Off stops and
skips maintenance with a log line. Minimal Batteries cards (EN/NL).
Off by default (battery_modes_enabled); off, commands are byte-identical
to a5dddad at N=1 and N=2 (test_modes.py part 1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
104 lines
4.3 KiB
Python
104 lines
4.3 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
|
|
|
|
|
|
@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
|