UX-77: Home's roll shares the peak room by room left to full, as control does
checks / unit (pull_request) Successful in 26m34s
infra-pin / fresh (pull_request) Successful in 2s

The cheap-run roll (cheaproll._Charge) shared the peak room by rating, control
shares it by room left to full (packshare.weights). With unequal levels one
battery's drawn block ended too early (rig F-1). The roll now uses
packshare.weights over the simulated levels, each battery capped at its rating,
a full one dropping out. test_ux77 gains two unequal-level walks and a
by-rating mutant; its walks model control's split by room too.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-06 09:11:00 +01:00
co-authored by Claude
parent 47c89a13d0
commit 6d2b9b5335
3 changed files with 102 additions and 19 deletions
+63 -12
View File
@@ -22,7 +22,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import ux54_scenarios as S # noqa: E402
from app import dayplan, homeview, modetrade, packmode # noqa: E402
from app import dayplan, homeview, modetrade, packmode, packshare # noqa: E402
from app.main import Controller # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
from app.static_tariff import static_plan # noqa: E402
@@ -38,6 +38,7 @@ UTC = timezone.utc
EFF = 0.95
AIM = 3450.0
SLOT = timedelta(hours=1) # the price slot of the day/night tariff
QUARTER = timedelta(minutes=15) # the run's own slot
SAMPLES = ("23:30", "00:30", "01:30", "02:30", "04:30")
OLD = ("leftover_solar", "cheap_hours") # the owner's old shape
NOW = ("leftover_solar", "leftover_solar") # the current one
@@ -83,8 +84,27 @@ def hm(s, day):
return day.replace(hour=h, minute=m)
def split_by_room(w, ch, rating, wt, invs):
"""Control's split of `w` over the chargers `ch`: by `packshare.weights()`,
no battery over its rating, the surplus to the others."""
p, open_, left = [0.0, 0.0], set(ch), w
while open_:
tot = sum(wt[invs[i].name] for i in open_)
over = [i for i in open_
if left * wt[invs[i].name] / tot > rating[i] + 1e-9]
if not over:
for i in open_:
p[i] = left * wt[invs[i].name] / tot
break
for i in over:
p[i] = rating[i]
left -= rating[i]
open_.discard(i)
return p
def walk(modes, soc0, house_w, night=None, dynamic=False, samples=SAMPLES,
start_h=22, end_h=7, plain=None):
start_h=22, end_h=7, plain=None, by_room=True, fc=(10.2, 10.0)):
"""Walk 22:00-07:15. Returns (charging, homes, commands):
charging[minute] = set of batteries control charges (power > 0),
homes[sample] = Home's `/status` plan at that minute, commands = the
@@ -116,7 +136,7 @@ def walk(modes, soc0, house_w, night=None, dynamic=False, samples=SAMPLES,
charging, homes, cmds = {}, {}, []
while now < end:
lt = loc(now)
today, tomorrow = (10.2, 10.0) if lt.hour < 12 else (10.0, 10.2)
today, tomorrow = fc if lt.hour < 12 else fc[::-1]
c.solar_fc = S.forecast(now, today, tomorrow, members=3)
c.solar_fc_why = None
if plan_at is None or (now - plan_at).total_seconds() >= 1800:
@@ -137,9 +157,13 @@ def walk(modes, soc0, house_w, night=None, dynamic=False, samples=SAMPLES,
if tick.roles.get(inv.name) == modetrade.CHARGE]
p = [0.0, 0.0]
if size < 0 and ch:
tot = sum(rating[i] for i in ch)
for i in ch:
p[i] = -size * rating[i] / tot
if by_room: # as control: room left to full, rating caps
wt = packshare.weights(c.pack_energy, -1.0)
p = split_by_room(-size, ch, rating, wt, c.inverters)
else:
tot = sum(rating[i] for i in ch)
for i in ch:
p[i] = -size * rating[i] / tot
for i in range(2):
soc[i] = min(100.0, soc[i] + p[i] / 1000.0 / 60.0 * EFF
/ caps[i] * 100.0)
@@ -175,7 +199,7 @@ def today_of(st, i):
return [(t["from"], t["to"]) for t in st["batteries"][i]["today"]]
def violations(charging, homes):
def violations(charging, homes, slot=SLOT):
"""What is wrong with Home's drawing at each sample."""
bad = []
for t, st in sorted(homes.items()):
@@ -191,8 +215,10 @@ def violations(charging, homes):
break
if mine:
stop = mine[-1] + timedelta(minutes=1)
if slot == QUARTER: # the last charging quarter's end
stop += timedelta(minutes=-stop.minute % 15)
for a, z in bl:
if z > stop + SLOT:
if z > stop + slot:
bad.append(f"{t:%H:%M} battery {i}: block to "
f"{z:%H:%M}, stops {stop:%H:%M}")
# the Today line says the same as the block
@@ -215,8 +241,8 @@ def violations(charging, homes):
# --------------------------------------------------------------------------- #
# Run
# --------------------------------------------------------------------------- #
def show(tag, ch, homes, want_bad):
bad = violations(ch, homes)
def show(tag, ch, homes, want_bad, slot=SLOT):
bad = violations(ch, homes, slot)
if want_bad:
check(f"{tag}: red", bool(bad), "no violation found")
else:
@@ -240,6 +266,11 @@ def main():
("current shape, no bind", NOW, (20.0, 20.0), 584.0, None),
("old shape, peak-bound", OLD, (40.0, 90.0), 2850.0, 2850.0),
("current shape, peak-bound", NOW, (40.0, 90.0), 2850.0, 2850.0))
# the rig's checks 2 and 3 (UX-77 round 2): room left and rating disagree
UNEQUAL = (("unequal levels, A+B Leftover solar, no bind", NOW,
(30.0, 20.0), 584.0, None, (5.0, 5.0)),
("unequal levels, A Leftover solar + B Cheap hours", OLD,
(40.0, 88.0), 2850.0, 2850.0, (10.2, 10.0)))
walked = {}
for tag, modes, soc, hw, night in CASES:
ch, homes, _cmds = walk(modes, soc, hw, night=night)
@@ -251,7 +282,7 @@ def main():
charged(ch, 0, day) or charged(ch, 1, day)))
print("2. a battery with nothing to charge gets no block")
ch, homes = walked["old shape, peak-bound"]
ch, homes, _c = walk(OLD, (40.0, 94.0), 2850.0, night=2850.0)
st = homes[datetime(2026, 11, 2, 2, 30, tzinfo=BILLING_TZ)]
check("B is at its level at 02:30: no block", blocks_of(st, 1) == [],
str(blocks_of(st, 1)))
@@ -281,7 +312,27 @@ def main():
any(r["kind"] == "grid" for st in homes.values()
for r in st["plan"]["runs"]))
print("5. the pieces")
print("5. unequal levels: the roll shares the room as control does")
for tag, modes, soc, hw, night, fc in UNEQUAL:
ch, homes, _c = walk(modes, soc, hw, night=night, fc=fc)
show(tag, ch, homes, False, QUARTER)
day = datetime(2026, 11, 2, 0, 30, tzinfo=BILLING_TZ)
check(f"{tag}: control charges after 00:30", bool(
charged(ch, 0, day) or charged(ch, 1, day)))
if hasattr(cheaproll, "_Charge") \
and hasattr(cheaproll._Charge, "_shares"):
real = cheaproll._Charge._shares
cheaproll._Charge._shares = lambda self, ch, w: {
p.name: w * abs(p.rating_w) / sum(abs(q.rating_w) for q in ch)
for p in ch}
try:
for tag, modes, soc, hw, night, fc in UNEQUAL:
ch, homes, _c = walk(modes, soc, hw, night=night, fc=fc)
show(f"mutant by rating, {tag}", ch, homes, True, QUARTER)
finally:
cheaproll._Charge._shares = real
print("6. the pieces")
if hasattr(cheaproll, "Roll"):
t = datetime(2026, 11, 2, 1, 46, tzinfo=UTC)
check("a stop is rounded up to the slot", cheaproll._ceil_slot(t)