UX-54 round 2: tomorrow's blocks where control will act, trust as the plan counts it, strings
checks / unit (pull_request) Successful in 48m25s
infra-pin / fresh (pull_request) Successful in 1s

F1: tomorrow's blocks no longer depend on the battery's level now. Home sizes
a block from the level now (`room()`, `plan_need()` in mode_runs, the leg
sizing in trade_runs) and draws none when there is no room or energy. For a
day that has not started the builders take `ahead=True` (Home never passes
it): a charge is read from the floor, a give from the ceiling. A grid block is
then kept only where `modetrade.chargers()` would charge at the curve's level
when the run starts. The UX-53 gate is unchanged. Today still equals Home; a
give run in the plan with no block (empty battery) has its own sentence
instead of "No charge or give blocks planned".

F2: test_ux54.py part 7 walks the real `_mode_roles()` every 15 minutes
through tomorrow and compares the roles with the tab's blocks (modes on, the
peak gate, part-full batteries), and the control cycle's verdict at 07:00
(modes off).

F3: the curve counts spare solar as trust x max(0, sun - house), stored at
EFF, as `dayplan.plan()` does (10 kWh sun, 2 kWh house, 70 %: 5.32 kWh).

Strings: cycle, no_tomorrow, ahead, no_level, measured; new give_later.

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-26 22:48:48 +01:00
co-authored by Claude
parent b6b2d1b184
commit 75195f7073
7 changed files with 388 additions and 85 deletions
+8 -2
View File
@@ -88,11 +88,15 @@ def _spread(amount, caps, weights):
return got, max(0.0, left)
def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct):
def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct,
trust=1.0):
"""`{name: [(t, pct), ...]}` from `start` (each battery's level now) to
`end`, a point at every hour edge and every block edge. A battery's list
stops where `sun_kwh(a, b)` is None (the sun is not known) or its
`stop_at` comes. `sun_kwh` and `use_kwh` give kWh for the span `[a, b)`."""
`stop_at` comes. `sun_kwh` and `use_kwh` give kWh for the span `[a, b)`.
Spare solar is `trust x max(0, sun - use)`, and what is stored loses
`EFF`: `dayplan.plan()`'s own `trust x EFF x max(0, sun - house)`. A
shortfall is the house's use less the whole sun."""
floor, ceil = float(floor_pct), float(ceiling_pct)
cuts = {end}
for a, _b, _h in _hour_slices(start, end):
@@ -115,6 +119,8 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct):
if b.name in alive and b.stop_at is not None and a >= b.stop_at:
alive.discard(b.name)
net = sun - use_kwh(a, z)
if net > 0.0:
net *= trust
hrs = (z - a).total_seconds() / 3600.0
want = {} # name -> grid target %
for b in batts:
+91 -43
View File
@@ -16,6 +16,7 @@ A part that is not known yet is a plain note (`home_words.day`), never an
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
@@ -76,17 +77,22 @@ def _build(c, status, now):
else "learning" if est is None else None)
# The blocks of each day: today's from Home, tomorrow's by its builders.
runs = [plan["runs"], hv.runs_for(c, n0, n1, now, shadow) if running
else []]
blocks = [[[r for r in rs if r["battery"] == i and r["kind"] in BLOCKS]
for i in range(len(c.inverters))] for rs in runs]
# 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 []]
# The level curve of every battery that is on and known.
# The level curve of every battery that is on and known. It also drops a
# grid block of tomorrow that control will not take: the battery is then
# at or above its planned level when the run starts.
notes_b = {}
levels = {}
if running and missing is None:
levels = _levels(c, est, fc, trust, now, n1, runs, floor, ceil,
notes_b)
levels, runs = _levels(c, est, fc, trust, now, n1, runs, floor, ceil,
notes_b)
blocks = [[[r for r in rs if r["battery"] == i and r["kind"] in BLOCKS]
for i in range(len(c.inverters))] for rs in runs]
# Prices are planned about a day ahead (`priceruns.HORIZON`), so tomorrow's
# blocks may be missing now and appear later.
priced = bool(plan.get("has_prices")) and (
@@ -104,49 +110,80 @@ def _build(c, status, now):
def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b):
"""`{name: [(t, pct), ...]}` from `daycurve.curve()` for the batteries
that are on, have a level and are not in their monthly cycle."""
"""`({name: [(t, pct), ...]}, runs)`: `daycurve.curve()` for the batteries
that are on, have a level and are not in their monthly cycle, and `runs`
less the grid blocks of tomorrow that control will not take. Those are
asked of `modetrade.chargers()`, the cycle's own test, at the level the
curve has when the block starts (a block is drawn from the floor, see
`homeview.mode_runs(ahead=True)`, so that level is the filter)."""
packs = {p.name: p for p in c._mode_packs()}
targets = c.plan_targets()
rp = getattr(c, "mode_runs", None)
plan_on = rp is not None and rp.on
house_w, base_w = est
batts = []
for i, inv in enumerate(c.inverters):
pm = c.pack_mode(inv)
if not pm.use:
continue
if inv.soc is None or not inv.capacity_kwh:
notes_b[inv.name] = hv.day_note("no_level", name=inv.name)
continue
mine = [r for rs in runs for r in rs
if r["battery"] == i and r["kind"] in BLOCKS]
cyc = [_times(r)[0] for r in mine if r["kind"] == "cycle"]
if cyc and min(cyc) <= now:
notes_b[inv.name] = hv.day_note("cycle", name=inv.name)
continue
if cyc:
notes_b[inv.name] = hv.day_note("cycle", name=inv.name)
to = modetrade.charge_to(packs[inv.name], ceil, targets)
grid = tuple((*_times(r), to) for r in mine
if r["kind"] == "grid" and to is not None)
priced = plan_on and pm.mode != SOLAR_FIRST
give = tuple((g.start, g.end) for g in rp.give) if priced else ()
batts.append(Batt(inv.name, float(inv.capacity_kwh),
inv.max_w / 1000.0, float(inv.soc),
pm.mode == SOLAR_FIRST, priced, grid, give,
min(cyc) if cyc else None))
def make(rs):
batts = []
for i, inv in enumerate(c.inverters):
pm = c.pack_mode(inv)
if not pm.use:
continue
if inv.soc is None or not inv.capacity_kwh:
notes_b[inv.name] = hv.day_note("no_level", name=inv.name)
continue
mine = [r for day in rs for r in day
if r["battery"] == i and r["kind"] in BLOCKS]
cyc = [_times(r)[0] for r in mine if r["kind"] == "cycle"]
if cyc and min(cyc) <= now:
notes_b[inv.name] = hv.day_note("cycle", name=inv.name)
continue
if cyc:
notes_b[inv.name] = hv.day_note("cycle", name=inv.name)
to = modetrade.charge_to(packs[inv.name], ceil, targets)
grid = tuple((*_times(r), to) for r in mine
if r["kind"] == "grid" and to is not None)
priced = plan_on and pm.mode != SOLAR_FIRST
give = tuple((g.start, g.end) for g in rp.give) if priced else ()
batts.append(Batt(inv.name, float(inv.capacity_kwh),
inv.max_w / 1000.0, float(inv.soc),
pm.mode == SOLAR_FIRST, priced, grid, give,
min(cyc) if cyc else None))
return batts
def sun(a, z):
kwh = sun_kwh(fc, now, a, z)
return None if kwh is None else kwh * trust
return sun_kwh(fc, now, a, z)
def use(a, z):
return sum(max(house_w(h), base_w) * _hours_between(s, e) / 1000.0
for s, e, h in dayplan._hour_slices(a, z))
if not batts:
return {}
return curve(batts, now, end, sun, use, floor_pct=floor, ceiling_pct=ceil)
def run(rs):
batts = make(rs)
return curve(batts, now, end, sun, use, floor_pct=floor,
ceiling_pct=ceil, trust=trust) if batts else {}
first = run([runs[0], [r for r in runs[1] if r["kind"] != "grid"]])
keep = []
for r in runs[1]:
name = c.inverters[r["battery"]].name
lvl = _level_at(first.get(name, ()), _times(r)[0])
if r["kind"] == "grid" and lvl is not None and not modetrade.chargers(
[dataclasses.replace(packs[name], soc=lvl)], ceil,
targets=targets):
continue
keep.append(r)
runs = [runs[0], keep]
return run(runs), runs
def _level_at(pts, t):
"""The curve's level at `t` (between two points, straight), or None when
the curve does not reach `t`."""
for (t0, v0), (t1, v1) in zip(pts, pts[1:]):
if t0 <= t <= t1:
span = (t1 - t0).total_seconds()
return v0 if span <= 0 else v0 + (v1 - v0) * (
(t - t0).total_seconds() / span)
return None
def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
@@ -183,7 +220,8 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
"blocks": [{x: r[x] for x in ("kind", "from", "to", "start_h",
"end_h", "what", "why", "amount")}
for r in mine],
"line": _line(c, inv, row, mine, running, key, shadow)})
"line": _line(c, inv, row, mine, running, key, shadow, d0, d1,
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))
@@ -237,8 +275,17 @@ def _tomorrow_line(status) -> str:
return (status.get("plan") or {}).get("tomorrow_line") or ""
def _line(c, inv, row, mine, running, key, shadow) -> str:
"""What to say for a battery with no block: the plan's own words."""
def _give_planned(c, d0, d1, now) -> bool:
"""Does the run plan have a give run in `[d0, d1)` not yet over?"""
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)
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."""
if not running:
return hv.why_words(key)
if row.get("use") is False:
@@ -247,5 +294,6 @@ def _line(c, inv, row, mine, running, key, shadow) -> str:
return ""
if getattr(c, "modes_on", False) and row.get("mode_choice") in \
hv.PRICE_MODES:
return hv.day_note("no_blocks")
return hv.day_note("give_later" if _give_planned(c, d0, d1, now)
else "no_blocks")
return hv.run_house_what(shadow) + " " + hv.run_house_why(shadow)
+39 -22
View File
@@ -333,18 +333,22 @@ def trade_legs(c, plan, verdict, now):
MIN_KWH = 0.05
def trade_runs(c, plan, verdict, day0, day1, now, shadow):
def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
"""A charge block and a give block on each battery, where the verdict put
them. The site trades as one; each battery's share is capped by its own
rating and by the room its level leaves (arbitrage's own floor/ceiling).
Only what `arbitrage.decide()` would still do is drawn: no block on a
battery with no room, no sale at a negative price, and no sale while the
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."""
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."""
floor, ceiling = arbitrage.soc_bounds(c.o)
if any(inv.soc is None for inv in c.inverters):
if not ahead and any(inv.soc is None for inv in c.inverters):
return [], [], "no_level"
level = [float(inv.soc) for inv in c.inverters]
level = [] if ahead else [float(inv.soc) for inv in c.inverters]
runs, later = [], []
for kind, pt in trade_legs(c, plan, verdict, now):
end = _point_end(plan, pt)
@@ -353,7 +357,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
if not (day0 <= pt.start < day1):
later.append((kind, pt))
continue
if kind == "give" and (pt.eur_per_kwh < 0 or min(level) <= floor):
if kind == "give" and (pt.eur_per_kwh < 0
or (not ahead and min(level) <= floor)):
continue
hrs = (end - pt.start).total_seconds() / 3600.0
for i, inv in enumerate(c.inverters):
@@ -362,12 +367,15 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
kwh = inv.max_w / 1000.0 * hrs
cap = inv.capacity_kwh
if cap:
room = (ceiling - level[i] if kind == "grid"
else level[i] - floor)
now_pct = (floor if kind == "grid" else ceiling) if ahead \
else level[i]
room = (ceiling - now_pct if kind == "grid"
else now_pct - 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").
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
if not ahead:
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
if kwh < MIN_KWH:
continue
eur = kwh * pt.eur_per_kwh
@@ -387,14 +395,19 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
def mode_runs(c, day0, day1, now, shadow):
def mode_runs(c, day0, day1, now, shadow, ahead=False):
"""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. UX-53: no grid
charge is drawn while the trade gate refuses one (`c.mode_gate`, what the
control cycle found). Returns `(runs, no_trade_key)`."""
control cycle found). Returns `(runs, no_trade_key)`.
UX-54 `ahead`: the window has not started, so the level now says nothing
about it. The room and energy filter (`room()` and `plan_need()` below)
then reads a charge from the floor and a give from the ceiling, and a
block is drawn wherever the plan and the gate have a run. Home never
passes it."""
rp = getattr(c, "mode_runs", None)
if getattr(c, "mode_tick", None) is None:
return [], None # no cycle yet: nothing is known
@@ -413,13 +426,14 @@ def mode_runs(c, day0, day1, now, shadow):
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)
lvl = ((floor if kind == "grid" else ceiling) if ahead else soc)
if cap and lvl is not None:
kwh = min(kwh, max(0.0, (ceiling - lvl) if kind == "grid"
else (lvl - floor)) / 100.0 * cap)
return kwh
blocks = []
# P4: with a day plan, the grid block is the planned shortfall.
need = plan_need(c, inv.name)
need = plan_need(c, inv.name, floor if ahead else 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):
@@ -461,16 +475,18 @@ def mode_runs(c, day0, day1, now, shadow):
return out, None
def plan_need(c, name):
def plan_need(c, name, soc=None):
"""P4: kWh this battery buys from the grid by the day plan, or None when
there is no plan for it (then P3 option A applies)."""
there is no plan for it (then P3 option A applies). `soc`: UX-54, a level
to read it from instead of the one now (`mode_runs(ahead=True)`)."""
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
if not targets or name not in targets:
return None
inv = next((i for i in c.inverters if i.name == name), None)
if inv is None or inv.soc is None or not inv.capacity_kwh:
at = None if inv is None else (inv.soc if soc is None else soc)
if inv is None or at is None or not inv.capacity_kwh:
return None
return max(0.0, targets[name] - float(inv.soc)) / 100.0 * float(
return max(0.0, targets[name] - float(at)) / 100.0 * float(
inv.capacity_kwh)
@@ -613,17 +629,18 @@ def region_of(opts):
if rid in REGION_PRESETS else None}
def runs_for(c, day0, day1, now, shadow):
def runs_for(c, day0, day1, now, shadow, ahead=False):
"""UX-54: the price blocks of the window `[day0, day1)`, by the same
builders `build_plan()` uses for today: the batteries' price runs with
modes on, the verdict's legs with them off. Nothing is decided here."""
modes on, the verdict's legs with them off. Nothing is decided here.
`ahead`: see `mode_runs()`; for a day that has not started."""
if getattr(c, "modes_on", False):
return mode_runs(c, day0, day1, now, shadow)[0]
return mode_runs(c, day0, day1, now, shadow, ahead)[0]
fresh = c.fresh_tariff_plan(now)
verdict, _why = trade_verdict(c, fresh, now)
if verdict is None:
return []
return trade_runs(c, fresh, verdict, day0, day1, now, shadow)[0]
return trade_runs(c, fresh, verdict, day0, day1, now, shadow, ahead)[0]
def build_plan(c, now, shadow, running, held=False, settled=True):
+6 -5
View File
@@ -76,7 +76,7 @@
"sun": "Solar and house",
"unit": "kWh per hour",
"forecast": "Solar forecast",
"measured": "Solar measured",
"measured": "Measured solar",
"house": "House use",
"levels": "Battery level",
"table": "Hour by hour",
@@ -448,15 +448,16 @@
"not_set": "No solar forecast is set up. Without one, no battery level is planned.",
"no_forecast": "The solar forecast is missing or too old. The levels return when it is back.",
"learning": "The add-on is learning your house's use. The levels start after one full day.",
"no_tomorrow": "There is no solar forecast for tomorrow yet. The levels stop at sunrise.",
"no_tomorrow": "There is no solar forecast for tomorrow yet. The levels stop at 06:00.",
"estimate": "The forecast gives one total for the day. The add-on spreads it over the hours.",
"trust": "The levels count on {pct} % of the forecast.",
"cycle": "{name} does its monthly cycle. Its level is not shown from the start.",
"no_level": "{name} has no level reading yet. Its levels are not shown.",
"cycle": "{name} does its monthly cycle. Its level is not shown once the cycle starts.",
"no_level": "{name} has no level reading yet. Its level is not shown.",
"measured_from": "Measured solar is counted from {at}, when the add-on started.",
"tomorrow_total": "Solar expected tomorrow: {kwh}, we count on {count}.",
"no_blocks": "No charge or give blocks planned.",
"ahead": "The add-on plans prices about a day ahead. Blocks for later hours appear as they come near."
"give_later": "The add-on plans to give power. No block shows while the battery has no energy to give.",
"ahead": "The add-on plans about a day ahead. Blocks for later hours appear as they come near."
},
"region": {
"preset": "Levies and VAT: {region}, {year} figures.",
+6 -5
View File
@@ -73,7 +73,7 @@
"sun": "Zon en huis",
"unit": "kWh per uur",
"forecast": "Zonneprognose",
"measured": "Zon gemeten",
"measured": "Gemeten zon",
"house": "Huisverbruik",
"levels": "Laadniveau",
"table": "Uur per uur",
@@ -376,15 +376,16 @@
"not_set": "Er is geen zonneprognose ingesteld. Zonder prognose wordt geen laadniveau gepland.",
"no_forecast": "De zonneprognose ontbreekt of is te oud. De niveaus komen terug als ze er weer is.",
"learning": "De add-on leert het verbruik van uw huis. De niveaus starten na één volle dag.",
"no_tomorrow": "Er is nog geen zonneprognose voor morgen. De niveaus stoppen bij zonsopgang.",
"no_tomorrow": "Er is nog geen zonneprognose voor morgen. De niveaus stoppen om 06:00.",
"estimate": "De prognose geeft één totaal voor de dag. De add-on verdeelt het over de uren.",
"trust": "De niveaus rekenen op {pct} % van de prognose.",
"cycle": "{name} doet de maandelijkse cyclus. Het niveau wordt niet getoond vanaf de start.",
"no_level": "{name} geeft nog geen laadniveau door. De niveaus worden niet getoond.",
"cycle": "{name} doet de maandelijkse cyclus. Het niveau wordt niet getoond zodra de cyclus start.",
"no_level": "{name} geeft nog geen laadniveau door. Het niveau wordt niet getoond.",
"measured_from": "Gemeten zon telt vanaf {at}, toen de add-on startte.",
"tomorrow_total": "Verwachte zon morgen: {kwh}, we rekenen op {count}.",
"no_blocks": "Geen laad- of leverblokken gepland.",
"ahead": "De add-on plant prijzen ongeveer een dag vooruit. Blokken voor latere uren verschijnen als ze naderen."
"give_later": "De add-on plant stroom te leveren. Er verschijnt geen blok zolang de batterij geen stroom heeft.",
"ahead": "De add-on plant ongeveer een dag vooruit. Blokken voor latere uren verschijnen als ze naderen."
},
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",