OPT-11 P3: price runs per battery
checks / unit (pull_request) Successful in 13m45s
infra-pin / fresh (pull_request) Successful in 1s

Cheap and expensive hours as runs of consecutive firm price slots
(priceruns.py); each battery's role per tick from its mode (modetrade.py):
grid charging for Leftover solar / Cheap hours only, never Solar first;
holding for the expensive runs; selling per battery (pack_sell) above the
spread and the floor. The site trade is sized inside the modes (loop bound)
and the peak guard's headroom; a run edge reseeds the loop. Cheap hours only
and selling are enabled on the Batteries screen and in the HA select
(appended). The site Why row no longer says "held back" when the peak limit
pushes power up (F2). Modes off: byte-identical to 7a00f12 on the trade path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude dev agent
2026-09-29 02:37:44 +02:00
parent 7a00f12c76
commit 902524ae5e
19 changed files with 2202 additions and 113 deletions
+292 -12
View File
@@ -37,11 +37,13 @@ import aiohttp
from . import arbitrage
from .arbiter import (
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_MODE, P_PEAK, P_SAFETY,
Claim, Resolution, actuator_for, pre_resolve, resolve,
ACTUATOR_SITE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_MODE, P_PEAK,
P_SAFETY, SET, Claim, Resolution, actuator_for, pre_resolve, resolve,
)
from . import allocator
from . import modetrade
from . import packmode
from . import priceruns
from . import control as _ctl
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .delivery import (
@@ -80,7 +82,9 @@ from . import packshare
from . import pv as pv_read
from . import shadow
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, brussels, price_option, provider_configured
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
from .price import required_spread as price_required_spread
from .price import uncertainty_of as price_uncertainty_of
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
@@ -226,6 +230,24 @@ def load_options() -> dict:
# OPT-11 P3: how long the last cheap run stays the price stored energy is
# valued at (`priceruns.plan(ref_eur_kwh=)`): the rest of that day.
REF_CHEAP_KEEP = timedelta(hours=12)
def why_raised(resolution, claims) -> bool:
"""F2 (OPT-11 P1 test): did the limit that bound the site target push it
ABOVE what the winner asked (the peak floor asking for more power)? Then
the Why line must not say "held back". False when nothing bound it."""
if not resolution.bound_by or resolution.contradiction:
return False
asked = next((c.value for c in sorted(
(c for c in claims if c.kind == SET
and c.actuator == ACTUATOR_SITE and c.source == resolution.winner),
key=lambda c: c.priority, reverse=True)), None)
return asked is not None and resolution.target_w > float(asked)
def pack_energy_of(inv):
"""OPT-09. ONE roster entry as the view `packshare` consumes.
@@ -909,6 +931,17 @@ class Controller:
self.modes_on = packmode.enabled(opts)
# The units whose mode fault is logged already (edge-logged).
self._mode_faults = set()
# OPT-11 P3: the price runs (`priceruns.RunPlan`, or None), the key
# they were built for, this tick's roles (`modetrade.Tick`), the
# batteries that left a run (full, or at the floor) keyed by the run,
# and whether last tick filed a mode trade (a run edge reseeds).
self.mode_runs = None
self._runs_key = None
self.mode_tick = None
self._run_done = {}
self._mode_trade_was = False
self._charge_blocked_q = None
self._ref_cheap = None
# OPT-11 P2: (topic, reason) of MQTT commands refused and logged once.
self._mqtt_refused = set()
self._mqtt_refused_more = 0
@@ -1982,7 +2015,11 @@ class Controller:
# OPT-11: the solar order. While the site charges, Leftover solar
# batteries weigh 0, so Solar first batteries fill first. A comfort
# choice like the rest of this method: permission is the mode claim.
return packmode.charge_weights(base, self.pack_modes(), target_w)
w = packmode.charge_weights(base, self.pack_modes(), target_w)
# P3: in a price run, the batteries whose run it is carry it first.
tick = self.mode_tick
return w if tick is None else modetrade.weights(tick.roles, w,
target_w)
def pack_mode(self, inv) -> "packmode.PackMode":
"""OPT-11. This battery's on/off switch and mode, read from the store."""
@@ -2011,6 +2048,214 @@ class Controller:
for u in self.units}
return allocator.loop_bound(bounds, live=self.live_units())
# --- OPT-11 P3: price runs, per battery -------------------------------
def _mode_packs(self, maint_results=None) -> list:
"""Each battery as `modetrade.Pack`, in roster order."""
live = set(self.live_units())
out = []
for i, inv in enumerate(self.inverters):
pm = self.pack_mode(inv)
idle = (maint_results is None
or maint_results[i].phase == IDLE)
refused = (inv.refused if len(self.inverters) > 1
else self.batt_refused) or 0
out.append(modetrade.Pack(
inv.name, pm.use, pm.mode, pm.sell and pm.can_sell,
float(inv.max_w), inv.soc, inv.capacity_kwh,
live=inv.name in live, idle=idle, refused=int(refused)))
return out
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
"""The kept run plan, rebuilt at most once per price slot (15 min) or
when the prices or a battery's choice change; a run under way is
kept to its end (`priceruns.keep_started()`). None with no fresh
firm prices: then no battery holds or trades (design section 5)."""
plan = self.fresh_tariff_plan(now)
if plan is None or not plan.has_prices:
self.mode_runs, self._runs_key = None, None
return None
slot = int(now.timestamp() // 900)
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
for p in packs))
if key != self._runs_key:
done_c = self._run_done.get("charge", (None, set()))[1]
done_s = self._run_done.get("sell", (None, set()))[1]
e = modetrade.energy(packs, floor_pct, ceiling_pct, done_c,
done_s)
required = price_required_spread(
arbitrage.threshold_eur_kwh(self.o),
price_uncertainty_of(plan.read.product)
| {Uncertainty.NO_WEATHER})
ref = self._ref_cheap
if ref is not None and now - ref.end > REF_CHEAP_KEEP:
ref = self._ref_cheap = None
new = priceruns.plan(
plan.read.points, now, required_eur_kwh=required,
charge_kwh=e[0], charge_kw=e[1], sell_kwh=e[2],
sell_kw=e[3],
ref_eur_kwh=None if ref is None else ref.mean_eur_kwh)
self.mode_runs = priceruns.keep_started(self.mode_runs, new, now)
self._runs_key = key
runs = self.mode_runs
# The cheapest cheap run under way lately is the price the stored
# energy was bought at; it stays the reference for REF_CHEAP_KEEP
# after it ends, unless a cheaper one comes.
ref = self._ref_cheap
if runs is not None and runs.cheap is not None \
and runs.cheap.holds(now) and (
ref is None or now - ref.end > REF_CHEAP_KEEP
or runs.cheap.mean_eur_kwh <= ref.mean_eur_kwh):
self._ref_cheap = runs.cheap
return runs
def _mode_roles(self, now, maint_results):
"""OPT-11 P3: each battery's role and range this tick. Grid charging
and selling need a fresh plan that `TariffPlan.arbitrage()` allows
with the MEASURED peak guard (None refuses: nothing measures the
quarter), exactly the gate `arbitrage.decide()` has."""
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
packs = self._mode_packs(maint_results)
runs = self._price_runs(now, packs, floor_pct, ceiling_pct)
guard = arbitrage.guard_state(self.peak_guard)
plan = self.fresh_tariff_plan(now)
charge_ok = sell_ok = False
trade_why = "not_covered"
def allowed(active):
return plan.arbitrage(
peak_guard_active=active,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER})).match(
allowed=lambda _v: True, refused=lambda _v: False)
if guard is not None and plan is not None and runs is not None:
# A charge asks the guard (section 9.1 interlock); a sell only
# needs it to have measured the quarter, since discharge is never
# capped by it (a big discharge alone engages it on export).
sell_ok = allowed(False)
charge_ok = sell_ok and allowed(guard)
trade_why = (None if charge_ok else "peak" if sell_ok
else "not_worth")
price_now = None
if plan is not None:
try:
pt = plan.read.match(series=lambda r: r.at(now),
no_data=lambda _r: None)
price_now = (None if pt is None or pt.provisional
else float(pt.eur_per_kwh))
except Absence:
price_now = None
house = (None if self.grid is None or self.site_batt_w is None
else float(self.grid) + float(self.site_batt_w))
done_c = self._run_mark("charge", runs and runs.cheap, packs,
lambda p: p.soc is not None
and p.soc >= ceiling_pct)
done_s = self._run_mark("sell", runs and runs.sell, packs,
lambda p: p.soc is not None
and p.soc <= floor_pct)
quarter = int(now.timestamp() // 900)
was = self.mode_tick is not None and \
self.mode_tick.action == modetrade.CHARGE
self.mode_tick = modetrade.decide(
runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok,
trade_why=trade_why, price_now=price_now,
headroom_w=self.arb_headroom_w, floor_pct=floor_pct,
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
blocked=self._charge_blocked_q == quarter,
done_charge=done_c, done_sell=done_s)
# A charge the guard or the headroom stopped stays off this period.
if was and self.mode_tick.action != modetrade.CHARGE and \
self.mode_tick.why in ("peak", "headroom"):
self._charge_blocked_q = quarter
return self.mode_tick
def _run_mark(self, kind, run, packs, out) -> set:
"""The batteries that left run `run` (full, or at the floor); they
stay out until it ends, so a level edge does not re-arm them."""
key = None if not run else (run.start, run.end)
was_key, done = self._run_done.get(kind, (None, set()))
if key != was_key:
done = set()
if key is not None:
done |= {p.name for p in packs if out(p)}
self._run_done[kind] = (key, done)
return done
def _mode_trade(self, claims, now) -> bool:
"""OPT-11 P3: file the site trade the roles ask for, sized inside the
modes and the hard limits (the loop bound) and, for a charge, the
billed quarter's headroom (SAFETY-45, aim cap - margin, SAFETY-79).
So no battery that its mode or a refusal holds is asked for watts it
may not give (P1's unmet share). Returns True when a trade is filed.
"""
tick = self.mode_tick
if tick is None:
return False
size = modetrade.size(tick, self.loop_bound(claims)
if tick.action else None, self.arb_headroom_w)
runs = self.mode_runs
if tick.action and (size == 0.0 or runs is None):
# Nothing the batteries may place (a refusal, a hard limit): no
# trade, and the roles say so rather than "charging".
back = {modetrade.CHARGE: modetrade.HOLD,
modetrade.REST: modetrade.HOUSE,
modetrade.SELL: modetrade.GIVE}
tick = self.mode_tick = modetrade.Tick(
tick.ranges, {n: back.get(r, r) for n, r in tick.roles.items()},
None, 0.0, "size")
if size == 0.0 or runs is None:
reason = ("battery modes: no price run now" if tick.why is None
else f"battery modes: no trade ({tick.why})")
self.arb = arbitrage.ArbitrageDecision(
action=None, power_w=None, reason=reason,
spread_eur_kwh=None, required_eur_kwh=None,
price_now_eur_kwh=None)
else:
charge = tick.action == modetrade.CHARGE
run = runs.cheap if charge else runs.sell
who = ", ".join(n for n, r in sorted(tick.roles.items())
if r == tick.action)
act = arbitrage.CHARGE if charge else arbitrage.DISCHARGE
self.arb = arbitrage.ArbitrageDecision(
action=act, power_w=size,
reason=(f"{act}: battery modes, "
f"{'cheap' if charge else 'sell'} run "
f"{run.start.isoformat()} to {run.end.isoformat()} "
f"at {run.mean_eur_kwh:.4f} EUR/kWh; "
f"{abs(size):.0f} W on {who}"),
spread_eur_kwh=None, required_eur_kwh=runs.level_eur_kwh,
price_now_eur_kwh=None)
claims.append(arbitrage.claim(self.arb))
if self.arb.action != self._arb_action:
self._arb_action = self.arb.action
self.log_event(self.arb.explain(), hint=messages.hint(
"trade_charge" if self.arb.action == arbitrage.CHARGE else
"trade_give" if self.arb.action == arbitrage.DISCHARGE
else "trade_off"))
return self.arb.action is not None
def _reseed_run_edge(self, claims) -> None:
"""OPT-11 P3: a mode trade just ended, so the loop takes over from a
command no loop chose. Start it at the house's own point E, the
command that brings the grid to its target now (`site battery + grid
- target`, inside the loop bound), instead of at the trade's watts
(the trade began with the same unslewed step). This tick's error still
measures the trade's command (it is exactly E - battery), so the
integrator is seeded that much short and lands on E after its step.
Measured (tools/opt11p3_replay.py, lagged battery, 2.5 kW charge
ending under an 800 W house): no reseed -258 W export then a 2976 W
import, settled in 55 s; this -257 W / +615 W, settled in 20 s.
"""
batt = self.site_batt_w
if batt is None or self.grid is None:
return
want = float(batt) + float(self.grid) - self.tuning.target_grid_w
bound = self.loop_bound(claims)
if bound is not None:
want = max(bound[0], min(bound[1], want))
want = float(round(max(-self.site_max_w, min(self.site_max_w, want))))
self.target = want
self.i_w = want - self.tuning.gain * (want - float(batt))
def _mode_fault_log(self, resolution) -> None:
"""OPT-11: say once, per battery, that its mode was dropped as a fault.
@@ -2032,7 +2277,8 @@ class Controller:
self._mode_faults = now
def _pack_action(self, what, unit, value, now) -> dict:
"""OPT-11: switch one battery on or off, or set its mode.
"""OPT-11: switch one battery on or off, set its mode, or (P3) turn
its selling on or off.
Store state, not an option, so it applies on the next cycle with no
restart. Written to the action log. Switching a battery off also stops
@@ -2056,6 +2302,13 @@ class Controller:
if not value:
self._maint_stop_off(self.maints[self.inverters.index(inv)],
now)
elif what == "pack_sell":
if not isinstance(value, bool):
return {"error": messages.pack_refused("value")}
inv.store_set(packmode.SELL_KEY, value)
self.log_event(f"{inv.name} selling switched "
f"{'on' if value else 'off'} by operator",
hint=messages.hint("pack_sell", unit=inv.name))
else:
if value not in packmode.MODES:
return {"error": messages.pack_refused("value")}
@@ -2064,7 +2317,10 @@ class Controller:
f"by operator",
hint=messages.hint("pack_mode", unit=inv.name))
pm = self.pack_mode(inv)
return {"unit": inv.name, "use": pm.use, "mode": pm.mode}
out = {"unit": inv.name, "use": pm.use, "mode": pm.mode}
if what == "pack_sell":
out["sell"] = pm.sell
return out
def mode_values(self) -> dict:
"""OPT-11 P2: each battery's mode and switch, for their HA state
@@ -3579,15 +3835,20 @@ class Controller:
# decision presented as current.
self.arb, self.arb_at = None, now
self.arb_headroom_w = None
if arbitrage.enabled(self.o):
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
# OPT-11 P3: with battery modes on, each battery's mode decides who
# trades (`_mode_roles()`, `_mode_trade()`, below, which read the
# same headroom), and this site-wide trade is not run.
site_trade = arbitrage.enabled(self.o) and not self.modes_on
if site_trade or self.modes_on:
# SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED. One evaluation of
# the shipped expression feeds both the sizing below and
# `arbitrage.sized_from_meter()` in `_shadow_settle()`, so the
# caveat on the recorded watts can never be answered from a
# different cycle's guard than the watts were.
self.arb_headroom_w = peakguard.charge_headroom_w(self.peak_guard)
if site_trade:
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
self.arb = arbitrage.decide(
self.fresh_tariff_plan(now), now,
peak_guard_active=arbitrage.guard_state(self.peak_guard),
@@ -3767,12 +4028,17 @@ class Controller:
# apart from the hard limits, and it gives way to the peak guard's
# site floor (P_PEAK) only as far as the floor needs, so the two never
# meet in a `lo > hi` (arbiter._unit_modes()).
# P3: the range is the switch's, narrowed by the battery's role in
# the price runs this tick (`modetrade.decide()`); both hold 0 W.
if self.modes_on:
tick = self._mode_roles(now, maint_results)
for inv, result in zip(self.inverters, maint_results):
if result.phase != IDLE:
continue
pm = self.pack_mode(inv)
lo, hi = packmode.mode_range(pm, inv.max_w)
rlo, rhi = tick.ranges.get(inv.name, (lo, hi))
lo, hi = max(lo, rlo), min(hi, rhi)
claims.append(Claim.limit("mode", P_MODE, lo, hi, pm.key,
actuator=self.actuator_of("mode",
inv)))
@@ -3823,6 +4089,13 @@ class Controller:
_ctl.REFUSED,
actuator=self.actuator_of("refused",
inv)))
# --- OPT-11 P3: the site trade, after every unit claim --------------
# Sized from what the batteries may place with their modes and hard
# limits (the loop bound), so none is asked for watts it may not give.
mode_trade, trade_was = False, self._mode_trade_was
if self.modes_on:
mode_trade = self._mode_trade(claims, now)
self._mode_trade_was = mode_trade
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware
@@ -3840,6 +4113,9 @@ class Controller:
# stale period as power.
if self.loop_w is None or self.target != self.loop_w:
self.i_w = self.target
# OPT-11 P3: a run edge (a mode trade ended) reseeds the loop.
if trade_was and not mode_trade:
self._reseed_run_edge(claims)
# SAFETY-52: another writer overrode the loop, so a refusal learned
# against the loop's own ask no longer describes the wire. A gap
# (`loop_w` None: blind or stopped) keeps it: the stale-reading stop
@@ -4172,7 +4448,8 @@ class Controller:
else:
self.why_words = messages.why(
_why, resolution.target_w, resolution.bound_by,
contradiction=resolution.contradiction)
contradiction=resolution.contradiction,
raised=why_raised(resolution, claims))
# --- DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ---------------------
# ⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the
# allocator's answer and `sum(shares) + unmet_w == round(target_w)` is
@@ -5934,8 +6211,11 @@ class Controller:
# instead of inferring it, and separates "trading is off" from "no
# decision was reached this cycle"; `cycle_at` and `arb_at` are what
# make the second one an observation rather than a guess.
# OPT-11 P3: with battery modes on the modes trade, whatever the
# switch says, so the row reports their decision.
("Arbitrage", arbitrage_row(
on=arbitrage.enabled(self.o), decision=self.arb,
on=arbitrage.enabled(self.o) or self.modes_on,
decision=self.arb,
decided_at=self.arb_at, cycle_at=self.cycle_at,
now=datetime.now(timezone.utc), why=self.why_words)),
]
@@ -5967,7 +6247,7 @@ class Controller:
async def handle_action(self, what: str, *, unit=None, value=None):
now = datetime.now(timezone.utc)
if what in ("pack_use", "pack_mode"):
if what in ("pack_use", "pack_mode", "pack_sell"):
return self._pack_action(what, unit, value, now)
if what == "auto_toggle":
self.auto = not self.auto