UX-54 round 5: give blocks end at the floor point, the tab on the clock-change days
checks / unit (pull_request) Failing after 50m11s
infra-pin / fresh (pull_request) Successful in 1s

daycurve.curve() puts a point where a giving battery reaches the floor
inside a step (_floor_cut(), as it already does for a grid charge reaching
its level), so _stops() ends a give block in the quarter control stops. A
walk where control moves the packs with its own model (not the curve's
levels) matches the tab quarter by quarter in 11 cases; 25/45 % with the
peak guard: control 17:00-20:15, block 17:00-20:06.

SolarToday keys an hour by its start taken back from the UTC instant, so
the two 02:00 hours of the autumn change stay apart. test_ux54_dst.py builds
the tab (forecast_payload()) at every quarter of 27-29 March and
30 October-1 November 2027, modes on and off, in the no-tzdata zone and,
where the host has tzdata, ZoneInfo, every call under a time limit raised
as a BaseException that forecast_payload() cannot swallow.

give_later: "No block shows while a battery has no energy to give." /
"Er verschijnt geen blok zolang een batterij niets kan leveren."
DECISIONS: what the walks prove, the modes-off charge drawn on a sunny day,
the clock-change days.

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 15:54:33 +01:00
co-authored by Claude
parent 00c8ae354c
commit 8dbf831854
6 changed files with 536 additions and 24 deletions
+160
View File
@@ -898,6 +898,153 @@ check("F2: the same with the batteries part full and nearly full at 22:00",
for x in o3))
# Control walked with its own model of the packs, not the curve's levels:
# every 15 minutes the real `_mode_roles()`; a battery in CHARGE charges at
# its rating up to `charge_to()`; spare sun goes to the batteries whose range
# may charge, by room, up to the ceiling; a shortfall is covered by those
# whose range may give and whose own discharge envelope is open, by what
# they hold above the cut-out (at the floor), each at most its rating. The
# house is the profile the tab reads, the sun the forecast at 100 % trust.
# Per battery and quarter tomorrow, control charges (gives) exactly where the
# tab has a grid (give) block, both ways, no tolerance.
def own_walk(soc, *, guard=None, sun=(0.0, 0.0)):
now0 = t22
c = S.make("dynamic", now0, soc=soc)
cut_out_at_floor(c)
floor_, ceil_ = arbitrage.soc_bounds(c.o)
c.tariff_plan = S.quarters(now0, night_evening, days=3)
c.tariff_at = now0
if guard is not None:
c.peak_guard = guard
c.solar_trust_pct = 100
c.solar_fc = fc = S.forecast(now0, sun[0], sun[1])
c._mode_roles(now0, None)
out = tab(c, now0)
house_w, base_w = c.house_profile.estimator()
d0, _d1 = homeview.local_day(now0)
seen = {inv.name: {} for inv in c.inverters}
t = now0
while t < d0 + timedelta(hours=48):
c.tariff_at = t
tick = c._mode_roles(t, None)
targets = c.plan_targets()
packs = {p.name: p for p in c._mode_packs()}
house = max(house_w(S.brussels(t).hour), base_w) / 1000.0 * 0.25
net = (dayview.sun_kwh(fc, now0, t, t + Q) or 0.0) - house
takers, givers = [], []
for inv in c.inverters:
role = tick.roles.get(inv.name)
lo, hi = tick.ranges.get(inv.name, (0.0, 0.0))
act = None
if role == modetrade.CHARGE:
to = modetrade.charge_to(packs[inv.name], ceil_, targets)
step = min(inv.max_w / 1000.0 * 0.25 * dayplan.EFF,
max(0.0, to - inv.soc) / 100.0 * inv.capacity_kwh)
act = "grid" if step > 1e-9 else None
inv.soc += step / inv.capacity_kwh * 100.0
else:
if lo < 0.0 and inv.soc < ceil_:
takers.append(inv)
if hi > 0.0 and may_give(inv):
givers.append(inv)
act = "give" if role == modetrade.GIVE else None
seen[inv.name][t] = act
if net > 0.0:
room = {i.name: (ceil_ - i.soc) / 100.0 * i.capacity_kwh
/ dayplan.EFF for i in takers}
tot = sum(room.values())
for i in takers:
part = min(net * room[i.name] / tot, room[i.name],
i.max_w / 1000.0 * 0.25) if tot > 0.0 else 0.0
i.soc += part * dayplan.EFF / i.capacity_kwh * 100.0
elif net < 0.0:
hold = {i.name: max(0.0, i.soc - floor_) / 100.0 * i.capacity_kwh
for i in givers}
tot = sum(hold.values())
for i in givers:
part = min(-net * hold[i.name] / tot,
i.max_w / 1000.0 * 0.25) if tot > 0.0 else 0.0
i.soc = max(floor_, i.soc - part / dayplan.EFF
/ i.capacity_kwh * 100.0)
t += Q
return out, seen, d0
def own_match(out, seen, d0):
"""`[(name, kind, hour, who)]`: the quarters tomorrow where control and
the tab disagree (who acts there alone)."""
bad = []
for i, name in enumerate(seen):
for kind in ("grid", "give"):
blocks = [r for r in out["days"][1]["batteries"][i]["blocks"]
if r["kind"] == kind]
for t, a in seen[name].items():
h = (t - d0).total_seconds() / 3600.0 - 24.0
if h < 0.0:
continue
inside = any(r["start_h"] - 1e-6 <= h < r["end_h"] - 1e-6
for r in blocks)
if (a == kind) != inside:
bad.append((name, kind, round(h, 2),
"control" if a == kind else "tab"))
return bad
def give_span(seen, d0, name):
hs = [(t - d0).total_seconds() / 3600.0 - 24.0
for t, a in seen[name].items()
if a == "give" and (t - d0).total_seconds() >= 86400]
return (round(min(hs), 2), round(max(hs) + 0.25, 2)) if hs else None
own = {}
for key, soc, g, sun in (("20/20", (20.0, 20.0), None, (0.0, 0.0)),
("55/40", (55.0, 40.0), None, (0.0, 0.0)),
("90/70", (90.0, 70.0), None, (0.0, 0.0)),
("30/80", (30.0, 80.0), None, (0.0, 0.0)),
("60/60 guard", (60.0, 60.0), guard, (0.0, 0.0)),
("20/20 guard", (20.0, 20.0), guard, (0.0, 0.0)),
("30/30 guard", (30.0, 30.0), guard, (0.0, 0.0)),
("25/45 guard", (25.0, 45.0), guard, (0.0, 0.0)),
("20/20 guard 14 kWh", (20.0, 20.0), guard,
(14.0, 14.0)),
("20/20 guard 6 kWh", (20.0, 20.0), guard,
(6.0, 6.0)),
("20/20 8 kWh", (20.0, 20.0), None, (8.0, 8.0))):
o_w, s_w, d_w = own_walk(soc, guard=g, sun=sun)
own[key] = (o_w, s_w, d_w, own_match(o_w, s_w, d_w))
print(f" own walk {key}: tab",
[[(r["kind"], r["from"], r["to"]) for r in b["blocks"]]
for b in o_w["days"][1]["batteries"]],
"control gives", {n: give_span(s_w, d_w, n) for n in s_w},
"mismatched", len(own[key][3]), own[key][3][:4])
check("F2, control walked with its own model of the packs (not the curve's "
"levels): in all 11 cases it charges and gives in exactly the quarters "
"the tab has a block of that kind, on both batteries",
all(not v[3] for v in own.values()))
o_g, s_g, d_g, _b = own["25/45 guard"]
check("...25/45 % with the peak guard: control gives 17:00-20:15 on both "
"(the packs empty in the 20:00 quarter), and the tab's give block "
"starts at 17:00 and ends inside that quarter, at the curve's floor "
"point",
all(give_span(s_g, d_g, n) == (17.0, 20.25) for n in s_g)
and all([(r["kind"], r["from"]) for r in b["blocks"]] == [("give",
"17:00")]
and 20.0 < b["blocks"][0]["end_h"] <= 20.25
for b in o_g["days"][1]["batteries"]))
o_6, s_6, d_6, _b = own["20/20 guard 6 kWh"]
check("...20/20 % with the guard and 6 kWh of sun: control gives 17:00-17:15, "
"and the tab's give block ends inside that quarter",
all(give_span(s_6, d_6, n) == (17.0, 17.25) for n in s_6)
and all(len(b["blocks"]) == 1 and 17.0 < b["blocks"][0]["end_h"] <= 17.25
for b in o_6["days"][1]["batteries"]))
fl_g = arbitrage.soc_bounds(S.make("dynamic", t22).o)[0]
check("...and the curve has a point exactly at the floor where the block "
"ends (no step end)",
all([v for h, v in b["levels"] if abs(h - b["blocks"][0]["end_h"])
< 1e-3] == [fl_g] for b in o_g["days"][1]["batteries"]))
def off_prices(h):
if 23.0 <= h < 24.0:
return 0.05
@@ -1065,6 +1212,19 @@ check("F2, modes off: one battery at the floor and one at 60 %: no give block "
all(not [r for r in b if r[0] == "give"] for b in kinds(od))
and all(not v for v in acts(sd, dd, "give").values())
and off_match(od, sd, dd))
c_dn = S.make("off", t22, soc=(20.0, 60.0))
c_dn.tariff_plan = S.quarters(t22, one_quarter(13.0, 7.0), days=3)
c_dn.tariff_at = t22
od_nl = tab(c_dn, t22, "nl")
check("...and B, which has energy, is not told it has none: the line names "
"'a battery' (the emptiest one holds the site back), EN and NL",
all(b["line"] == say("give_later") for b in od["days"][1]["batteries"])
and say("give_later").endswith(
"No block shows while a battery has no energy to give.")
and all(b["line"] == say("give_later", "nl")
for b in od_nl["days"][1]["batteries"])
and say("give_later", "nl").endswith(
"Er verschijnt geen blok zolang een batterij niets kan leveren."))
# C: full at 22:00 and a cheap quarter at 01:00: the house uses some first,
# so control charges then, and the tab has that block.
cc, oc, sc, dc = off_sweep(one_quarter(1.0, 17.0), t22, (95.0, 95.0))