From 6d2b9b53355607d7e923f6082d6b4b47cb08c188 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 6 Nov 2026 09:11:00 +0100 Subject: [PATCH] UX-77: Home's roll shares the peak room by room left to full, as control does 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 --- .../DECISIONS/UX-54-forecast-tab.md | 5 +- goodwe_controller/app/cheaproll.py | 41 ++++++++-- goodwe_controller/test_ux77.py | 75 ++++++++++++++++--- 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/goodwe_controller/DECISIONS/UX-54-forecast-tab.md b/goodwe_controller/DECISIONS/UX-54-forecast-tab.md index 577a681..54b66d7 100644 --- a/goodwe_controller/DECISIONS/UX-54-forecast-tab.md +++ b/goodwe_controller/DECISIONS/UX-54-forecast-tab.md @@ -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 diff --git a/goodwe_controller/app/cheaproll.py b/goodwe_controller/app/cheaproll.py index e9eb515..d3d0346 100644 --- a/goodwe_controller/app/cheaproll.py +++ b/goodwe_controller/app/cheaproll.py @@ -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 diff --git a/goodwe_controller/test_ux77.py b/goodwe_controller/test_ux77.py index 55b9900..8ebd730 100644 --- a/goodwe_controller/test_ux77.py +++ b/goodwe_controller/test_ux77.py @@ -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)