UX-54 round 3: tomorrow's blocks last as long as control acts, give blocks checked at run start
- 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:
co-authored by
Claude
parent
75195f7073
commit
5ab8622265
@@ -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))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user