UX-54 round 4: modes off reads tomorrow at Home's count, no relabel in the walk
checks / unit (pull_request) Successful in 48m51s
infra-pin / fresh (pull_request) Successful in 2s

Modes off has no curve (no forecast is read, no house profile kept), so
dayview._levels() never ran there and tomorrow's legs were drawn from the
bound. homeview.trade_runs(ahead=f) now reads each leg at f(name, kind,
start), as mode_runs() does: no give while the emptiest battery is at the
floor, each share sized from that level. dayview._counted() gives a give
the level by Home's own count (the level now, plus what the blocks before
it put in, less what they take out); a charge is read from the floor,
because a full battery covers the house before the cheap hour.

The battery line says a give is planned when one is and the battery has
no give block, also with modes off.

test_ux54.py: the modes-on walk no longer relabels a GIVE role at the
floor; it asks control's own discharge envelope (packshare.envelope())
with each pack's cut-out at the floor. The peak-guard 20 % case asserts
the curve is at the floor at the 17:00 give start. A modes-off walk on a
cloudy day (arbitrage.decide() with the cycle's inputs, the packs moved by
what control does and by the house), cases A-D: control charges and gives
in exactly the quarters the tab has a block. trade_runs(ahead=f) unit
check. _stops() ends a give block where the level the tab shows is at the
floor (LEVEL_DP).

No decision, command, timing or option key changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-27 03:06:12 +01:00
co-authored by Claude
parent 5ab8622265
commit 4256bd3eda
4 changed files with 326 additions and 42 deletions
+81 -20
View File
@@ -26,6 +26,7 @@ from .packmode import SOLAR_FIRST
from .price import Uncertainty, brussels, required_spread, uncertainty_of
BLOCKS = ("grid", "give", "cycle") # the kinds a battery's blocks carry
LEVEL_DP = 1 # the tab shows a level to 0.1 %
def _hours_between(a, b) -> float:
@@ -79,10 +80,15 @@ def _build(c, status, now):
# The blocks of each day: today's from Home, tomorrow's by its builders.
# Tomorrow has not started, so the level now says nothing about it: the
# builders read it with `ahead` and draw a block wherever the plan and the
# gate have a run (`homeview.mode_runs()` has the room and energy filter).
runs = [plan["runs"], hv.runs_for(c, n0, n1, now, shadow, ahead=True)
if running else []]
# builders read each block at the level it starts at (`ahead`), with
# their own room and energy filter. With modes on that level is the
# curve's (`_levels()`); with modes off there is no curve, and it is
# Home's own count (`_counted()`).
runs = [plan["runs"], []]
if running and not modes_on:
runs[1] = _off_tomorrow(c, runs[0], now, shadow, n0, n1)
elif running:
runs[1] = hv.runs_for(c, n0, n1, now, shadow, ahead=True)
# The level curve of every battery that is on and known. It also makes
# tomorrow's blocks again at the level each has when its run starts, and
@@ -230,13 +236,55 @@ def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow,
return lv, [runs[0], rt]
def _off_tomorrow(c, today, now, shadow, n0, n1):
"""Modes off: tomorrow's blocks by `homeview.trade_runs()`, each read at
the level `_counted()` gives when it starts. A charge may come before a
give, so this repeats until the blocks stop changing."""
sig, rt = None, []
for _ in range(4):
rt = hv.runs_for(c, n0, n1, now, shadow,
ahead=_counted(c, [today, rt]))
new = [(r["battery"], r["kind"], r["start"], r["end"]) for r in rt]
if new == sig:
break
sig = new
return rt
def _counted(c, rs):
"""Modes off reads no forecast and keeps no house profile, so there is no
curve. A give is read at Home's own count (`trade_runs()`): the level now,
plus what the blocks in `rs` that start before it put in, less what they
take out. The house can only take from that and no sun is read, so a
battery at the floor by the count is at the floor then. A charge is read
from the floor (None): the house uses some of a full battery before the
cheap hour, and control charges it back then."""
_floor, ceil = arbitrage.soc_bounds(c.o)
def at(name, kind, start):
i = next(k for k, inv in enumerate(c.inverters) if inv.name == name)
inv = c.inverters[i]
if kind == "grid" or inv.soc is None or not inv.capacity_kwh:
return None
v = float(inv.soc)
mine = sorted((r for day in rs for r in day if r["battery"] == i
and r["kind"] in ("grid", "give")
and r.get("kwh") is not None
and _times(r)[0] < start),
key=lambda r: _times(r)[0])
for r in mine:
d = float(r["kwh"]) / float(inv.capacity_kwh) * 100.0
v = min(max(v, ceil), v + d) if r["kind"] == "grid" else v - d
return v
return at
def _stops(r, names, lv, packs, targets, floor, ceil, day0):
"""`r` ended where its battery stops: a grid block where the curve has it
at the level `modetrade.charge_to()` allows (the curve has a point there),
a give block where the curve has it at the floor, within the 0.1 % the
tab shows (a step end), if that is
before the run ends. A run is for all the batteries together; each leaves
it on its own."""
"""`r` ended where its battery stops, if that is before the run ends: a
grid block where the curve has it at the level `modetrade.charge_to()`
allows (the curve has a point there), a give block at the first point
where the level the tab shows (`LEVEL_DP`) is at the floor. A run is for
all the batteries together; each leaves it on its own."""
if r["kind"] not in ("grid", "give"):
return r
name = names[r["battery"]]
@@ -247,7 +295,7 @@ def _stops(r, names, lv, packs, targets, floor, ceil, day0):
return r
hit = lambda v: v >= to - 1e-6 # noqa: E731
else:
hit = lambda v: v <= floor + 0.05 # noqa: E731
hit = lambda v: round(v, LEVEL_DP) <= floor # noqa: E731
done = next((t for t, v in lv.get(name, ()) if start < t <= end
and hit(v)), None)
if done is None or done >= end:
@@ -296,7 +344,7 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
batts.append({
"name": inv.name, "capacity_kwh": inv.capacity_kwh,
"off": row.get("use") is False,
"levels": [[round(_hours_between(d0, t), 3), round(v, 1)]
"levels": [[round(_hours_between(d0, t), 3), round(v, LEVEL_DP)]
for t, v in pts],
"blocks": [{x: r[x] for x in ("kind", "from", "to", "start_h",
"end_h", "what", "why", "amount")}
@@ -305,7 +353,8 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
now)})
at = {t: v for t, v in pts}
for s, hr in hours:
hr["levels"].append(None if s not in at else round(at[s], 1))
hr["levels"].append(None if s not in at
else round(at[s], LEVEL_DP))
has_levels = any(b["levels"] for b in batts)
fcs = [h["forecast_kwh"] for _s, h in hours]
notes = []
@@ -364,7 +413,16 @@ def _tomorrow_line(status) -> str:
def _give_planned(c, d0, d1, now) -> bool:
"""Does the run plan have a give run in `[d0, d1)` not yet over?"""
"""Does the add-on plan a give in `[d0, d1)` not yet over? Modes on: a
give run of the run plan. Modes off: a give leg of the verdict that the
gate takes (`homeview.trade_legs()`), at a price that is not negative."""
if not getattr(c, "modes_on", False):
plan = c.fresh_tariff_plan(now)
verdict, _why = hv.trade_verdict(c, plan, now)
return verdict is not None and any(
k == "give" and pt.eur_per_kwh >= 0 and pt.start < d1
and hv._point_end(plan, pt) > max(now, d0)
for k, pt in hv.trade_legs(c, plan, verdict, now))
rp = getattr(c, "mode_runs", None)
return rp is not None and rp.on and any(
g.end > max(now, d0) and g.start < d1 for g in rp.give)
@@ -372,16 +430,19 @@ def _give_planned(c, d0, d1, now) -> bool:
def _line(c, inv, row, mine, running, key, shadow, d0, d1, now) -> str:
"""What to say for a battery with no block: the plan's own words. Home
draws no give block for a battery with no energy to give now, so a give run
in the plan with no block gets its own sentence."""
draws no give block for a battery with no energy to give, so a give that
the add-on plans with no block gets its own sentence."""
if not running:
return hv.why_words(key)
if row.get("use") is False:
return hv.why_words("off")
modes_on = getattr(c, "modes_on", False)
gives = not modes_on or row.get("mode_choice") in hv.PRICE_MODES
if gives and not any(r["kind"] == "give" for r in mine) \
and _give_planned(c, d0, d1, now):
return hv.day_note("give_later")
if mine:
return ""
if getattr(c, "modes_on", False) and row.get("mode_choice") in \
hv.PRICE_MODES:
return hv.day_note("give_later" if _give_planned(c, d0, d1, now)
else "no_blocks")
if modes_on and gives:
return hv.day_note("no_blocks")
return hv.run_house_what(shadow) + " " + hv.run_house_why(shadow)
+16 -9
View File
@@ -342,13 +342,21 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
emptiest battery is at the floor (the cycle's `soc` is the emptiest).
With a level unknown the cycle does not trade, so nothing is drawn.
UX-54 `ahead`: the window has not started, so the level now says nothing
about it. A charge is sized from the floor, a give from the ceiling (the
most it could be), and the levels are not asked; the leg itself is still
the gate's (`trade_legs()`). Home never passes it."""
about it. The same rules then read each battery at the level a leg starts
at: `ahead(name, kind, start)`, a level in % or None, as in `mode_runs()`;
for None (and for `ahead=True`) a charge is read from the floor and a give
from the ceiling (the most it could be). The leg itself is still the
gate's (`trade_legs()`). Home never passes it."""
floor, ceiling = arbitrage.soc_bounds(c.o)
if not ahead and any(inv.soc is None for inv in c.inverters):
return [], [], "no_level"
level = [] if ahead else [float(inv.soc) for inv in c.inverters]
def start_pct(kind, start):
got = [ahead(inv.name, kind, start) if callable(ahead) else None
for inv in c.inverters]
return [(floor if kind == "grid" else ceiling) if v is None
else float(v) for v in got]
runs, later = [], []
for kind, pt in trade_legs(c, plan, verdict, now):
end = _point_end(plan, pt)
@@ -357,8 +365,9 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
if not (day0 <= pt.start < day1):
later.append((kind, pt))
continue
if kind == "give" and (pt.eur_per_kwh < 0
or (not ahead and min(level) <= floor)):
if ahead:
level = start_pct(kind, pt.start)
if kind == "give" and (pt.eur_per_kwh < 0 or min(level) <= floor):
continue
hrs = (end - pt.start).total_seconds() / 3600.0
for i, inv in enumerate(c.inverters):
@@ -367,10 +376,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
kwh = inv.max_w / 1000.0 * hrs
cap = inv.capacity_kwh
if cap:
now_pct = (floor if kind == "grid" else ceiling) if ahead \
else level[i]
room = (ceiling - now_pct if kind == "grid"
else now_pct - floor)
room = (ceiling - level[i] if kind == "grid"
else level[i] - floor)
kwh = min(kwh, max(0.0, room) / 100.0 * cap)
# A later give may use what this charge put in: an upper
# bound, like every amount here ("up to").