Compare commits
3
Commits
UX-77
..
release/1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c66aa7f69a | ||
|
|
6d2b9b5335 | ||
|
|
47c89a13d0 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user