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
infra-pin / fresh (push) Successful in 1s
This commit was merged in pull request #235.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user