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
+170
View File
@@ -0,0 +1,170 @@
"""UX-77: where the cheap run rolls on. Display only; control is unchanged.
A cheap run is sized for the batteries charging together, at their full
rating, and starts at the first of the cheapest hours. The peak limit leaves
less power than that, so a battery is still short when the run ends, and
control plans the next run then (`Controller._price_runs()`). On a flat night
that goes on until the batteries are at their level or the cheap hours end.
`roll_end()` asks what control asks at each run end, of the same functions
(`modetrade.energy()`, `priceruns.plan()`), until nothing is short or the next
run does not follow on. `for_today()` reads the batteries at the level they
will have by then: the charge power is the peak limit's room (peak aim minus
the house, as `dayplan` counts it), shared by rating. Where that room is not
known (no house profile or no aim), the rating is used.
DECISIONS/UX-54-forecast-tab.md has the record.
"""
import dataclasses
import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from . import arbitrage, dayplan, modetrade, priceruns
from .dayplan import EFF
from .price import required_spread, uncertainty_of
SLOT_S = 900 # a price slot
STEP = timedelta(minutes=5) # how finely the level is followed
LIMIT = 24 # rolls at most (a day of one-hour runs)
def roll_end(run, points, packs_at, *, required, floor_pct, ceiling_pct,
targets=None, limit=LIMIT):
"""Where the cheap run `run` ends once control has rolled it on.
`packs_at(end)` gives the `modetrade.Pack`s as they stand when a run
ends at `end`. At each end the charge still short is asked of
`modetrade.energy()` and the next run of `priceruns.plan()`, as control
asks them. It stops when nothing is short or the next run does not follow
on, and is `run.end` when control would not roll it."""
end_t = run.end
for _ in range(limit):
e = modetrade.energy(packs_at(end_t), floor_pct, ceiling_pct,
targets=targets)
if e[0] <= 1e-6:
break
new = priceruns.plan(points, end_t, required_eur_kwh=required,
charge_kwh=e[0], charge_kw=e[1], sell_kwh=0.0,
sell_kw=0.0)
nxt = new.cheap
if nxt is None or not new.charge_useful() or nxt.start > end_t \
or nxt.end <= end_t:
break
end_t = nxt.end
return end_t
@dataclass(frozen=True)
class Roll:
"""`end`: where control stops charging, `stops`: name -> when that battery
reaches its level (rounded up to a price slot), None while it still
charges at `end`. A battery not in `stops` does not charge in the run."""
end: datetime
stops: dict
def end_for(self, name, base_end):
"""The end of `name`'s block: its stop, no later than `end`."""
if name not in self.stops:
return base_end
stop = self.stops[name]
return self.end if stop is None else min(self.end, stop)
def last(self, base_end):
"""The last end of any battery's block (the chart's cheap bar)."""
return max([base_end] + [self.end_for(n, base_end)
for n in self.stops])
def _ceil_slot(t):
return datetime.fromtimestamp(
math.ceil(t.timestamp() / SLOT_S) * SLOT_S, tz=t.tzinfo
or timezone.utc)
class _Charge:
"""The batteries' levels followed from `t0` while the chargers charge at
the room the peak limit leaves: `min(sum of ratings, aim - house)`, shared
by rating, each stopping at `modetrade.charge_to()`."""
def __init__(self, packs, t0, ceiling_pct, targets, aim_w, house):
self.packs = {p.name: p for p in packs}
self.t = t0
self.ceil = ceiling_pct
self.targets = targets
self.aim = aim_w
self.house = house
self.stops = {}
def at(self, end):
"""The packs when a run ends at `end`."""
self._to(end)
return list(self.packs.values())
def _room_w(self, hour, total):
if self.aim is None or self.house is None:
return total
house_w, base_w = self.house
return min(total, max(0.0, float(self.aim)
- max(house_w(hour), base_w, 0.0)))
def _to(self, end):
while self.t < end:
nxt = min(end, self.t + STEP)
for s, e, hour in dayplan._hour_slices(self.t, nxt):
self._charge(s, e, hour)
self.t = nxt
def _charge(self, s, e, hour):
ch = modetrade.chargers(list(self.packs.values()), self.ceil,
targets=self.targets)
total = sum(abs(p.rating_w) for p in ch)
if not ch or total <= 0.0:
return
w = self._room_w(hour, total)
hrs = (e - s).total_seconds() / 3600.0
for p in ch:
self.stops.setdefault(p.name, None)
if not p.capacity_kwh:
continue
to = modetrade.charge_to(p, self.ceil, self.targets)
kwh = w * abs(p.rating_w) / total * hrs / 1000.0 * EFF
room = (to - p.soc) / 100.0 * float(p.capacity_kwh)
if kwh <= 0.0:
continue
if kwh >= room - 1e-9:
self.stops[p.name] = _ceil_slot(
s + (e - s) * (room / kwh))
kwh = room
self.packs[p.name] = dataclasses.replace(
p, soc=p.soc + kwh / float(p.capacity_kwh) * 100.0)
def for_today(c, rp, now):
"""The `Roll` of the cheap run `rp.cheap` now under way or to come today,
or None when control does not roll it on (no run left, charging not
useful, the trade gate shut, nothing short at its end). Display only:
nothing here is fed back to control."""
if rp is None or not rp.on or rp.cheap is None or rp.cheap.end <= now \
or not rp.charge_useful() \
or getattr(c, "mode_gate", None) is not None:
return None
plan = c.fresh_tariff_plan(now)
if plan is None:
return None
floor, ceil = arbitrage.soc_bounds(c.o)
hp = getattr(c, "house_profile", None)
sim = _Charge(c._mode_packs(), max(now, rp.cheap.start), ceil,
c.plan_targets(), c.peak_aim_w(),
None if hp is None else hp.estimator())
required = required_spread(
arbitrage.threshold_eur_kwh(c.o),
uncertainty_of(plan.read.product)
| arbitrage.weather_uncertainty(True, c.solar_fc,
bool(c.weather_held(now))))
end = roll_end(rp.cheap, plan.read.points, sim.at, required=required,
floor_pct=floor, ceiling_pct=ceil,
targets=c.plan_targets())
if end <= rp.cheap.end:
return None
return Roll(end, dict(sim.stops))
+13 -19
View File
@@ -19,7 +19,8 @@ empty chart. DECISIONS/UX-54-forecast-tab.md has the record.
import dataclasses
from datetime import datetime, timedelta
from . import arbitrage, dayplan, homeview as hv, modetrade, priceruns
from . import arbitrage, cheaproll, dayplan, homeview as hv, modetrade
from . import priceruns
from . import solarfc
from .daycurve import Batt, curve
from .packmode import SOLAR_FIRST
@@ -210,25 +211,18 @@ def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow,
arbitrage.threshold_eur_kwh(c.o),
uncertainty_of(plan.read.product)
| arbitrage.weather_uncertainty(True, c.solar_fc))
end_t = cheap.end
for _ in range(24):
ext = dataclasses.replace(rp, cheap=priceruns.Run(
def packs_at(end_t):
"""The packs at the curve's level when a run ends at `end_t`."""
at = dataclasses.replace(rp, cheap=priceruns.Run(
cheap.start, end_t, cheap.mean_eur_kwh))
rt, lv = settle(ext, lv0)
at_end = [dataclasses.replace(packs[n], soc=v)
for n, v in ((n, _level_at(lv.get(n, ()), end_t))
for n in lv) if v is not None]
e = modetrade.energy(at_end, floor, ceil, targets=targets)
if e[0] <= 1e-6:
break
new = priceruns.plan(
plan.read.points, end_t, required_eur_kwh=required,
charge_kwh=e[0], charge_kw=e[1], sell_kwh=0.0, sell_kw=0.0)
nxt = new.cheap
if nxt is None or not new.charge_useful() or nxt.start > end_t \
or nxt.end <= end_t:
break
end_t = nxt.end
_rt, lv = settle(at, lv0)
return [dataclasses.replace(packs[n], soc=v)
for n, v in ((n, _level_at(lv.get(n, ()), end_t))
for n in lv) if v is not None]
end_t = cheaproll.roll_end(
cheap, plan.read.points, packs_at, required=required,
floor_pct=floor, ceiling_pct=ceil, targets=targets)
ext = dataclasses.replace(rp, cheap=priceruns.Run(
cheap.start, end_t, cheap.mean_eur_kwh))
rt, lv = settle(ext, lv0)
+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: