UX-77: Home draws today's grid charge for the hours control will really charge
checks / unit (pull_request) Successful in 27m7s
infra-pin / fresh (pull_request) Successful in 1s

The cheap run is sized at the full rating, so under the peak limit a battery is
still short when it ends, and control plans the next run then. Home drew only
the run under way. `cheaproll` rolls the run on the way control does
(`modetrade.energy()` + `priceruns.plan()` at each run end, lifted from
`dayview._levels()`), reading the levels at the charge power the peak limit
leaves (peak aim less the house, else the rating). `homeview.mode_runs()` and
`price_windows()` use it for today: the grid block, the Today line and the
chart's cheap bars run to where control stops, each battery's block to its
own stop. Display only: no control change.

test_ux54: today's grid block of the night-before scenario now follows the roll.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-05 21:48:56 +01:00
co-authored by Claude
parent af2d0d2f61
commit 45535e19d7
7 changed files with 541 additions and 39 deletions
+19 -6
View File
@@ -19,7 +19,7 @@ section at the bottom and is held to STYLE.md by test_messages.py.
import contextlib
from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard, uitext
from . import arbitrage, cheaproll, messages, peakguard, priceruns, uitext
from .dayplan import EFF
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE, next_slot_start
@@ -218,14 +218,20 @@ def price_windows(c, plan, verdict, now=None):
"""`[(kind, start, end)]`: where the add-on itself buys ("cheap") and
gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan`
(`c.mode_runs`): the cheap run when charging in it pays and the trade gate
allows it (UX-53), and every give run. With them off it is the arbitrage
verdict's legs the add-on will take, the ones `trade_runs()` draws
(`trade_legs()`, given `now`). Nothing is classified here."""
allows it (UX-53), and every give run. UX-77: the cheap run is drawn to
where control stops charging as it rolls on (`cheaproll`). With them off
it is the arbitrage verdict's legs the add-on will take, the ones
`trade_runs()` draws (`trade_legs()`, given `now`). Nothing is classified
here."""
if getattr(c, "modes_on", False):
rp = getattr(c, "mode_runs", None)
if rp is None or not rp.on or not _price_mode_in_use(c):
return []
out = [("cheap", rp.cheap.start, rp.cheap.end)] \
end = rp.cheap.end
roll = None if now is None else cheaproll.for_today(c, rp, now)
if roll is not None:
end = roll.last(end) # UX-77: to where control stops
out = [("cheap", rp.cheap.start, end)] \
if rp.charge_useful() and getattr(c, "mode_gate", None) is None \
else []
return out + [("dear", g.start, g.end) for g in rp.give]
@@ -422,6 +428,7 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
`ahead=True`) a charge from the floor and a give from the ceiling. The
same filter, the same `MIN_KWH`. `rp`: a run plan to read instead of
`c.mode_runs`. Home never passes either."""
today = rp is None and not ahead
rp = rp if rp is not None else getattr(c, "mode_runs", None)
if getattr(c, "mode_tick", None) is None:
return [], None # no cycle yet: nothing is known
@@ -431,6 +438,9 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
return [], "not_worth"
floor, ceiling = arbitrage.soc_bounds(c.o)
gate = getattr(c, "mode_gate", None)
# UX-77: today's grid block runs to where control stops charging (the run
# rolls on when it ends), each battery's to its own stop.
roll = cheaproll.for_today(c, rp, now) if today else None
out = []
for i, inv in enumerate(c.inverters):
pm = c.pack_mode(inv)
@@ -465,7 +475,10 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
if gate is None and rp.charge_useful() and (
need > 0.0 if need is not None
else pm.mode in GRID_CHARGE_MODES):
blocks.append(("grid", rp.cheap))
cheap = rp.cheap if roll is None else priceruns.Run(
rp.cheap.start, roll.end_for(inv.name, rp.cheap.end),
rp.cheap.mean_eur_kwh)
blocks.append(("grid", cheap))
sell = rp.sell if pm.sell and pm.can_sell else None
for g in rp.give:
if sell is not None and g.start <= sell.start < g.end: