UX-54 round 5: give blocks end at the floor point, the tab on the clock-change days
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:
co-authored by
Claude
parent
00c8ae354c
commit
8dbf831854
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user