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))
+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
+9 -3
View File
@@ -839,17 +839,22 @@ WHY_LIMIT = {
}
def why(winner, target_w, bound_by=None, *, contradiction=False) -> str:
def why(winner, target_w, bound_by=None, *, contradiction=False,
raised=False) -> str:
"""`winner` is the arbiter's winning source, or its reason for the two
that share a source ("stopped", "inputs missing", "no claim was made").
`bound_by` is `Resolution.bound_by`, e.g. "peak(quarter peak)"."""
`bound_by` is `Resolution.bound_by`, e.g. "peak(quarter peak)".
`raised` is True when that limit pushed the power UP (the peak limit
asking for more): then it did not hold anything back (OPT-11 P1 test F2).
"""
if contradiction:
return "Two limits clash, so every inverter is set to 0 W"
out = f"{WHY_WINNER.get(winner, 'Other')}: {signed_watts(target_w)}"
if bound_by:
limit = WHY_LIMIT.get(bound_by) or WHY_LIMIT.get(
str(bound_by).split("(")[0], "another limit")
out += f", held back by {limit}"
out += f", raised for {limit}" if raised else \
f", held back by {limit}"
return out
@@ -922,6 +927,7 @@ HINTS = {
"pack_on": "{unit}: battery switched on",
"pack_off": "{unit}: battery switched off, it is asked for 0 W",
"pack_mode": "{unit}: battery mode changed",
"pack_sell": "{unit}: selling switched",
"maint_off": "{unit}: maintenance stopped, the battery is switched off",
"mode_fault": "{unit}: battery mode ignored because of a software fault",
}
+230
View File
@@ -0,0 +1,230 @@
"""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):
* every range holds 0 W (a mode may forbid a direction, never force one);
* a Solar first battery never charges from the grid (no forecast yet, P4);
* 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 the 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 a 3.5 kW house);
* 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.
"""
from dataclasses import dataclass
from .packmode import CHEAP_HOURS, LEFTOVER_SOLAR, 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
@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
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 chargers(packs, ceiling_pct, done=()) -> list:
"""Batteries that may charge from the grid in a cheap run (P3: no
forecast, so Leftover solar acts as Cheap hours only; owner answer 4)."""
return [p for p in packs
if p.use and p.idle and p.live and p.mode in PRICE_MODES
and p.soc is not None and p.soc < ceiling_pct
and p.refused >= 0 and p.name not in done]
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=()):
"""`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`."""
ch = chargers(packs, ceiling_pct, done_charge)
se = sellers(packs, floor_pct, done_sell)
return (sum(_room_kwh(p, ceiling_pct) 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=()) -> 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.
"""
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) if in_cheap else []
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:
ask = -sum(abs(p.rating_w) for p in ch)
return Tick(ranges, roles, CHARGE, ask, None)
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:
cap = min(-float(tick.ask_w), -float(lo),
float(headroom_w) if headroom_w is not None else 0.0)
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
+2 -1
View File
@@ -453,7 +453,8 @@ def discovery_payloads(units=(), omit=(), include=(), modes=()) -> list:
#
# The select's options are these labels, in `packmode.MODES` order. A new mode
# (P3) is a new entry here and a new option at the end; no id changes.
MODE_LABELS = {"solar_first": "Solar first", "leftover_solar": "Leftover solar"}
MODE_LABELS = {"solar_first": "Solar first", "leftover_solar": "Leftover solar",
"cheap_hours": "Cheap hours only"} # OPT-11 P3
USE_ON, USE_OFF = "ON", "OFF"
# A longer command payload is refused before it is decoded.
MAX_COMMAND_BYTES = 64
+25 -21
View File
@@ -1,4 +1,4 @@
"""OPT-11 P1: each battery's mode and on/off switch, as limits and weights.
"""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.
@@ -14,13 +14,11 @@ Two rules hold for every mode, and they are what keeps modes safe:
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.
`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
@@ -29,17 +27,18 @@ from .store import unit_key
SOLAR_FIRST = "solar_first"
LEFTOVER_SOLAR = "leftover_solar"
CHEAP_HOURS = "cheap_hours" # reserved for P3; not accepted in P1
CHEAP_HOURS = "cheap_hours" # OPT-11 P3
# 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)
# 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" # reserved for P3
SELL_KEY = "pack_sell" # OPT-11 P3: sell in the sell run
def enabled(opts) -> bool:
@@ -50,10 +49,15 @@ def enabled(opts) -> bool:
@dataclass(frozen=True)
class PackMode:
"""One battery's choice: on or off, its mode, and (P3) selling."""
"""One battery's choice: on or off, its mode, and selling."""
use: bool = True
mode: str = DEFAULT_MODE
sell: bool = False # reserved for P3, never read in P1
sell: bool = False
@property
def can_sell(self) -> bool:
"""Selling applies to the price modes only; Solar first never sells."""
return self.mode in (LEFTOVER_SOLAR, CHEAP_HOURS)
@property
def key(self) -> str:
@@ -73,11 +77,10 @@ def read(data: dict, name: str) -> PackMode:
def mode_range(pm: PackMode, rating_w: float) -> tuple:
"""`(lo, hi)` in W for this battery's mode claim. Always holds 0 W.
"""`(lo, hi)` in W for this battery's switch. 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.
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
@@ -92,7 +95,7 @@ def charge_weights(base, modes: dict, target_w: float):
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).
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
@@ -102,7 +105,8 @@ def charge_weights(base, modes: dict, target_w: float):
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])
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
+203
View File
@@ -0,0 +1,203 @@
"""OPT-11 P3: price runs. Which consecutive firm price slots are cheap, which
are expensive, and which one a battery may sell in.
Pure: prices, the time and a few energy figures in, a `RunPlan` out. No I/O,
no clock. `modetrade.py` turns the plan into each battery's range and the
site's trade; main.py rebuilds the plan at most once per 15 minutes and keeps
a run that has started until its end (`keep_started()`).
The owner's SAFETY-46 answer (2026-09-25): a trade is a run of consecutive
slots, not one 15-minute interval. OPT-11-design section 2 ("Prices") and
DECISIONS/OPT-11-battery-modes.md have the record.
* The CHEAP run is the cheapest consecutive firm window long enough to put
the planned energy in at the chargers' combined rating.
* A slot is EXPENSIVE when its price is above the cheap run's mean by at
least the required spread (`cycle_cost_threshold_eur`, widened as
`TariffPlan.arbitrage()` widens it). Give runs are the maximal consecutive
expensive slots. Holding batteries cover the house in them. The mean is
the lower of the next cheap run's and `ref_eur_kwh`, the cheap run the
batteries last filled in (main.py keeps it for 12 h): energy bought at
03:00 is still worth that price at 08:00, when the next cheap run in sight
is today's dearer midday (measured: without it a Cheap hours battery spent
its night charge on flat morning prices, then had nothing at 19:00).
* The SELL run is the dearest consecutive window inside one give run long
enough to sell the sellers' energy at their rating, with no slot below 0
(section 17 of the spec: never export at a negative price).
* A provisional (carried) price is never part of any run (section 8.3).
"""
import math
from dataclasses import dataclass
from datetime import datetime, timedelta
from .price import _cover_table
# How far ahead runs are looked for. A day: tonight's cheap hours and the next
# evening's expensive ones, without holding a battery for a peak two days out.
HORIZON = timedelta(hours=24)
# No plan, and why (the page words are in homeview / ui_text).
NO_PRICES = "no_prices" # fewer than two firm slots ahead
NOT_WORTH = "not_worth" # no slot clears the required spread
@dataclass(frozen=True)
class Run:
start: datetime
end: datetime
mean_eur_kwh: float
def holds(self, when: datetime) -> bool:
return self.start <= when < self.end
@dataclass(frozen=True)
class RunPlan:
"""What the prices say for the next day. `reason` is None when there is
a plan (a cheap run and at least one give run), else why there is none."""
cheap: Run | None
give: tuple
sell: Run | None
level_eur_kwh: float | None
reason: str | None
# A slot after the cheap run clears the spread over the CHEAP RUN'S OWN
# price (not the reference): only then is buying in it worth a cycle.
charge_worth: bool = False
@property
def on(self) -> bool:
return self.reason is None
def give_at(self, when):
return next((g for g in self.give if g.holds(when)), None)
def next_give(self, when):
"""The give run under way at `when`, else the next one, else None."""
return next((g for g in self.give if g.end > when), None)
def charge_useful(self) -> bool:
"""Charging in the cheap run pays only if, after it, a slot clears
the spread over the cheap run's own price."""
return self.cheap is not None and self.charge_worth
def firm_slots(points, now: datetime, horizon: timedelta = HORIZON) -> list:
"""`[(start, end, eur_kwh)]`, ascending: the firm slots that have not
ended and start within the horizon. A slot's span is TARIFF-14's rule
(`price._cover_table()`), so 15-minute and hourly series both work."""
out = []
for start, end, p in _cover_table(points):
if p.provisional or end <= now or start >= now + horizon:
continue
if end > start:
out.append((start, end, float(p.eur_per_kwh)))
return out
def _segments(slots) -> list:
"""Split slots into runs of consecutive ones (each starts where the last
ended). A gap or a provisional slot breaks a run."""
segs, cur = [], []
for s in slots:
if cur and s[0] != cur[-1][1]:
segs.append(cur)
cur = []
cur.append(s)
if cur:
segs.append(cur)
return segs
def _hours(s) -> float:
return (s[1] - s[0]).total_seconds() / 3600.0
def _mean(win) -> float:
h = sum(_hours(s) for s in win)
return sum(_hours(s) * s[2] for s in win) / h
def _windows(segs, need_h):
"""Every consecutive window that lasts at least `need_h` hours, shortest
such from each start. If no segment is that long, each whole segment."""
out = []
for seg in segs:
for i in range(len(seg)):
h, j = 0.0, i
while j < len(seg) and h < need_h - 1e-9:
h += _hours(seg[j])
j += 1
if h >= need_h - 1e-9:
out.append(seg[i:j])
return out or [seg for seg in segs]
def need_hours(kwh, kw, slots) -> float:
"""How long a run must last: `kwh` at `kw`, and at least one slot."""
one = min((_hours(s) for s in slots), default=1.0)
if not (kwh and kw) or kwh <= 0 or kw <= 0:
return one
return max(one, float(kwh) / float(kw))
def plan(points, now: datetime, *, required_eur_kwh: float, charge_kwh,
charge_kw, sell_kwh, sell_kw, horizon: timedelta = HORIZON,
ref_eur_kwh=None) -> RunPlan:
"""The run plan for the next `horizon`. Ties go to the earliest window."""
if not (isinstance(required_eur_kwh, (int, float))
and math.isfinite(required_eur_kwh) and required_eur_kwh >= 0):
raise ValueError("required_eur_kwh must be a finite number >= 0")
slots = firm_slots(points, now, horizon)
if len(slots) < 2:
return RunPlan(None, (), None, None, NO_PRICES)
segs = _segments(slots)
wins = _windows(segs, need_hours(charge_kwh, charge_kw, slots))
best = min(wins, key=lambda w: (round(_mean(w), 9), w[0][0]))
cheap = Run(best[0][0], best[-1][1], _mean(best))
base = cheap.mean_eur_kwh if ref_eur_kwh is None else min(
cheap.mean_eur_kwh, float(ref_eur_kwh))
level = base + float(required_eur_kwh)
# Expensive: clears the spread, strictly dearer than the cheap mean, and
# not part of the cheap run itself (a threshold of 0 must not overlap).
dear = [s for s in slots
if s[2] >= level and s[2] > cheap.mean_eur_kwh
and not (cheap.start <= s[0] < cheap.end)]
give_segs = _segments(dear)
give = tuple(Run(g[0][0], g[-1][1], _mean(g)) for g in give_segs)
worth = any(s[0] >= cheap.end and s[2] >= cheap.mean_eur_kwh
+ float(required_eur_kwh) for s in dear)
if not give:
return RunPlan(cheap, (), None, level, NOT_WORTH)
# Sell: inside one give run, never over a slot priced below 0.
sellable = [part for g in give_segs
for part in _segments([s for s in g if s[2] >= 0.0])]
sell = None
if sellable:
sw = _windows(sellable, need_hours(sell_kwh, sell_kw, slots))
top = max(sw, key=lambda w: (round(_mean(w), 9),
-w[0][0].timestamp()))
sell = Run(top[0][0], top[-1][1], _mean(top))
return RunPlan(cheap, give, sell, level, None, worth)
def keep_started(old: RunPlan | None, new: RunPlan, now: datetime) -> RunPlan:
"""A run under way keeps going to its end (no flapping on a new plan): the
cheap run, the give run and the sell run each come from `old` while `old`
holds `now` in it. The rest is `new`. A safety or peak rule can still stop
a run at any tick; that is not decided here."""
if old is None or not old.on:
return new
cheap = old.cheap if old.cheap and old.cheap.holds(now) else new.cheap
sell = old.sell if old.sell and old.sell.holds(now) else new.sell
g_old = old.give_at(now)
give = new.give
if g_old is not None:
give = (g_old,) + tuple(g for g in new.give
if g.start >= g_old.end)
if cheap is new.cheap and sell is new.sell and g_old is None:
return new
worth = old.charge_worth if cheap is old.cheap else new.charge_worth
return RunPlan(cheap, give, sell, new.level_eur_kwh if new.on
else old.level_eur_kwh, None if give else new.reason,
worth)
+13 -10
View File
@@ -314,11 +314,11 @@
/* ---------------- Batteries (UX-13 P3) ---------------- */
// One card per battery. With battery modes on (`mode_key` in /status) each
// card has an On/Off switch and a mode list, sent as `pack_use` and
// `pack_mode` through /action (OPT-11 P1). Selling and cheap-hours charging
// are shown greyed out until OPT-11 P3. The Now, Today and Why lines are
// `pack_mode` through /action (OPT-11 P1), and a selling switch, sent as
// `pack_sell` (P3; Leftover solar and Cheap hours only). The Now, Today and Why lines are
// homeview.py's sentences. The Simple settings view draws the same switch
// and list into its own card (simpleHost).
const MODES = ["solar_first", "leftover_solar"];
const MODES = ["solar_first", "leftover_solar", "cheap_hours"];
const say = (text, vars) => Object.keys(vars).reduce((x, k) => x.replace("{" + k + "}", vars[k]), text);
let packBusy = false;
let simpleHost = null;
@@ -331,6 +331,7 @@
const j = await r.json().catch(() => ({}));
if (!r.ok) toast(j.error || t("toast.failed"));
else if (what === "pack_use") toast(say(value ? t("batteries.toast.on") : t("batteries.toast.off"), { name: b.name }));
else if (what === "pack_sell") toast(say(value ? t("batteries.toast.sell_on") : t("batteries.toast.sell_off"), { name: b.name }));
else toast(say(t("batteries.toast.mode"), { name: b.name, mode: t("batteries.choice." + value + ".name") }));
} catch (e) { toast(t("toast.failed")); }
finally { packBusy = false; }
@@ -345,15 +346,17 @@
function modeSelect(b, i, where, lid) {
return h("select", { class: "sel", id: "bsel-" + where + i, "aria-labelledby": lid, disabled: !b.use,
onchange: (e) => packAct("pack_mode", b, e.target.value) },
...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name"))),
h("option", { value: "", disabled: true }, t("batteries.later")));
...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name"))));
}
function sellRow(i, where) {
function sellRow(b, i, where) {
const lid = "bsl-" + where + i;
const can = b.use && b.can_sell === true;
return h("div", { class: "f inline" },
h("div", {}, h("div", { class: "lbl", id: lid }, t("batteries.sell.name")), h("div", { class: "help" }, t("batteries.sell.description"))),
h("div", { class: "ctl" }, h("button", { type: "button", class: "switch", role: "switch", "aria-checked": "false",
"aria-labelledby": lid, disabled: true })));
h("div", {}, h("div", { class: "lbl", id: lid }, t("batteries.sell.name")),
h("div", { class: "help" }, b.can_sell === true ? t("batteries.sell.description") : t("batteries.sell.off"))),
h("div", { class: "ctl" }, h("button", { type: "button", class: "switch", role: "switch", id: "bsw-sell-" + where + i,
"aria-checked": String(can && b.sell === true), "aria-labelledby": lid, disabled: !can,
onclick: () => packAct("pack_sell", b, !(b.sell === true)) })));
}
function todayLines(b) {
if (!b.today || !b.today.length) return b.today_line || "–";
@@ -373,7 +376,7 @@
h("label", { class: "lbl small", for: "bsel-c" + i }, t("batteries.mode")),
modeSelect(b, i, "c", null),
h("div", { class: "help" }, t("batteries.choice." + b.mode_key + ".description")),
sellRow(i, "c")) : null,
sellRow(b, i, "c")) : null,
levelBar(b.level_pct, b.name),
h("dl", { class: "kv" },
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
+22 -8
View File
@@ -87,19 +87,20 @@
"on": "On", "off": "Off",
"choice": {
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."},
"leftover_solar": {"name": "Leftover solar", "description": "Charges from solar the other batteries cannot take, and covers the house."},
"leftover_solar": {"name": "Leftover solar", "description": "Gets solar the others cannot take, charges in cheap hours and gives power in expensive ones."},
"cheap_hours": {"name": "Cheap hours only", "description": "Charges in the cheapest hours and gives power in the expensive ones."},
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
},
"later": "Cheap hours only, coming later",
"today": "Today", "why": "Why",
"sell": {"name": "Sell in expensive hours", "description": "Selling comes in a later update."},
"sell": {"name": "Sell in expensive hours", "description": "Also sells to the grid in the most expensive hours.", "off": "Not for Solar first."},
"modes_off": {"name": "Battery modes are off"},
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
"leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, and also covers the house."},
"leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, charges in cheap hours, and saves its power for expensive hours."},
"cheap_hours": {"description": "Cheap hours only charges in the cheapest hours and gives power in the expensive ones."},
"off": {"description": "Off rests the battery and starts no monthly cycle, but the peak limit may still use it."},
"rules": {"description": "The peak limit and the safety stops always come first, and charging from the grid and selling come later."}},
"toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}."}},
"rules": {"description": "The peak limit and the safety stops come first, and while one battery charges from the grid the others rest."}},
"toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}.", "sell_on": "{name} sells in expensive hours.", "sell_off": "{name} does not sell."}},
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
},
"choices_later": {
@@ -250,7 +251,7 @@
},
"home_words": {
"mode_house": "Follows the house",
"modes": {"solar_first": "Solar first", "leftover_solar": "Leftover solar", "off": "Off"},
"modes": {"solar_first": "Solar first", "leftover_solar": "Leftover solar", "cheap_hours": "Cheap hours only", "off": "Off"},
"attn_shadow": "Shadow mode is on",
"attn_shadow_body": "The add-on decides everything, but every inverter stays at 0 W.",
"zero_held": "Held at 0 W",
@@ -326,10 +327,19 @@
"leftover": "It gets the solar that Solar first batteries cannot take.",
"leftover_alone": "No Solar first battery is on, so it gets solar first.",
"covers_house": "It gives power to cover the house.",
"other_refuses": "{name} refuses to give power, so this battery takes over."
"other_refuses": "{name} refuses to give power, so this battery takes over.",
"grid_charge": "Prices are low, so it charges from the grid.",
"rests_charge": "Another battery charges from the grid, so it rests.",
"selling": "Prices are high, so it sells to the grid.",
"expensive": "Prices are high, so it covers the house.",
"saves": "Saves its power for {at}.",
"saves_later": "Saves its power for higher prices.",
"no_prices": "No prices, so it follows the house.",
"no_spread": "Prices differ too little, so it follows the house."
},
"today": {
"not_planned": "Not planned yet.",
"no_runs": "No cheap or expensive hours left today.",
"off": "Off, so no monthly cycle today.",
"house": "Follows the house.",
"house_would": "Would follow the house.",
@@ -356,6 +366,10 @@
"give_what": "Gives up to {kwh}, to the house first.",
"give_what_would": "Would give up to {kwh}, to the house first.",
"give_why": "Highest published price, {eur} per kWh.",
"sell_what": "Sells up to {kwh}, to the house first.",
"sell_what_would": "Would sell up to {kwh}, to the house first.",
"cheap_why": "Cheapest hours, about {eur} per kWh.",
"dear_why": "Expensive hours, about {eur} per kWh.",
"give_amount": "Worth up to {eur} at that price.",
"cycle_start": "Starts the monthly battery cycle.",
"cycle_start_would": "Would start the monthly battery cycle.",
+22 -8
View File
@@ -84,19 +84,20 @@
"on": "Aan", "off": "Uit",
"choice": {
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."},
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Laadt met zonne-energie die de andere batterijen niet opnemen, en dekt het huis."},
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Krijgt zonne-energie die de andere niet opnemen, laadt in goedkope uren en levert in dure uren."},
"cheap_hours": {"name": "Alleen goedkope uren", "description": "Laadt in de goedkoopste uren en levert in de dure uren."},
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
},
"later": "Alleen goedkope uren, volgt later",
"today": "Vandaag", "why": "Waarom",
"sell": {"name": "Verkopen in dure uren", "description": "Verkopen komt in een latere update."},
"sell": {"name": "Verkopen in dure uren", "description": "Verkoopt ook aan het net in de duurste uren.", "off": "Niet voor Eerst zonne-energie."},
"modes_off": {"name": "Batterijmodi staan uit"},
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat de batterijen met Eerst zonne-energie niet opnemen, en dekt ook het huis."},
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat de batterijen met Eerst zonne-energie niet opnemen, laadt in goedkope uren en spaart haar stroom voor dure uren."},
"cheap_hours": {"description": "Alleen goedkope uren laadt in de goedkoopste uren en levert in de dure uren."},
"off": {"description": "Uit laat de batterij rusten en start geen maandelijkse cyclus, maar de piekgrens mag haar nog gebruiken."},
"rules": {"description": "De piekgrens en de veiligheidsstops gaan altijd voor, en laden van het net en verkopen volgen later."}},
"toast": {"on": "{name} staat aan.", "off": "{name} staat uit.", "mode": "{name}: {mode}."}},
"rules": {"description": "De piekgrens en de veiligheidsstops gaan voor, en terwijl een batterij van het net laadt rusten de andere."}},
"toast": {"on": "{name} staat aan.", "off": "{name} staat uit.", "mode": "{name}: {mode}.", "sell_on": "{name} verkoopt in dure uren.", "sell_off": "{name} verkoopt niet."}},
"settings": {"name": "Instellingen", "description": "De instellingen staan hieronder per taak.", "open": "Instellingen openen"}
},
"choices_later": {"region": {"wallonia": "Wallonië, nog geen standaardwaarde", "brussels": "Brussel, nog geen standaardwaarde"}},
@@ -224,7 +225,7 @@
},
"home_words": {
"mode_house": "Volgt het huis",
"modes": {"solar_first": "Eerst zonne-energie", "leftover_solar": "Overschot zonne-energie", "off": "Uit"},
"modes": {"solar_first": "Eerst zonne-energie", "leftover_solar": "Overschot zonne-energie", "cheap_hours": "Alleen goedkope uren", "off": "Uit"},
"attn_shadow": "Schaduwmodus staat aan",
"attn_shadow_body": "De add-on beslist alles, maar elke omvormer blijft op 0 W.",
"zero_held": "Op 0 W gehouden",
@@ -268,10 +269,19 @@
"leftover": "Ze krijgt de zonne-energie die de batterijen met Eerst zonne-energie niet opnemen.",
"leftover_alone": "Geen batterij met Eerst zonne-energie staat aan, dus zij krijgt eerst zonne-energie.",
"covers_house": "Ze levert stroom om het huis te dekken.",
"other_refuses": "{name} weigert te leveren, dus deze batterij neemt het over."
"other_refuses": "{name} weigert te leveren, dus deze batterij neemt het over.",
"grid_charge": "De prijs is laag, dus ze laadt van het net.",
"rests_charge": "Een andere batterij laadt van het net, dus deze rust.",
"selling": "De prijs is hoog, dus ze verkoopt aan het net.",
"expensive": "De prijs is hoog, dus ze dekt het huis.",
"saves": "Spaart haar stroom voor {at}.",
"saves_later": "Spaart haar stroom voor hogere prijzen.",
"no_prices": "Geen prijzen, dus ze volgt het huis.",
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis."
},
"today": {
"not_planned": "Nog niet gepland.",
"no_runs": "Vandaag geen goedkope of dure uren meer.",
"off": "Uit, dus vandaag geen maandelijkse cyclus.",
"house": "Volgt het huis.",
"house_would": "Zou het huis volgen.",
@@ -298,6 +308,10 @@
"give_what": "Levert tot {kwh}, eerst aan het huis.",
"give_what_would": "Zou tot {kwh} leveren, eerst aan het huis.",
"give_why": "Hoogste gepubliceerde prijs, {eur} per kWh.",
"sell_what": "Verkoopt tot {kwh}, eerst aan het huis.",
"sell_what_would": "Zou tot {kwh} verkopen, eerst aan het huis.",
"cheap_why": "Goedkoopste uren, ongeveer {eur} per kWh.",
"dear_why": "Dure uren, ongeveer {eur} per kWh.",
"give_amount": "Tot {eur} waard aan die prijs.",
"cycle_start": "Start de maandelijkse batterijcyclus.",
"cycle_start_would": "Zou de maandelijkse batterijcyclus starten.",
+3 -2
View File
@@ -34,7 +34,8 @@ succeeds, so the page stops opening the wizard by itself (uipage.setup_needed).
UX-15's setup-only mode serves these routes from `setuponly.SetupOnly`, which
commands nothing; there `/action` answers 409.
OPT-11 P1 adds two `/action` values, `pack_use` and `pack_mode`, which set one
battery's on/off switch or mode in the store (Controller._pack_action). They
battery's on/off switch or mode in the store (Controller._pack_action); P3 adds
`pack_sell`, that battery's selling switch. They
are the only actions that name a battery; the configuration routes still reach
none of them.
"""
@@ -58,7 +59,7 @@ from .uimodel import DeriveError, options_to_ui
_LOG = logging.getLogger("goodwe.web")
# OPT-11: the per-battery actions, which name a battery and a value.
PACK_ACTIONS = ("pack_use", "pack_mode")
PACK_ACTIONS = ("pack_use", "pack_mode", "pack_sell")
# UX-13 P1: the page lives in app/static/ as three files - index.html,
# app.css, app.js - plus the page words from app/ui_text/<lang>.json (English