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
+140 -17
View File
@@ -10,8 +10,9 @@ note, section 3.3.
The plan shows only what the add-on does today: trading on price (the
arbitrage verdict, the same call the control cycle makes), the monthly battery
cycle, the peak limit and "follows the house". OPT-11 P1 adds each battery's
switch and mode to its row; the mode plan arrives with P3/P4, so nothing here
invents one. Every sentence is in the words
switch and mode to its row; P3 adds each battery's price runs and its role this
cycle (`Controller.mode_runs`, `mode_tick`), read as the control cycle left
them, so nothing here invents a plan. Every sentence is in the words
section at the bottom and is held to STYLE.md by test_messages.py.
"""
@@ -20,7 +21,7 @@ from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
from .packmode import CHEAP_HOURS, LEFTOVER_SOLAR, SOLAR_FIRST
from .price import Absence, Uncertainty, brussels
from .static_tariff import static_plan
from .tariff import BILLING_TZ
@@ -312,6 +313,73 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
return runs, later, None
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
def mode_runs(c, day0, day1, now, shadow):
"""OPT-11 P3: each battery's price runs today, as the control cycle keeps
them (`c.mode_runs`): the cheap run on the batteries that may charge
from the grid, the give runs on Leftover solar and Cheap hours, and the
sell run on those with selling on. Amounts are upper bounds ("up to"),
from the battery's own room, rating and the run's length. Returns
`(runs, no_trade_key)`."""
rp = getattr(c, "mode_runs", None)
if getattr(c, "mode_tick", None) is None:
return [], None # no cycle yet: nothing is known
if rp is None:
return [], "no_prices"
if not rp.on:
return [], "not_worth"
floor, ceiling = arbitrage.soc_bounds(c.o)
out = []
for i, inv in enumerate(c.inverters):
pm = c.pack_mode(inv)
if not pm.use or pm.mode not in PRICE_MODES:
continue
soc, cap = inv.soc, inv.capacity_kwh
def room(kind, hrs):
kwh = inv.max_w / 1000.0 * hrs
if cap and soc is not None:
kwh = min(kwh, max(0.0, (ceiling - soc) if kind == "grid"
else (soc - floor)) / 100.0 * cap)
return kwh
blocks = []
if rp.charge_useful():
blocks.append(("grid", rp.cheap))
sell = rp.sell if pm.sell else None
for g in rp.give:
if sell is not None and g.start <= sell.start < g.end:
blocks.append(("sell", sell))
else:
blocks.append(("give", g))
for kind, run in blocks:
if run.end <= now or not (run.start < day1 and run.end > day0):
continue
hrs = (run.end - run.start).total_seconds() / 3600.0
kwh = room(kind, hrs)
if kwh < MIN_KWH:
continue
price = run.mean_eur_kwh
if kind == "grid":
what, why, amount = (run_grid_what(kwh, shadow),
run_cheap_why(price),
run_grid_amount(kwh * price))
elif kind == "sell":
what, why, amount = (run_sell_what(kwh, shadow),
run_dear_why(price),
run_give_amount(kwh * price))
else:
what, why, amount = (run_give_what(kwh, shadow),
run_dear_why(price),
run_give_amount(kwh * price))
out.append(_run(i, "give" if kind == "sell" else kind,
max(run.start, day0), min(run.end, day1), day0,
day1, what, why, amount, kwh=kwh,
eur=kwh * price, price=price))
return out, None
def maintenance_runs(c, day0, day1, now, shadow):
"""The monthly battery cycle: under way now, or starting later today.
The cycle starts in the hour `maintenance_start_hour` names, read as the
@@ -434,10 +502,17 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
runs, lines = [], []
# The control cycle trades on the fresh plan only, so this does too.
verdict, trade_why = trade_verdict(c, fresh)
modes_on = bool(getattr(c, "modes_on", False))
if running:
off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)}
runs += house_runs(len(c.inverters), day0, day1, now, shadow, off)
if verdict is not None:
if modes_on:
# OPT-11 P3: with battery modes on, each battery's price runs.
mine, no_trade = mode_runs(c, day0, day1, now, shadow)
runs += mine
if no_trade:
lines.append(line_no_trade(no_trade))
elif verdict is not None:
trade, later, no_trade = trade_runs(c, fresh, verdict, day0, day1,
now, shadow)
runs += trade
@@ -544,6 +619,11 @@ def battery_rows(c, shadow, running, held=False):
pms = {inv.name: c.pack_mode(inv) for inv in c.inverters} \
if modes_on else {}
peak = peak_engaged(c)
# OPT-11 P3: each battery's role this cycle and the price runs, as the
# control cycle left them.
tick = getattr(c, "mode_tick", None) if modes_on else None
rp = getattr(c, "mode_runs", None)
now = datetime.now(timezone.utc)
rows = []
for i, inv in enumerate(c.inverters):
m = c.maints[i] if i < len(c.maints) else None
@@ -558,13 +638,22 @@ def battery_rows(c, shadow, running, held=False):
# carries its share (SAFETY-73)?
other = next((o.name for o in c.inverters if o is not inv
and refusing(c, o) > 0 and not _off(c, o)), None)
role = None if tick is None else tick.roles.get(inv.name)
# Only once a cycle has run with modes on (`tick`): before that
# nothing is known, so nothing is claimed about the prices.
plan = (None if tick is None or pm.mode not in PRICE_MODES
else "no_prices" if rp is None
else "no_spread" if not rp.on else None)
nxt = rp.next_give(now) if rp is not None and rp.on else None
at = "" if nxt is None else (
f"{hhmm(nxt.start)} - {hhmm(nxt.end)}")
why = battery_why(
modes_on=modes_on, mode=None if pm is None else pm.mode, off=off,
reading=inv.soc is not None and inv.batt is not None,
phase=phase, shadow=shadow, running=running, held=held,
refused=refusing(c, inv), peak=peak,
giving=flow(inv.batt) == "giving", other_refuses=other,
sf_ahead=sf_ahead)
sf_ahead=sf_ahead, role=role, plan=plan, has_at=bool(at))
rows.append({
"name": inv.name,
"level_pct": None if inv.soc is None else float(inv.soc),
@@ -582,7 +671,7 @@ def battery_rows(c, shadow, running, held=False):
# UX-13 P3: why it does what "now" says, from what the add-on
# knows this cycle. Price and forecast reasons are OPT-11 P3/P4.
"why_key": why,
"why": why_words(why, name=other),
"why": why_words(why, name=other, at=at),
})
if modes_on:
# OPT-11 P1: the battery's switch and mode, for the Batteries
@@ -590,13 +679,18 @@ def battery_rows(c, shadow, running, held=False):
# without them.
rows[-1].update({"use": pm.use, "mode_key": pm.key,
"mode_choice": pm.mode,
"mode": mode_word(pm.key)})
"mode": mode_word(pm.key),
# P3: selling, for Leftover solar and Cheap
# hours only (Solar first never sells).
"sell": pm.sell, "can_sell": pm.can_sell,
"role": role})
return rows
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
held, refused, peak, sf_ahead, giving=False,
other_refuses=None) -> str:
other_refuses=None, role=None, plan=None,
has_at=False) -> str:
"""The key of one battery's "why" line (`home_words.why`), most urgent
first. Pure, so every branch is tested without a controller."""
if not reading:
@@ -620,18 +714,31 @@ def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
return "other_refuses"
if not modes_on:
return "modes_off"
# OPT-11 P3: the battery's role in the price runs this cycle.
if role in ROLE_WHY:
if role == "hold" and not has_at:
return "saves_later"
return ROLE_WHY[role]
if plan in ("no_prices", "no_spread"):
return plan
if giving:
return "covers_house" # both modes cover the house until P3
if mode == LEFTOVER_SOLAR:
return "covers_house"
if mode in PRICE_MODES:
return "leftover" if sf_ahead else "leftover_alone"
return "solar_first"
# OPT-11 P3: a role (modetrade) -> its Why key.
ROLE_WHY = {"charge": "grid_charge", "rest": "rests_charge",
"sell": "selling", "give": "expensive", "hold": "saves"}
def attach_today(rows, runs, *, modes_on, running=True, held=False,
shadow=False) -> None:
"""UX-13 P3: each battery's runs today from the plan (the same blocks as
Home's chart), or one line saying there are none. With battery modes on
and no run, the mode's own plan is OPT-11 P3/P4, so it says so."""
Home's chart), or one line saying there are none. With battery modes on,
a Leftover solar or Cheap hours battery with no price run left today
says so; the others follow the house."""
for i, row in enumerate(rows):
mine = [r for r in runs if r["battery"] == i and r["kind"] != "house"]
row["today"] = [{"from": r["from"], "to": r["to"], "kind": r["kind"],
@@ -644,8 +751,11 @@ def attach_today(rows, runs, *, modes_on, running=True, held=False,
row["today_line"] = today_words("stopped")
elif row.get("use") is False:
row["today_line"] = today_words("off")
elif modes_on:
row["today_line"] = today_words("not_planned")
elif modes_on and row.get("mode_choice") in PRICE_MODES:
# OPT-11 P3: a price mode with no run left today, or no plan
# made yet (no cycle has run with modes on).
row["today_line"] = today_words("no_runs" if row.get("role")
else "not_planned")
else:
row["today_line"] = today_words("house_would" if shadow
else "house")
@@ -954,10 +1064,11 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
return _t("now.resting")
def why_words(key, name=None) -> str:
def why_words(key, name=None, at="") -> str:
"""One battery's "why" line (UX-13 P3), from `battery_why()`'s key.
`name`: the battery that refuses, for `other_refuses`."""
return _t("why." + key, name=name or "")
`name`: the battery that refuses, for `other_refuses`; `at`: the
give run's time, for `saves`."""
return _t("why." + key, name=name or "", at=at)
def today_words(key) -> str:
@@ -989,6 +1100,18 @@ def run_grid_amount(eur) -> str:
return _t("run.grid_amount", eur=_eur(eur))
def run_sell_what(kwh, shadow) -> str:
return _w(shadow, "run.sell_what", kwh=_kwh(kwh))
def run_cheap_why(price) -> str:
return _t("run.cheap_why", eur=_eur(price))
def run_dear_why(price) -> str:
return _t("run.dear_why", eur=_eur(price))
def run_give_what(kwh, shadow) -> str:
return _w(shadow, "run.give_what", kwh=_kwh(kwh))