diff --git a/goodwe_controller/DECISIONS/UX-54-forecast-tab.md b/goodwe_controller/DECISIONS/UX-54-forecast-tab.md index 74eea69..e0ff423 100644 --- a/goodwe_controller/DECISIONS/UX-54-forecast-tab.md +++ b/goodwe_controller/DECISIONS/UX-54-forecast-tab.md @@ -48,9 +48,14 @@ battery's expected level with its blocks. It follows the add-on, as Home does levels (`_mode_roles()` each quarter; the house from the profile, the sun at 100 % trust, a GIVE role counted only where control's own discharge envelope `packshare.envelope()` is open, the cut-out at the floor), charges - and gives in exactly the quarters the tab has a block in 11 cases + and gives in exactly the quarters the tab has a block in 13 cases (`test_ux54.py` part 7, `own_walk()`), e.g. 25/45 % with the peak guard: - control gives 17:00-20:15, the block is 17:00-20:06. With a cut-out under + control gives 17:00-20:15, the block is 17:00-20:06; 30/80 % with a 7 kW + evening house, B held to its rating: control 17:00-19:45, the block + 17:00-19:32. The floor point is placed in one cut, where the battery runs + out at the rate it gives (`_floor_cut()`, checked to the second against + times worked out by hand), so a build stays small: at most 14 floor cuts + and 28 level points per battery and day in those cases. With a cut-out under the floor, or a house or sun that differs from the forecast and profile, control differs (Limits). - **The sun** is what `solarfc` reads. An hourly split (Solcast @@ -64,7 +69,9 @@ battery's expected level with its blocks. It follows the add-on, as Home does `daycurve.curve()` derives it: each battery has the role `modetrade.decide()` gives (follow the house, hold, give, charge from the grid, rest), spare solar goes to Solar first batteries first, a shortfall is met by those that may - give, both at `dayplan.EFF`, inside the plan's floor and ceiling, from the + give, shared as control shares it (by what each holds above the floor, + `packshare.weights()`, each at most its rating, the rest passed on), both + at `dayplan.EFF`, inside the plan's floor and ceiling, from the level now. Spare solar is `trust x max(0, sun - house)` and what is stored loses `EFF`, the plan's own `trust x EFF x max(0, sun - house)` (10 kWh of sun, 2 kWh of house, 70 %: 5.32 kWh stored). A shortfall is not scaled by diff --git a/goodwe_controller/app/daycurve.py b/goodwe_controller/app/daycurve.py index 2588ae1..b662fb1 100644 --- a/goodwe_controller/app/daycurve.py +++ b/goodwe_controller/app/daycurve.py @@ -13,10 +13,13 @@ first or no price plan is on; inside a give run it covers the house (GIVE); else it holds (HOLD, may charge from spare solar, gives nothing). Spare solar (sun less the house's use) goes to Solar first batteries first, then the rest, by room. A shortfall comes from the batteries that may give, -those in a give run first, by what they hold above the floor. Charging and -giving lose `dayplan.EFF`, as the plan counts. A level stays inside the -floor and ceiling the plan uses, and a battery already outside them does -not move further out. +those in a give run first, by what they hold above the floor, each at most +its rating, the rest passed on (as control shares it, `packshare.weights()` +and the allocator's fill). A battery that empties inside a step gets a +point there (`_floor_cut()`), as one that reaches its grid level does. +Charging and giving lose `dayplan.EFF`, as the plan counts. A level stays +inside the floor and ceiling the plan uses, and a battery already outside +them does not move further out. `SolarToday` keeps the site's measured solar per hour, in memory, from the reading the control cycle already has. It is shown and never read back @@ -91,38 +94,41 @@ def _spread(amount, caps, weights): def _give_caps(roles, level, by, floor, hrs): - """`(first, rest)`: what each battery that may give can give in a step - of `hrs` hours (its rating, or what it holds above the floor), those in a - give run first.""" - caps = {n: min(by[n].rating_kw * hrs, - max(0.0, level[n] - floor) / 100.0 - * by[n].capacity_kwh * EFF) + """`(first, rest, held)`: what each battery that may give can give in a + step of `hrs` hours (its rating, or what it holds above the floor), those + in a give run first, and what each holds above the floor (as given).""" + held = {n: max(0.0, level[n] - floor) / 100.0 * by[n].capacity_kwh * EFF for n, r in roles.items() if r[2]} + caps = {n: min(by[n].rating_kw * hrs, h) for n, h in held.items()} first = {n: c for n, c in caps.items() if roles[n][0] == GIVE} - return first, {n: c for n, c in caps.items() if n not in first} + return first, {n: c for n, c in caps.items() if n not in first}, held def _floor_cut(a, z, net, roles, level, by, floor, hrs): """Where in the step `[a, z)` the first battery that gives reaches the - floor, or None. A shortfall is spread over a step at a steady rate and - shared by what each can give, so a group that holds less than its part - of the shortfall, one of them short of its rating, is empty at - `held / part` of the step.""" - left, cut = -net, None - for grp in _give_caps(roles, level, by, floor, hrs): - held = sum(grp.values()) - if held > EPS and left > held + EPS and any( - c < by[n].rating_kw * hrs * (1.0 - 1e-9) - for n, c in grp.items()): - t = a + (z - a) * (held / left) - if a + MIN_CUT < t < z - MIN_CUT and (cut is None or t < cut): - cut = t - left = max(0.0, left - held) + floor, or None. The shortfall comes at a steady rate, `-net / hrs` kW. + It is shared as control shares it (`packshare.weights()`: by the energy + each holds above its cut-out, here the floor), each at most its rating, + what one cannot take passed to the others, those in a give run first. + A battery giving `v` kW that holds `h` kWh is empty at `h / v` hours; a + cut there makes the step come out exact. A battery already at the floor + holds nothing, gives nothing and never sets a cut.""" + rate, cut = -net / hrs, None + *groups, held = _give_caps(roles, level, by, floor, hrs) + for grp in groups: + rates, rate = _spread(rate, {n: by[n].rating_kw for n in grp}, + {n: held[n] for n in grp}) + for n, v in rates.items(): + if held[n] > EPS and v > EPS: + t = a + timedelta(hours=held[n] / v) + if a + MIN_CUT < t < z - MIN_CUT and (cut is None + or t < cut): + cut = t return cut def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct, - trust=1.0): + trust=1.0, stats=None): """`{name: [(t, pct), ...]}` from `start` (each battery's level now) to `end`, a point at every hour edge, every block edge and where a battery reaches its grid level or, giving, the floor. A battery's list @@ -130,7 +136,9 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct, `stop_at` comes. `sun_kwh` and `use_kwh` give kWh for the span `[a, b)`. Spare solar is `trust x max(0, sun - use)`, and what is stored loses `EFF`: `dayplan.plan()`'s own `trust x EFF x max(0, sun - house)`. A - shortfall is the house's use less the whole sun.""" + shortfall is the house's use less the whole sun. `stats`, a dict or + None: gets `floor_cuts` (the floor points placed) and `capped` (True when + `MAX_FLOOR_CUTS` stopped one).""" floor, ceil = float(floor_pct), float(ceiling_pct) cuts = {end} for a, _b, _h in _hour_slices(start, end): @@ -143,7 +151,7 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct, level = {b.name: float(b.soc) for b in batts} out = {b.name: [(start, level[b.name])] for b in batts} alive = {b.name for b in batts} - floor_cuts = 0 + floor_cuts, capped = 0, False i = 0 while i < len(ts) - 1: a, z = ts[i], ts[i + 1] @@ -185,7 +193,9 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct, # A battery that gives all it holds inside the step gets a point # where it reaches the floor, the same way. cut = _floor_cut(a, z, net, roles, level, by, floor, hrs) \ - if net < -EPS and floor_cuts < MAX_FLOOR_CUTS else None + if net < -EPS else None + if cut is not None and floor_cuts >= MAX_FLOOR_CUTS: + capped, cut = True, None if cut is not None: floor_cuts += 1 ts.insert(i + 1, cut) @@ -208,16 +218,19 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct, for n, v in got.items(): level[n] += v * EFF / by[n].capacity_kwh * 100.0 elif net < -EPS: - first, rest = _give_caps(roles, level, by, floor, hrs) + first, rest, held = _give_caps(roles, level, by, floor, hrs) left = -net for grp in (first, rest): - got, left = _spread(left, grp, dict(grp)) + got, left = _spread(left, grp, {n: held[n] for n in grp}) for n, v in got.items(): level[n] -= v / EFF / by[n].capacity_kwh * 100.0 for b in batts: if b.name in alive: out[b.name].append((z, level[b.name])) i += 1 + if stats is not None: + stats["floor_cuts"] = stats.get("floor_cuts", 0) + floor_cuts + stats["capped"] = stats.get("capped", False) or capped return out diff --git a/goodwe_controller/test_ux54.py b/goodwe_controller/test_ux54.py index d6968c1..8d6496f 100644 --- a/goodwe_controller/test_ux54.py +++ b/goodwe_controller/test_ux54.py @@ -43,6 +43,7 @@ from app import arbitrage, dayplan, dayview, homeview, modetrade # noqa: E402 from app import packmode, packshare, peakguard, solarfc, uitext # noqa: E402 from app.main import pack_energy_of # noqa: E402 from app.price import Uncertainty # noqa: E402 +from app import daycurve # noqa: E402 from app.daycurve import Batt, SolarToday, curve # noqa: E402 from app.peakguard import PeakDecision # noqa: E402 @@ -324,6 +325,64 @@ check("a battery already above the ceiling does not charge and one below the " slow = run(Batt("a", 10.0, 1.0, 50.0, True, False), flat(5.0), flat(0.0)) check("the rating caps the rate: 1 kW charges 9.5 % of 10 kWh an hour", abs(lv(slow, "a", 1) - 59.5) < 1e-6) +# Where a giving battery reaches the floor, worked out by hand. 10 kWh at +# 25 % over a 20 % floor holds 0.05 x 10 x 0.95 = 0.475 kWh to give. + + +def floor_at(out, name): + """`(seconds after T0, level)` of the first point at the floor.""" + return next(((round((t - T0).total_seconds(), 3), v) for t, v in out[name] + if abs(v - 20.0) < 1e-9), (None, None)) + + +fc1 = run(Batt("a", 10.0, 5.0, 25.0, True, False), flat(0.0), flat(2.0)) +check("one battery, 2 kW short: empty at 0.475 / 2 h = 14:15 (855 s), a point " + f"there at the floor ({floor_at(fc1, 'a')})", + floor_at(fc1, "a") == (855.0, 20.0)) +fc2 = run([Batt("a", 10.0, 5.0, 25.0, True, False), + Batt("b", 20.0, 4.0, 80.0, True, False)], flat(0.0), flat(5.0)) +check("two that may give, 5 kW short: shared by what they hold (0.475 : " + "11.4 kWh), B held to its 4 kW, so A gives 1 kW and is empty at " + f"28:30 (1710 s) ({floor_at(fc2, 'a')})", + floor_at(fc2, "a") == (1710.0, 20.0)) +GV1 = ((T0, T0 + timedelta(hours=2)),) +fc3 = run([Batt("g", 20.0, 2.0, 80.0, False, True, (), GV1), + Batt("h", 10.0, 5.0, 25.0, True, False)], flat(0.0), flat(2.95)) +check("the second group: G in a give run gives first, at its 2 kW; the 0.95 " + "kW left goes to H (Solar first), empty at 0.475 / 0.95 h = 30:00 " + f"(1800 s) ({floor_at(fc3, 'h')})", + floor_at(fc3, "h") == (1800.0, 20.0)) +fc4 = run([Batt("a", 10.0, 5.0, 25.0, True, False), + Batt("b", 20.0, 5.0, 80.0, True, False)], flat(0.0), flat(3.0)) +check("shared by what they hold, not by what they can give in a step: 3 kW " + "short, A (0.475 kWh) and B (11.4 kWh) drain in step and are both " + "empty when the 11.875 kWh is gone, at 3:57:30 (14250 s) " + f"({floor_at(fc4, 'a')}, {floor_at(fc4, 'b')})", + floor_at(fc4, "a") == (14250.0, 20.0) + and floor_at(fc4, "b") == (14250.0, 20.0)) +fc5 = curve([Batt("a", 10.0, 5.0, 20.0, True, False), + Batt("b", 20.0, 2.0, 80.0, True, False)], T0, + T0 + timedelta(hours=4), flat(0.0), flat(7.0), floor_pct=20.0, + ceiling_pct=95.0) +check("a battery already at the floor sets no cut: A at 20 %, B held to its " + "2 kW for 4 h (it holds 11.4 kWh): one point per hour " + f"({len(fc5['a'])} points)", + len(fc5["a"]) == 5 and len(fc5["b"]) == 5) +st_cap, real_max = {}, daycurve.MAX_FLOOR_CUTS +daycurve.MAX_FLOOR_CUTS = 0 +try: + fc6 = run(Batt("a", 10.0, 5.0, 25.0, True, False), flat(0.0), flat(2.0), + stats=st_cap) +finally: + daycurve.MAX_FLOOR_CUTS = real_max +st_ok = {} +run(Batt("a", 10.0, 5.0, 25.0, True, False), flat(0.0), flat(2.0), + stats=st_ok) +check("the curve says when its bound on floor points stopped one (`stats`): " + f"{st_cap} at a bound of 0, {st_ok} as shipped", + st_cap == {"floor_cuts": 0, "capped": True} + and st_ok == {"floor_cuts": 1, "capped": False} + and floor_at(fc6, "a") == (3600.0, 20.0)) # Roles. GV = ((T0 + timedelta(hours=3), T0 + timedelta(hours=6)),) hold = Batt("p", 10.0, 5.0, 60.0, False, True, (), GV) @@ -903,14 +962,65 @@ check("F2: the same with the batteries part full and nearly full at 22:00", # its rating up to `charge_to()`; spare sun goes to the batteries whose range # may charge, by room, up to the ceiling; a shortfall is covered by those # whose range may give and whose own discharge envelope is open, by what -# they hold above the cut-out (at the floor), each at most its rating. The -# house is the profile the tab reads, the sun the forecast at 100 % trust. -# Per battery and quarter tomorrow, control charges (gives) exactly where the -# tab has a grid (give) block, both ways, no tolerance. -def own_walk(soc, *, guard=None, sun=(0.0, 0.0)): +# they hold above the cut-out (at the floor), each at most its rating, what +# one cannot take passed to the others (a water fill, as the allocator's +# `_fill()`). The house is the profile the tab reads, the sun the forecast +# at 100 % trust. Per battery and quarter tomorrow, control charges (gives) +# exactly where the tab has a grid (give) block, both ways, no tolerance. +# Each tab build is also measured: the curves' floor cuts (`stats`), the +# level points per battery and day, and the fastest of five builds. +def water(amount, caps, weights): + """`amount` shared by `weights`, none above its cap, the rest passed on.""" + got = {n: 0.0 for n in caps} + live = {n for n in caps if caps[n] > 1e-12} + left = amount + while left > 1e-12 and live: + w = sum(weights[n] for n in live) + if w <= 0.0: + break + over = {n for n in live + if left * weights[n] / w >= caps[n] - got[n] - 1e-12} + if not over: + for n in live: + got[n] += left * weights[n] / w + break + for n in over: + left -= caps[n] - got[n] + got[n] = caps[n] + live.discard(n) + return got + + +def built(c, now): + """The tab, with `(floor cuts, capped, fastest of 5 builds in s)`.""" + stats, real = {}, dayview.curve + + def counted(*a, **k): + return real(*a, stats=stats, **k) + dayview.curve = counted + try: + out = tab(c, now) + cut = (stats.get("floor_cuts", 0), stats.get("capped", False)) + st = home(c, now) + fast = [] + for _ in range(5): + t0_ = time.perf_counter() + dayview.build(c, st, now) + fast.append(time.perf_counter() - t0_) + finally: + dayview.curve = real + return out, cut + (min(fast),) + + +def own_walk(soc, *, guard=None, sun=(0.0, 0.0), evening=None, max_w=None): now0 = t22 c = S.make("dynamic", now0, soc=soc) cut_out_at_floor(c) + if max_w is not None: + for inv in c.inverters: + inv.max_w = max_w + if evening is not None: + c.house_profile = S.profile(now0, evening=evening) floor_, ceil_ = arbitrage.soc_bounds(c.o) c.tariff_plan = S.quarters(now0, night_evening, days=3) c.tariff_at = now0 @@ -919,7 +1029,7 @@ def own_walk(soc, *, guard=None, sun=(0.0, 0.0)): c.solar_trust_pct = 100 c.solar_fc = fc = S.forecast(now0, sun[0], sun[1]) c._mode_roles(now0, None) - out = tab(c, now0) + out, cost = built(c, now0) house_w, base_w = c.house_profile.estimator() d0, _d1 = homeview.local_day(now0) seen = {inv.name: {} for inv in c.inverters} @@ -959,15 +1069,14 @@ def own_walk(soc, *, guard=None, sun=(0.0, 0.0)): i.soc += part * dayplan.EFF / i.capacity_kwh * 100.0 elif net < 0.0: hold = {i.name: max(0.0, i.soc - floor_) / 100.0 * i.capacity_kwh - for i in givers} - tot = sum(hold.values()) + * dayplan.EFF for i in givers} + got = water(-net, {i.name: min(hold[i.name], i.max_w / 1000.0 + * 0.25) for i in givers}, hold) for i in givers: - part = min(-net * hold[i.name] / tot, - i.max_w / 1000.0 * 0.25) if tot > 0.0 else 0.0 - i.soc = max(floor_, i.soc - part / dayplan.EFF + i.soc = max(floor_, i.soc - got[i.name] / dayplan.EFF / i.capacity_kwh * 100.0) t += Q - return out, seen, d0 + return out, seen, d0, cost def own_match(out, seen, d0): @@ -998,7 +1107,7 @@ def give_span(seen, d0, name): own = {} -for key, soc, g, sun in (("20/20", (20.0, 20.0), None, (0.0, 0.0)), +for key, soc, g, sun, *more in (("20/20", (20.0, 20.0), None, (0.0, 0.0)), ("55/40", (55.0, 40.0), None, (0.0, 0.0)), ("90/70", (90.0, 70.0), None, (0.0, 0.0)), ("30/80", (30.0, 80.0), None, (0.0, 0.0)), @@ -1010,19 +1119,46 @@ for key, soc, g, sun in (("20/20", (20.0, 20.0), None, (0.0, 0.0)), (14.0, 14.0)), ("20/20 guard 6 kWh", (20.0, 20.0), guard, (6.0, 6.0)), - ("20/20 8 kWh", (20.0, 20.0), None, (8.0, 8.0))): - o_w, s_w, d_w = own_walk(soc, guard=g, sun=sun) - own[key] = (o_w, s_w, d_w, own_match(o_w, s_w, d_w)) + ("20/20 8 kWh", (20.0, 20.0), None, (8.0, 8.0)), + # one battery empty, the other held to its rating + ("30/80 guard 7 kW house", (30.0, 80.0), guard, + (0.0, 0.0), 7000.0, None), + ("30/80 guard 2 x 3 kW, 3.5 kW house", (30.0, 80.0), + guard, (0.0, 0.0), 3500.0, 3000.0)): + evening_, max_w_ = (more + [None, None])[:2] + o_w, s_w, d_w, cost = own_walk(soc, guard=g, sun=sun, evening=evening_, + max_w=max_w_) + pts = max(len(b["levels"]) for d in o_w["days"] for b in d["batteries"]) + own[key] = (o_w, s_w, d_w, own_match(o_w, s_w, d_w), cost, pts) print(f" own walk {key}: tab", [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]] for b in o_w["days"][1]["batteries"]], "control gives", {n: give_span(s_w, d_w, n) for n in s_w}, - "mismatched", len(own[key][3]), own[key][3][:4]) + "mismatched", len(own[key][3]), own[key][3][:4], + f"| floor cuts {cost[0]}, capped {cost[1]}, {pts} points, " + f"{cost[2] * 1000:.0f} ms") check("F2, control walked with its own model of the packs (not the curve's " - "levels): in all 11 cases it charges and gives in exactly the quarters " + "levels): in all 13 cases it charges and gives in exactly the quarters " "the tab has a block of that kind, on both batteries", - all(not v[3] for v in own.values())) -o_g, s_g, d_g, _b = own["25/45 guard"] + len(own) == 13 and all(not v[3] for v in own.values())) +MAX_CUTS, MAX_POINTS, MAX_S = 40, 40, 0.2 +big = {k: (v[4], v[5]) for k, v in own.items() if v[4][1] + or v[4][0] > MAX_CUTS or v[5] > MAX_POINTS or v[4][2] >= MAX_S} +check(f"...and every build stays small: at most {MAX_CUTS} floor cuts over " + f"all its curves, never the curve's bound (`MAX_FLOOR_CUTS`), at most " + f"{MAX_POINTS} level points per battery and day, and under {MAX_S:g} s " + f"(the fastest of five builds on this host) {big}", + all(v[4][0] <= MAX_CUTS and not v[4][1] and v[5] <= MAX_POINTS + and v[4][2] < MAX_S for v in own.values())) +o_7, s_7, d_7 = own["30/80 guard 7 kW house"][:3] +check("...30/80 % with the guard and a 7 kW evening house (A soon empty, B " + "held to its rating): control gives A 17:00-17:45 and B 17:00-19:45, " + "and B's give block ends in control's last giving quarter, not at 20:00", + give_span(s_7, d_7, "inverter-a") == (17.0, 17.75) + and give_span(s_7, d_7, "inverter-b") == (17.0, 19.75) + and 19.5 < o_7["days"][1]["batteries"][1]["blocks"][-1]["end_h"] + <= 19.75) +o_g, s_g, d_g, _b = own["25/45 guard"][:4] check("...25/45 % with the peak guard: control gives 17:00-20:15 on both " "(the packs empty in the 20:00 quarter), and the tab's give block " "starts at 17:00 and ends inside that quarter, at the curve's floor " @@ -1032,7 +1168,7 @@ check("...25/45 % with the peak guard: control gives 17:00-20:15 on both " "17:00")] and 20.0 < b["blocks"][0]["end_h"] <= 20.25 for b in o_g["days"][1]["batteries"])) -o_6, s_6, d_6, _b = own["20/20 guard 6 kWh"] +o_6, s_6, d_6, _b = own["20/20 guard 6 kWh"][:4] check("...20/20 % with the guard and 6 kWh of sun: control gives 17:00-17:15, " "and the tab's give block ends inside that quarter", all(give_span(s_6, d_6, n) == (17.0, 17.25) for n in s_6)