Merge pull request 'UX-77 round 2: Home's roll shares the peak room by room left, as control does' (#235) from UX-77-2 into release/1.0
infra-pin / fresh (push) Successful in 1s

This commit was merged in pull request #235.
This commit is contained in:
2026-11-06 11:19:20 +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 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 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 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 peak limit leaves (peak aim less the house, else the rating) and shared by
battery's block ends at its own stop. The curve stops charging at its level 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. (`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 - 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 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 (`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 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 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 the house, as `dayplan` counts it), shared as control shares it: by room
known (no house profile or no aim), the rating is used. 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. DECISIONS/UX-54-forecast-tab.md has the record.
""" """
@@ -20,7 +22,7 @@ import math
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from . import arbitrage, dayplan, modetrade, priceruns from . import arbitrage, dayplan, modetrade, packshare, priceruns
from .dayplan import EFF from .dayplan import EFF
from .price import required_spread, uncertainty_of from .price import required_spread, uncertainty_of
@@ -85,7 +87,7 @@ def _ceil_slot(t):
class _Charge: class _Charge:
"""The batteries' levels followed from `t0` while the chargers charge at """The batteries' levels followed from `t0` while the chargers charge at
the room the peak limit leaves: `min(sum of ratings, aim - house)`, shared 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): def __init__(self, packs, t0, ceiling_pct, targets, aim_w, house):
self.packs = {p.name: p for p in packs} self.packs = {p.name: p for p in packs}
@@ -115,6 +117,34 @@ class _Charge:
self._charge(s, e, hour) self._charge(s, e, hour)
self.t = nxt 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): def _charge(self, s, e, hour):
ch = modetrade.chargers(list(self.packs.values()), self.ceil, ch = modetrade.chargers(list(self.packs.values()), self.ceil,
targets=self.targets) targets=self.targets)
@@ -123,12 +153,13 @@ class _Charge:
return return
w = self._room_w(hour, total) w = self._room_w(hour, total)
hrs = (e - s).total_seconds() / 3600.0 hrs = (e - s).total_seconds() / 3600.0
share = self._shares(ch, w)
for p in ch: for p in ch:
self.stops.setdefault(p.name, None) self.stops.setdefault(p.name, None)
if not p.capacity_kwh: if not p.capacity_kwh:
continue continue
to = modetrade.charge_to(p, self.ceil, self.targets) 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) room = (to - p.soc) / 100.0 * float(p.capacity_kwh)
if kwh <= 0.0: if kwh <= 0.0:
continue continue
+63 -12
View File
@@ -22,7 +22,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING) logging.disable(logging.WARNING)
import ux54_scenarios as S # noqa: E402 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.main import Controller # noqa: E402
from app.peakguard import PeakDecision # noqa: E402 from app.peakguard import PeakDecision # noqa: E402
from app.static_tariff import static_plan # noqa: E402 from app.static_tariff import static_plan # noqa: E402
@@ -38,6 +38,7 @@ UTC = timezone.utc
EFF = 0.95 EFF = 0.95
AIM = 3450.0 AIM = 3450.0
SLOT = timedelta(hours=1) # the price slot of the day/night tariff 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") SAMPLES = ("23:30", "00:30", "01:30", "02:30", "04:30")
OLD = ("leftover_solar", "cheap_hours") # the owner's old shape OLD = ("leftover_solar", "cheap_hours") # the owner's old shape
NOW = ("leftover_solar", "leftover_solar") # the current one NOW = ("leftover_solar", "leftover_solar") # the current one
@@ -83,8 +84,27 @@ def hm(s, day):
return day.replace(hour=h, minute=m) 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, 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): """Walk 22:00-07:15. Returns (charging, homes, commands):
charging[minute] = set of batteries control charges (power > 0), charging[minute] = set of batteries control charges (power > 0),
homes[sample] = Home's `/status` plan at that minute, commands = the 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 = {}, {}, [] charging, homes, cmds = {}, {}, []
while now < end: while now < end:
lt = loc(now) 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 = S.forecast(now, today, tomorrow, members=3)
c.solar_fc_why = None c.solar_fc_why = None
if plan_at is None or (now - plan_at).total_seconds() >= 1800: 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] if tick.roles.get(inv.name) == modetrade.CHARGE]
p = [0.0, 0.0] p = [0.0, 0.0]
if size < 0 and ch: if size < 0 and ch:
tot = sum(rating[i] for i in ch) if by_room: # as control: room left to full, rating caps
for i in ch: wt = packshare.weights(c.pack_energy, -1.0)
p[i] = -size * rating[i] / tot 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): for i in range(2):
soc[i] = min(100.0, soc[i] + p[i] / 1000.0 / 60.0 * EFF soc[i] = min(100.0, soc[i] + p[i] / 1000.0 / 60.0 * EFF
/ caps[i] * 100.0) / 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"]] 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.""" """What is wrong with Home's drawing at each sample."""
bad = [] bad = []
for t, st in sorted(homes.items()): for t, st in sorted(homes.items()):
@@ -191,8 +215,10 @@ def violations(charging, homes):
break break
if mine: if mine:
stop = mine[-1] + timedelta(minutes=1) 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: for a, z in bl:
if z > stop + SLOT: if z > stop + slot:
bad.append(f"{t:%H:%M} battery {i}: block to " bad.append(f"{t:%H:%M} battery {i}: block to "
f"{z:%H:%M}, stops {stop:%H:%M}") f"{z:%H:%M}, stops {stop:%H:%M}")
# the Today line says the same as the block # the Today line says the same as the block
@@ -215,8 +241,8 @@ def violations(charging, homes):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Run # Run
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def show(tag, ch, homes, want_bad): def show(tag, ch, homes, want_bad, slot=SLOT):
bad = violations(ch, homes) bad = violations(ch, homes, slot)
if want_bad: if want_bad:
check(f"{tag}: red", bool(bad), "no violation found") check(f"{tag}: red", bool(bad), "no violation found")
else: else:
@@ -240,6 +266,11 @@ def main():
("current shape, no bind", NOW, (20.0, 20.0), 584.0, None), ("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), ("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)) ("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 = {} walked = {}
for tag, modes, soc, hw, night in CASES: for tag, modes, soc, hw, night in CASES:
ch, homes, _cmds = walk(modes, soc, hw, night=night) ch, homes, _cmds = walk(modes, soc, hw, night=night)
@@ -251,7 +282,7 @@ def main():
charged(ch, 0, day) or charged(ch, 1, day))) charged(ch, 0, day) or charged(ch, 1, day)))
print("2. a battery with nothing to charge gets no block") 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)] 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) == [], check("B is at its level at 02:30: no block", blocks_of(st, 1) == [],
str(blocks_of(st, 1))) str(blocks_of(st, 1)))
@@ -281,7 +312,27 @@ def main():
any(r["kind"] == "grid" for st in homes.values() any(r["kind"] == "grid" for st in homes.values()
for r in st["plan"]["runs"])) 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"): if hasattr(cheaproll, "Roll"):
t = datetime(2026, 11, 2, 1, 46, tzinfo=UTC) t = datetime(2026, 11, 2, 1, 46, tzinfo=UTC)
check("a stop is rounded up to the slot", cheaproll._ceil_slot(t) check("a stop is rounded up to the slot", cheaproll._ceil_slot(t)