The probe's trade was sized at the probing battery's rating. Its SAFETY-73
pin held 200 W of that, and the allocator put the rest on a battery with no
mode range (one in a maintenance phase): TEST-23 OPT-15 g7, -2300 W on A in
its maintenance charge for 34 s, grid 3100 W.
modetrade.decide() now asks PROBE_W for the probing battery plus what the
other chargers would have had without the probe; size() adds the probe on top
of their share, or files PROBE_W exactly, or nothing. _mode_trade() carries
the pin only on the ticks it is filed, so the window's first and last cycle
do not move another battery's share either. Modes off and modes on with no
probe are unchanged (bf9019e digests). Tests: test_opt20.py.
Co-Authored-By: Claude <noreply@anthropic.com>
299 lines
14 KiB
Python
299 lines
14 KiB
Python
"""OPT-11 P3: each battery's role this tick, from its mode and the price runs.
|
|
|
|
Pure: no I/O, no clock. main.py hands in the run plan (`priceruns`), each
|
|
battery's state and the peak guard's answers, files each battery's range as
|
|
its `mode` claim (P_MODE), and files the site trade as the `arbitrage` claim
|
|
(P_ARBITRAGE), sized inside the modes (`size()`), only with battery modes on.
|
|
With them off, `arbitrage.decide()` runs exactly as before.
|
|
|
|
Roles (the page's Why line names them):
|
|
off Off: [0, 0].
|
|
house follows the house: charges from spare solar, gives power at any
|
|
hour (Solar first always; the other modes when there is no plan).
|
|
hold Leftover solar / Cheap hours outside a give run: may charge from
|
|
spare solar, gives nothing, so its energy is kept for the run.
|
|
give the same inside a give run: covers the house.
|
|
sell inside the sell run, with selling on: gives its rating, to the
|
|
house first and the rest to the grid.
|
|
charge inside the cheap run: charges from the grid.
|
|
rest another battery charges from the grid, so this one may not charge
|
|
(the owner's 2026-09-27 answer 3: while one battery charges from the
|
|
grid the others cannot give power either; the allocator never
|
|
splits a target both ways).
|
|
|
|
Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
|
* every range holds 0 W (a mode may forbid a direction, never force one);
|
|
* a Solar first battery never charges from the grid;
|
|
* P4: with a day plan (`targets`), a Leftover solar battery charges from
|
|
the grid only up to its planned level, never above the ceiling; with
|
|
none it takes solar only (P3 option A). Cheap hours only fills to the
|
|
ceiling either way (`charge_to()`);
|
|
* grid charging needs a measured peak guard that is clear and a measured
|
|
headroom (SAFETY-45); the trade is sized to that headroom, whose aim is
|
|
`peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or
|
|
more, and once the guard or a measured headroom stops it, it stays
|
|
stopped for the rest of that 15-minute period (`blocked`): a charge
|
|
makes the other batteries stop giving power, so a few watts of charge
|
|
flipping on and off would swing the house between them and the grid
|
|
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
|
|
* selling needs selling on for that battery, the sell run, a price >= 0
|
|
now, and the battery above `arbitrage_soc_floor`; it stops there. The
|
|
peak guard never stops a sell (section 9.1: discharge is never capped);
|
|
it must only have measured the quarter;
|
|
* a battery out of a run (full, or at the floor) stays out until the run
|
|
ends, so the ceiling and the floor do not re-arm within one run;
|
|
* a battery that refuses a direction (SAFETY-52/73) takes no part in a
|
|
run in that direction: the refusal wins over the run. The one exception
|
|
is its SAFETY-73 probe inside a cheap run (OPT-15): it is a charger then,
|
|
pinned at PROBE_W by its own claim, so the others rest (one sign) and the
|
|
card's "tries again every 15 minutes" holds in the run too;
|
|
* OPT-20: a probe asks PROBE_W for the probing battery and nothing more.
|
|
The rest of the ask is what the other chargers would have had without
|
|
the probe (none: PROBE_W alone), so a probe never places charge on a
|
|
battery in a maintenance phase or on one that rests;
|
|
* no grid charge while another battery drains for its monthly cycle
|
|
(`draining`, SAFETY-78): it would charge against the drain.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from .control import PROBE_W
|
|
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
|
|
PLAN_CHARGE_MODES, SOLAR_FIRST)
|
|
|
|
OFF, HOUSE, HOLD, GIVE, SELL, CHARGE, REST = (
|
|
"off", "house", "hold", "give", "sell", "charge", "rest")
|
|
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
|
|
|
|
# The smallest grid charge worth starting, in W. Under it the other batteries
|
|
# would stop covering the house for less than they give.
|
|
START_W = 500.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Pack:
|
|
"""One battery as this module needs it."""
|
|
name: str
|
|
use: bool
|
|
mode: str
|
|
sell: bool
|
|
rating_w: float
|
|
soc: float | None
|
|
capacity_kwh: float | None
|
|
live: bool = True
|
|
idle: bool = True # not in a maintenance phase
|
|
refused: int = 0 # SAFETY-52/73: +1 refuses to give, -1 to charge
|
|
probing: bool = False # OPT-15: its SAFETY-73 probe window is open
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Tick:
|
|
ranges: dict # name -> (lo, hi), only for idle batteries
|
|
roles: dict # name -> role
|
|
action: str | None # "charge" | "sell" | None
|
|
ask_w: float # signed site ask before sizing (0 if none)
|
|
why: str | None # why no trade when a run is on, else None
|
|
probe_w: float = 0.0 # OPT-20: the part of `ask_w` that is a probe
|
|
|
|
|
|
def _room_kwh(p: Pack, pct: float) -> float:
|
|
"""kWh to `pct` (charging) or above it (selling); a battery with no
|
|
capacity set counts one hour at its rating, so a run is still sized."""
|
|
if p.capacity_kwh is None or p.soc is None:
|
|
return abs(p.rating_w) / 1000.0
|
|
return max(0.0, abs(pct - p.soc)) / 100.0 * float(p.capacity_kwh)
|
|
|
|
|
|
def charge_to(p: Pack, ceiling_pct, targets=None):
|
|
"""The level this battery may charge to from the grid, or None.
|
|
|
|
`targets` is the day plan (`dayplan.plan()`: name -> level), or None
|
|
when there is none (no forecast, stale, or not configured). With no plan
|
|
this is P3 option A exactly: Cheap hours to the ceiling, the others never.
|
|
With a plan, a Leftover solar battery charges only to its planned level
|
|
(the sun fills the rest); a battery the plan leaves out, Cheap hours only
|
|
included, gets the no-plan answer. Solar first never charges."""
|
|
if p.mode in GRID_CHARGE_MODES:
|
|
return float(ceiling_pct)
|
|
if targets is None or p.name not in targets \
|
|
or p.mode not in PLAN_CHARGE_MODES:
|
|
return None
|
|
t = targets[p.name]
|
|
return None if t is None else min(float(ceiling_pct), float(t))
|
|
|
|
|
|
def chargers(packs, ceiling_pct, done=(), targets=None, probe=False) -> list:
|
|
"""Batteries that may charge from the grid in a cheap run: below the
|
|
level `charge_to()` allows them (P4: the day plan's). With `probe`, also
|
|
one refusing to charge while its SAFETY-73 probe is open: its own P_SAFETY
|
|
pin keeps it at PROBE_W (OPT-15)."""
|
|
out = []
|
|
for p in packs:
|
|
to = charge_to(p, ceiling_pct, targets)
|
|
if (to is not None and p.use and p.idle and p.live
|
|
and p.soc is not None and p.soc < to
|
|
and (p.refused >= 0 or (probe and p.probing))
|
|
and p.name not in done):
|
|
out.append(p)
|
|
return out
|
|
|
|
|
|
def sellers(packs, floor_pct, done=()) -> list:
|
|
"""Batteries that may sell in the sell run. Solar first never sells."""
|
|
return [p for p in packs
|
|
if p.use and p.idle and p.live and p.sell
|
|
and p.mode in PRICE_MODES
|
|
and p.soc is not None and p.soc > floor_pct
|
|
and p.refused <= 0 and p.name not in done]
|
|
|
|
|
|
def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
|
|
targets=None):
|
|
"""`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`.
|
|
With a day plan the charge is each battery's shortfall, so the cheap run
|
|
is only as long as the shortfall needs."""
|
|
ch = chargers(packs, ceiling_pct, done_charge, targets)
|
|
se = sellers(packs, floor_pct, done_sell)
|
|
return (sum(_room_kwh(p, charge_to(p, ceiling_pct, targets))
|
|
for p in ch),
|
|
sum(abs(p.rating_w) for p in ch) / 1000.0,
|
|
sum(_room_kwh(p, floor_pct) for p in se),
|
|
sum(abs(p.rating_w) for p in se) / 1000.0)
|
|
|
|
|
|
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
|
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
|
|
blocked=False, done_charge=(), done_sell=(),
|
|
targets=None, draining=False) -> Tick:
|
|
"""This tick's ranges, roles and site ask.
|
|
|
|
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
|
|
`charge_ok` is True only when `TariffPlan.arbitrage()` allowed a trade
|
|
this tick with the MEASURED peak guard clear; `sell_ok` when it allowed
|
|
one with the guard measured (either state); else `trade_why` says why.
|
|
`headroom_w` is `peakguard.charge_headroom_w()` (None: unmeasured).
|
|
`house_w` is the measured house (grid + batteries), or None.
|
|
`charging_now` says last tick charged (then any headroom above 0 keeps
|
|
it going); `blocked` that the guard or the headroom stopped a charge in
|
|
this 15-minute period. `targets` is the day plan (`charge_to()`).
|
|
`draining` says a battery drains for its monthly cycle, so none may
|
|
charge from the grid (why "cycle").
|
|
"""
|
|
plan_on = runs is not None and runs.on
|
|
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
|
|
in_give = plan_on and runs.give_at(now) is not None
|
|
in_sell = plan_on and runs.sell is not None and runs.sell.holds(now)
|
|
|
|
why = None
|
|
ch = chargers(packs, ceiling_pct, done_charge, targets, probe=True) \
|
|
if in_cheap else []
|
|
if ch and draining:
|
|
ch, why = [], "cycle"
|
|
charging = False
|
|
if ch:
|
|
need = 0.0 if charging_now else START_W
|
|
if blocked:
|
|
why = "headroom"
|
|
elif not charge_ok:
|
|
why = trade_why
|
|
elif headroom_w is None or not headroom_w > need:
|
|
why = "headroom"
|
|
else:
|
|
charging = True
|
|
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
|
|
else []
|
|
selling = False
|
|
if se:
|
|
if not sell_ok:
|
|
why = why or trade_why
|
|
elif price_now is None or price_now < 0.0 or house_w is None:
|
|
why = why or "negative"
|
|
else:
|
|
selling = True
|
|
|
|
ranges, roles = {}, {}
|
|
ch_names = {p.name for p in ch} if charging else set()
|
|
se_names = {p.name for p in se} if selling else set()
|
|
for p in packs:
|
|
r = abs(float(p.rating_w))
|
|
if not p.use:
|
|
role, lo, hi = OFF, 0.0, 0.0
|
|
elif p.name in ch_names:
|
|
role, lo, hi = CHARGE, -r, 0.0
|
|
elif charging:
|
|
# Another battery charges from the grid: this one may not charge
|
|
# (it would be grid charging too), and cannot give (one sign).
|
|
role, lo, hi = REST, 0.0, (r if p.mode == SOLAR_FIRST
|
|
or not plan_on or in_give else 0.0)
|
|
elif p.name in se_names:
|
|
role, lo, hi = SELL, -r, r
|
|
elif p.mode == SOLAR_FIRST or not plan_on:
|
|
role, lo, hi = HOUSE, -r, r
|
|
elif in_give:
|
|
role, lo, hi = GIVE, -r, r
|
|
else:
|
|
role, lo, hi = HOLD, -r, 0.0
|
|
roles[p.name] = role
|
|
if p.idle:
|
|
ranges[p.name] = (lo, hi)
|
|
|
|
if charging:
|
|
# OPT-20: a probing refuser is asked PROBE_W (its own pin holds it
|
|
# there); only the batteries that charge without a probe are asked
|
|
# for their rating.
|
|
probe_w = PROBE_W * sum(1 for p in ch if p.refused < 0)
|
|
ask = -sum(abs(p.rating_w) for p in ch if p.refused >= 0) - probe_w
|
|
return Tick(ranges, roles, CHARGE, ask, None, probe_w)
|
|
if selling:
|
|
ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w))
|
|
return Tick(ranges, roles, SELL, ask, None)
|
|
return Tick(ranges, roles, None, 0.0,
|
|
why if (in_cheap or in_sell) else None)
|
|
|
|
|
|
def size(tick: Tick, bound, headroom_w) -> float:
|
|
"""The site trade in W, inside what the batteries may place with their
|
|
modes (`bound` = main's loop bound) and, for a charge, the billed
|
|
quarter's headroom. 0.0 means no trade (a 0 W trade is a park order and
|
|
is never filed)."""
|
|
if tick.action is None or bound is None:
|
|
return 0.0
|
|
lo, hi = bound
|
|
if tick.action == CHARGE:
|
|
room = float(headroom_w) if headroom_w is not None else 0.0
|
|
pw = float(tick.probe_w)
|
|
if pw > 0.0:
|
|
# OPT-20: the others' share is sized as without the probe; the
|
|
# probe's PROBE_W is added on top (the grid rises by PROBE_W at
|
|
# most), and alone it is sized by the bound and the headroom.
|
|
rest = max(0.0, min(-float(tick.ask_w) - pw, -float(lo) - pw,
|
|
room))
|
|
if rest > 0.0:
|
|
return -(rest + pw)
|
|
# PROBE_W exactly or no trade: less would void the pin and hand
|
|
# the remainder to whichever battery the allocator can reach.
|
|
return -pw if min(-float(lo), room) >= pw else 0.0
|
|
cap = min(-float(tick.ask_w), -float(lo), room)
|
|
return -max(0.0, cap)
|
|
return max(0.0, min(float(tick.ask_w), float(hi)))
|
|
|
|
|
|
def weights(roles: dict, base, target_w: float):
|
|
"""Who carries the site target first (design 3.5). Selling: the sellers.
|
|
Giving power: batteries in a give run before Solar first. Charging from
|
|
the grid: only the chargers. Otherwise `base` unchanged. Comfort only;
|
|
the ranges above are the permission."""
|
|
names = sorted(roles)
|
|
first = None
|
|
if target_w > 0 and any(r == SELL for r in roles.values()):
|
|
first = {n for n in names if roles[n] == SELL}
|
|
elif target_w > 0 and any(r == GIVE for r in roles.values()):
|
|
first = {n for n in names if roles[n] in (GIVE, SELL)}
|
|
elif target_w < 0 and any(r == CHARGE for r in roles.values()):
|
|
first = {n for n in names if roles[n] == CHARGE}
|
|
if not first:
|
|
return base
|
|
w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names}
|
|
out = {n: (w[n] if n in first else 0.0) for n in names}
|
|
return out if any(v > 0.0 for v in out.values()) else base
|