UX-54 round 3: tomorrow's blocks last as long as control acts, give blocks checked at run start
checks / unit (pull_request) Successful in 48m22s
infra-pin / fresh (pull_request) Successful in 1s

- Tomorrow's grid block is drawn to where control stops charging. A cheap run
  is sized for all batteries together; a slower one is still below
  `charge_to()` when it ends and control plans the next cheap slots. The tab
  asks `modetrade.energy()` and `priceruns.plan()` with the packs at the
  curve's level and extends the run, so the curve charges as long.
- Each battery's block ends where it is at its own level (grid) or at the
  floor (give). The curve puts a point where a battery reaches its charge
  level inside a step.
- `homeview.mode_runs(ahead=f, rp=...)` reads each block at the level the
  curve has when it starts, with Home's own filter and MIN_KWH: no give block
  at the floor, no grid block at the planned level. Home never passes either.
- `no_tomorrow` names the hour from `solarfc.BELL_FROM_H`, where the curve
  stops.
- test_ux54.py part 7 compares control (`_mode_roles()` every 15 minutes) with
  the tab with no tolerance, and adds the cloudy / floor case.

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 00:53:58 +01:00
co-authored by Claude
parent 75195f7073
commit 5ab8622265
7 changed files with 258 additions and 89 deletions
+80 -41
View File
@@ -340,6 +340,12 @@ check("a grid block charges to its level at the rating (5 kW x 95 %: 47.5 % "
"an hour on 10 kWh, so 80 % in the first hour), and stops there",
lv(oc, "c", 2) == 40.0 and abs(lv(oc, "c", 3) - 80.0) < 1e-6
and lv(oc, "c", 5) == 80.0)
reach = T0 + timedelta(hours=2, minutes=50, seconds=31.579)
check("a battery that reaches its grid level inside a step gets a point "
"there (80 % at 02:50:31, not at the next hour), so the line and the "
"block end are exact",
any(abs((t - reach).total_seconds()) < 1.0
and abs(v - 80.0) < 1e-6 for t, v in oc["c"]))
two = [Batt("a", 10.0, 5.0, 50.0, True, False),
Batt("b", 10.0, 5.0, 50.0, False, True)]
ot = run(two, flat(3.0), flat(1.0))
@@ -474,14 +480,16 @@ check("learning (no house profile yet): a note, the sun is drawn, no house "
say("learning") in notes_of(o) and no_charts(o)
and o["days"][0]["sun"]["forecast"] and not o["days"][0]["sun"]["house"]
and all(h["house_kwh"] is None for h in o["days"][0]["hours"]))
SUN_FROM = f"{int(solarfc.BELL_FROM_H):02d}:00"
o = tab(S.make("notomorrow", at(10, 7)), at(10, 7))
tmr = o["days"][1]
check("no forecast for tomorrow: a note on that day only; the curve stops at "
"sunrise; today is whole",
say("no_tomorrow") in tmr["notes"]
and say("no_tomorrow") not in notes_of(o, 0)
check("no forecast for tomorrow: a note on that day only, naming the hour "
"the curve really stops (`solarfc.BELL_FROM_H`, the first hour of the "
"sun: its last point is there); today is whole",
say("no_tomorrow", at=SUN_FROM) in tmr["notes"]
and say("no_tomorrow", at=SUN_FROM) not in notes_of(o, 0)
and not tmr["sun"]["forecast"]
and tmr["batteries"][0]["levels"][-1][0] <= 6.0 + 1e-6
and abs(tmr["batteries"][0]["levels"][-1][0] - solarfc.BELL_FROM_H) < 1e-6
and o["days"][0]["batteries"][0]["levels"][-1][0] > 23.0)
c = S.make("owner", at(10, 7))
c.inverters[1].soc = None
@@ -721,10 +729,14 @@ Q = timedelta(minutes=15)
def level_at(b, h):
"""The curve's level at hour `h` of the day the list is for (the last
point at or before it; the first one before the curve starts)."""
pts = [p for p in b["levels"] if p[0] <= h + 1e-9]
return pts[-1][1] if pts else b["levels"][0][1]
"""The curve's level at hour `h` of the day the list is for, straight
between its points (the first level before the curve starts, the last
after it ends)."""
pts = b["levels"]
for (h0, v0), (h1, v1) in zip(pts, pts[1:]):
if h0 <= h <= h1:
return v0 if h1 <= h0 else v0 + (v1 - v0) * (h - h0) / (h1 - h0)
return pts[0][1] if h < pts[0][0] else pts[-1][1]
def night_evening(h):
@@ -733,7 +745,7 @@ def night_evening(h):
return 0.08 if 1.0 <= h < 5.0 else 0.34 if 17.0 <= h < 21.0 else 0.20
def sweep(name, now0, soc, *, guard=None, until=48.0):
def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None):
"""Walk the real control cycle (`_mode_roles()`) every 15 minutes from
`now0` to the end of tomorrow, each battery at the level the tab's curve
has at that time, and return the tab and the role seen per battery and
@@ -743,6 +755,8 @@ def sweep(name, now0, soc, *, guard=None, until=48.0):
c.tariff_at = now0
if guard is not None:
c.peak_guard = guard
if sun is not None:
c.solar_fc = S.forecast(now0, sun[0], sun[1])
c._mode_roles(now0, None)
out = tab(c, now0)
d0, _d1 = homeview.local_day(now0)
@@ -758,38 +772,36 @@ def sweep(name, now0, soc, *, guard=None, until=48.0):
else inv.soc
c.tariff_at = t
tick = c._mode_roles(t, None)
floor_, _ceil = arbitrage.soc_bounds(c.o)
for inv in c.inverters:
seen[inv.name][t] = tick.roles.get(inv.name)
role = tick.roles.get(inv.name)
# a give from a battery at the floor has no energy behind it:
# Home's own rule (`mode_runs()`, `MIN_KWH`) draws no block
seen[inv.name][t] = "no energy" if role == modetrade.GIVE \
and inv.soc <= floor_ + 0.05 else role
t += Q
return c, out, seen, d0
def tomorrow_match(out, seen, d0, role, kind, tail=0.0):
"""(a block of `kind` has a quarter where control had `role`, and no
quarter of that role is outside a block) per battery, tomorrow. `tail`:
hours control may go on after a block ends. A charge does: the run is
sized for all the batteries charging together, so a slower one is still
short when it ends and the next plan carries on in the same cheap hours."""
ok = True
def tomorrow_match(out, seen, d0, role, kind):
"""Per battery, tomorrow: control has `role` in exactly the quarters the
tab has a block of `kind` (no quarter outside a block, none of a block
without it), with no tolerance."""
for i, name in enumerate(seen):
blocks = [r for r in out["days"][1]["batteries"][i]["blocks"]
if r["kind"] == kind]
inside = lambda h: any(
r["start_h"] - 1e-6 <= h < r["end_h"] + tail - 1e-6
for r in blocks)
quarters = [(t, (t - d0).total_seconds() / 3600.0 - 24.0)
for t in seen[name] if (t - d0).total_seconds() >= 86400]
acting = [h for t, h in quarters if seen[name][t] == role]
ok &= all(inside(h) for h in acting)
ok &= all(any(r["start_h"] - 1e-6 <= h < r["end_h"] - 1e-6
for h in acting) for r in blocks)
ok &= bool(blocks) == bool(acting)
if not ok:
print(" ", name, role, "acting at", acting[:3], "..",
acting[-2:], "blocks", [(r["start_h"], r["end_h"])
for r in blocks])
return False
return ok
for t, h in quarters:
acting = seen[name][t] == role
inside = any(r["start_h"] - 1e-6 <= h < r["end_h"] - 1e-6
for r in blocks)
if acting != inside:
print(" ", name, role, "quarter", round(h, 2),
"control acts" if acting else "tab has a block",
[(r["start_h"], r["end_h"]) for r in blocks])
return False
return True
t22 = at(22, 0, 5)
@@ -798,30 +810,57 @@ tm1 = o1["days"][1]["batteries"]
print(" 22:00, both batteries at 20 %: tomorrow", [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]] for b in tm1])
check("F1, modes on: at 22:00 with both batteries at 20 % tomorrow's give "
"block (17:00-21:00) and the night's grid block show on both",
all([(r["kind"], r["from"], r["to"]) for r in b["blocks"]]
== [("grid", "01:00", "03:30"), ("give", "17:00", "21:00")]
for b in tm1))
all([(r["kind"], r["from"]) for r in b["blocks"]]
== [("grid", "01:00"), ("give", "17:00")]
and b["blocks"][1]["to"] == "21:00" for b in tm1))
check("F2: ...and control, walked every 15 minutes through tomorrow at the "
"level the curve has, gives (role GIVE) exactly in the give block and "
"charges (role CHARGE) only in the grid block, on both batteries",
tomorrow_match(o1, s1, d01, modetrade.GIVE, "give")
and tomorrow_match(o1, s1, d01, modetrade.CHARGE, "grid", tail=2.6))
and tomorrow_match(o1, s1, d01, modetrade.CHARGE, "grid"))
check("...and today's blocks are still Home's runs there",
tab_blocks(o1) == home_blocks(home(c1, t22), 2))
guard = PeakDecision(engaged=True, floor_w=1000.0, ceiling_w=3500.0,
ceiling_from="cap", projected_w=3600.0)
c2, o2, s2, d02 = sweep("dynamic", t22, (20.0, 20.0), guard=guard)
tm2 = o2["days"][1]["batteries"]
check("F2, the UX-53 gate: while the peak guard holds, control never charges "
"tomorrow, and the tab draws no grid block there (the give block stays)",
check("F2, the UX-53 gate: while the peak guard holds control never "
"charges tomorrow and the tab draws no grid block (both batteries stay "
"at the floor, so no give either, which control cannot give from)",
c2.mode_gate == "peak"
and all([r["kind"] for r in b["blocks"]] == ["give"] for b in tm2)
and tomorrow_match(o2, s2, d02, modetrade.CHARGE, "grid", tail=2.6)
and all(not b["blocks"] for b in tm2)
and tomorrow_match(o2, s2, d02, modetrade.CHARGE, "grid")
and tomorrow_match(o2, s2, d02, modetrade.GIVE, "give"))
c2b, o2b, s2b, d02b = sweep("dynamic", t22, (60.0, 60.0), guard=guard)
check("...with the batteries at 60 % the give block stays and the grid "
"block does not",
all([r["kind"] for r in b["blocks"]] == ["give"]
for b in o2b["days"][1]["batteries"])
and tomorrow_match(o2b, s2b, d02b, modetrade.CHARGE, "grid")
and tomorrow_match(o2b, s2b, d02b, modetrade.GIVE, "give"))
# Low sun, a battery at the floor when the evening give run starts (no grid
# charge, the peak guard holds): Home's rule draws no give block for it, and
# control has no energy to give from. A little sun fills it a little: a small
# block, and control gives from it.
c4, o4, s4, d04 = sweep("dynamic", t22, (20.0, 20.0), guard=guard,
sun=(0.0, 0.0))
check("F1, cloudy day, no grid charge, both batteries at the floor at the "
"evening give run: no block on either, and control has no energy to "
"give from (the sweep sees none)",
all(not b["blocks"] for b in o4["days"][1]["batteries"])
and tomorrow_match(o4, s4, d04, modetrade.GIVE, "give"))
c5, o5, s5, d05 = sweep("dynamic", t22, (20.0, 20.0), guard=guard,
sun=(14.0, 14.0))
gv5 = [[r for r in b["blocks"] if r["kind"] == "give"]
for b in o5["days"][1]["batteries"]]
check("...a bright day fills them: a give block on both, and control gives "
"in exactly that block",
all(len(g) == 1 and g[0]["from"] == "17:00" for g in gv5)
and tomorrow_match(o5, s5, d05, modetrade.GIVE, "give"))
o3 = [sweep("dynamic", t22, soc) for soc in ((55.0, 40.0), (90.0, 70.0))]
check("F2: the same with the batteries part full and nearly full at 22:00",
all(tomorrow_match(x[1], x[2], x[3], modetrade.GIVE, "give")
and tomorrow_match(x[1], x[2], x[3], modetrade.CHARGE, "grid", tail=2.6)
and tomorrow_match(x[1], x[2], x[3], modetrade.CHARGE, "grid")
for x in o3))