Compare commits

..
2 Commits
Author SHA1 Message Date
glenn schrooyenandClaude 6d2b9b5335 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>
2026-11-06 09:11:00 +01:00
admin 47c89a13d0 Merge pull request 'UX-77: Home draws today's grid charge for the hours control will really charge' (#234) from UX-77 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-11-06 00:11:03 +01:00
3 changed files with 102 additions and 19 deletions
@@ -101,8 +101,9 @@ is `SolarToday.add()` after the solar read, in a `try`, in memory.
control will stop charging, as the tab does for tomorrow's: the cheap run
rolls on at its end while a battery is short (`cheaproll.roll_end()`, the
same loop as `dayview._levels()`), the level read at the charge power the
peak limit leaves (peak aim less the house, else the rating), and each
battery's block ends at its own stop. The curve stops charging at its level
peak limit leaves (peak aim less the house, else the rating) and shared by
room left to full as control shares it (`packshare.weights()`, re-read as
the levels move), and each battery's block ends at its own stop. The curve stops charging at its level
(`charge_to()`) and giving at the floor.
- Today, a give run in the plan on a battery that is empty now has no block on
Home (no energy to give). The tab equals Home, and says so: "The add-on plans
+36 -5
View File
@@ -10,8 +10,10 @@ that goes on until the batteries are at their level or the cheap hours end.
(`modetrade.energy()`, `priceruns.plan()`), until nothing is short or the next
run does not follow on. `for_today()` reads the batteries at the level they
will have by then: the charge power is the peak limit's room (peak aim minus
the house, as `dayplan` counts it), shared by rating. Where that room is not
known (no house profile or no aim), the rating is used.
the house, as `dayplan` counts it), shared as control shares it: by room
left to full (`packshare.weights()`), read again as the levels move, so a
battery that fills drops out and its share goes to the others. Where that
room is not known (no house profile or no aim), the rating is used.
DECISIONS/UX-54-forecast-tab.md has the record.
"""
@@ -20,7 +22,7 @@ import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from . import arbitrage, dayplan, modetrade, priceruns
from . import arbitrage, dayplan, modetrade, packshare, priceruns
from .dayplan import EFF
from .price import required_spread, uncertainty_of
@@ -85,7 +87,7 @@ def _ceil_slot(t):
class _Charge:
"""The batteries' levels followed from `t0` while the chargers charge at
the room the peak limit leaves: `min(sum of ratings, aim - house)`, shared
by rating, each stopping at `modetrade.charge_to()`."""
by room left to full as control does (`_shares()`), each stopping at `modetrade.charge_to()`."""
def __init__(self, packs, t0, ceiling_pct, targets, aim_w, house):
self.packs = {p.name: p for p in packs}
@@ -115,6 +117,34 @@ class _Charge:
self._charge(s, e, hour)
self.t = nxt
def _shares(self, ch, w):
"""Watts per charger: `w` shared by `packshare.weights()` over all
packs (room left to full, as control passes it), no charger above its
rating, the surplus going to the others. By rating where the room left
is not known (no capacity)."""
wt = packshare.weights(list(self.packs.values()), -1.0)
if wt is None:
wt = {p.name: abs(p.rating_w) for p in ch}
wt = {p.name: wt.get(p.name, 0.0) for p in ch}
cap = {p.name: abs(p.rating_w) for p in ch}
out, open_, left = {}, set(wt), float(w)
while open_:
tot = sum(wt[n] for n in open_)
if tot <= 0.0:
for n in open_:
out[n] = 0.0
break
over = [n for n in open_ if left * wt[n] / tot > cap[n] + 1e-9]
if not over:
for n in open_:
out[n] = left * wt[n] / tot
break
for n in over:
out[n] = cap[n]
left -= cap[n]
open_.discard(n)
return out
def _charge(self, s, e, hour):
ch = modetrade.chargers(list(self.packs.values()), self.ceil,
targets=self.targets)
@@ -123,12 +153,13 @@ class _Charge:
return
w = self._room_w(hour, total)
hrs = (e - s).total_seconds() / 3600.0
share = self._shares(ch, w)
for p in ch:
self.stops.setdefault(p.name, None)
if not p.capacity_kwh:
continue
to = modetrade.charge_to(p, self.ceil, self.targets)
kwh = w * abs(p.rating_w) / total * hrs / 1000.0 * EFF
kwh = share[p.name] * hrs / 1000.0 * EFF
room = (to - p.soc) / 100.0 * float(p.capacity_kwh)
if kwh <= 0.0:
continue
+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)