From 5ab8622265b95a9dcd70078b3569f4b37268868d Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Tue, 27 Oct 2026 00:53:58 +0100 Subject: [PATCH] UX-54 round 3: tomorrow's blocks last as long as control acts, give blocks checked at run start - Tomorrow's grid block is drawn to where control stops charging. A cheap run is sized for all batteries together; a slower one is still below `charge_to()` when it ends and control plans the next cheap slots. The tab asks `modetrade.energy()` and `priceruns.plan()` with the packs at the curve's level and extends the run, so the curve charges as long. - Each battery's block ends where it is at its own level (grid) or at the floor (give). The curve puts a point where a battery reaches its charge level inside a step. - `homeview.mode_runs(ahead=f, rp=...)` reads each block at the level the curve has when it starts, with Home's own filter and MIN_KWH: no give block at the floor, no grid block at the planned level. Home never passes either. - `no_tomorrow` names the hour from `solarfc.BELL_FROM_H`, where the curve stops. - test_ux54.py part 7 compares control (`_mode_roles()` every 15 minutes) with the tab with no tolerance, and adds the cloudy / floor case. No decision, command, timing or option key changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC --- .../DECISIONS/UX-54-forecast-tab.md | 34 +++-- goodwe_controller/app/daycurve.py | 21 ++- goodwe_controller/app/dayview.py | 136 ++++++++++++++---- goodwe_controller/app/homeview.py | 31 ++-- goodwe_controller/app/ui_text/en.json | 2 +- goodwe_controller/app/ui_text/nl.json | 2 +- goodwe_controller/test_ux54.py | 121 ++++++++++------ 7 files changed, 258 insertions(+), 89 deletions(-) diff --git a/goodwe_controller/DECISIONS/UX-54-forecast-tab.md b/goodwe_controller/DECISIONS/UX-54-forecast-tab.md index 1e1bf03..b7a4efd 100644 --- a/goodwe_controller/DECISIONS/UX-54-forecast-tab.md +++ b/goodwe_controller/DECISIONS/UX-54-forecast-tab.md @@ -17,13 +17,24 @@ battery's expected level with its blocks. It follows the add-on, as Home does and draws none when there is no room or energy (`room()` and `plan_need()` in `homeview.mode_runs()`, the leg sizing in `trade_runs()`). For a day that has not started that level says nothing, so tomorrow's blocks are built with - `ahead=True` (Home never passes it): a charge is read from the floor, a give - from the ceiling, and a block is drawn wherever the plan and the gate have a - run. A grid block is then dropped where control will not take it: the curve's - level when the run starts is asked of `modetrade.chargers()`, the cycle's own - test. The gate (`mode_gate`, UX-53) is not bypassed. Control, checked every - 15 minutes through tomorrow (`test_ux54.py` part 7), gives and charges where - the tab has a block. + `ahead` (Home never passes it): `mode_runs()` reads each block at the level + the curve has when it starts, with its own filter and `MIN_KWH`, so there is + no give block where the battery is at the floor and no grid block where it + is at its planned level. Where the curve has no level yet, a charge is read + from the floor and a give from the ceiling. The blocks and the curve follow + each other and are taken to a fixed point. The gate (`mode_gate`, UX-53) is + not bypassed. +- **A block lasts as long as control acts.** A cheap run is sized for all the + batteries charging together, so a slower one is still below + `modetrade.charge_to()` when it ends, and control plans the next cheap slots + (`Controller._price_runs()`). `dayview._levels()` asks the same functions, + `modetrade.energy()` and `priceruns.plan()`, with the packs at the curve's + level, and draws the grid block to where control stops. Each battery's block + then ends where the curve has it at its own level (the curve puts a point + there), and a give block where it reaches the floor (a step end, within the + 0.1 % the tab shows). Control, walked every 15 minutes through tomorrow + (`test_ux54.py` part 7, no tolerance), charges and gives in exactly the + quarters the tab has a block. - **The sun** is what `solarfc` reads. An hourly split (Solcast `detailedForecast`, Open-Meteo `wh_period`) is used as given. A daily total (Forecast.Solar, and any other state-only sensor, the owner's three "today @@ -60,12 +71,17 @@ is `SolarToday.add()` after the solar read, in a `try`, in memory. - Prices are planned about a day ahead (`priceruns.HORIZON`), so a block of tomorrow later than 24 h from now, or one that needs prices not yet - published, appears as the day comes near. The tab says so. Control may also - charge longer than a block when a slower battery is still short at its end. + published, appears as the day comes near. The tab says so. +- Today's blocks are Home's, and are not extended or shortened: control may + plan more once they end, and Home draws it then. 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 to give power. No block shows while the battery has no energy to give." The curve gives in that run if the sun fills the battery first. +- With no forecast for tomorrow the curve stops at the first hour of the sun, + `solarfc.BELL_FROM_H` (06:00): `Forecast.between()` has no answer past it. The + note names that hour from the constant. - Tomorrow shows price blocks only. A monthly cycle is drawn today, where the curve then stops for that battery. - The curve uses the arbitrage floor and ceiling, as the plan does, not the diff --git a/goodwe_controller/app/daycurve.py b/goodwe_controller/app/daycurve.py index 754d2f0..e585f54 100644 --- a/goodwe_controller/app/daycurve.py +++ b/goodwe_controller/app/daycurve.py @@ -109,7 +109,9 @@ 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} - for a, z in zip(ts, ts[1:]): + i = 0 + while i < len(ts) - 1: + a, z = ts[i], ts[i + 1] if not alive: break sun = sun_kwh(a, z) @@ -128,9 +130,23 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct, for s, e, to in b.grid: if s <= a < e and level[b.name] < to - EPS: want[b.name] = float(to) + by = {b.name: b for b in batts} + # A battery that reaches its level inside the step gets a point + # there: the step is cut and read again, so the line is exact. + cut = None + for n, to in want.items(): + b = by[n] + room = (to - level[n]) / 100.0 * b.capacity_kwh + rate = b.rating_kw * hrs * EFF + if EPS < room < rate * (1.0 - 1e-9): + t_r = a + (z - a) * (room / rate) + if a < t_r < z and (cut is None or t_r < cut): + cut = t_r + if cut is not None: + ts.insert(i + 1, cut) + continue roles = {b.name: _role(b, a, bool(want), b.name in want) for b in batts if b.name in alive} - by = {b.name: b for b in batts} for n, to in want.items(): # charging from the grid b = by[n] room = (to - level[n]) / 100.0 * b.capacity_kwh @@ -163,6 +179,7 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct, for b in batts: if b.name in alive: out[b.name].append((z, level[b.name])) + i += 1 return out diff --git a/goodwe_controller/app/dayview.py b/goodwe_controller/app/dayview.py index 0501e82..4dd9741 100644 --- a/goodwe_controller/app/dayview.py +++ b/goodwe_controller/app/dayview.py @@ -19,10 +19,11 @@ empty chart. DECISIONS/UX-54-forecast-tab.md has the record. import dataclasses from datetime import datetime, timedelta -from . import arbitrage, dayplan, homeview as hv, modetrade +from . import arbitrage, dayplan, homeview as hv, modetrade, priceruns +from . import solarfc from .daycurve import Batt, curve from .packmode import SOLAR_FIRST -from .price import brussels +from .price import Uncertainty, brussels, required_spread, uncertainty_of BLOCKS = ("grid", "give", "cycle") # the kinds a battery's blocks carry @@ -83,14 +84,14 @@ def _build(c, status, now): runs = [plan["runs"], hv.runs_for(c, n0, n1, now, shadow, ahead=True) if running else []] - # The level curve of every battery that is on and known. It also drops a - # grid block of tomorrow that control will not take: the battery is then - # at or above its planned level when the run starts. + # The level curve of every battery that is on and known. It also makes + # tomorrow's blocks again at the level each has when its run starts, and + # draws a grid block as long as control keeps charging (`_levels()`). notes_b = {} levels = {} if running and missing is None: levels, runs = _levels(c, est, fc, trust, now, n1, runs, floor, ceil, - notes_b) + notes_b, shadow, n0, n1) blocks = [[[r for r in rs if r["battery"] == i and r["kind"] in BLOCKS] for i in range(len(c.inverters))] for rs in runs] # Prices are planned about a day ahead (`priceruns.HORIZON`), so tomorrow's @@ -109,13 +110,25 @@ def _build(c, status, now): "days": days} -def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b): +def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow, + n0, n1): """`({name: [(t, pct), ...]}, runs)`: `daycurve.curve()` for the batteries that are on, have a level and are not in their monthly cycle, and `runs` - less the grid blocks of tomorrow that control will not take. Those are - asked of `modetrade.chargers()`, the cycle's own test, at the level the - curve has when the block starts (a block is drawn from the floor, see - `homeview.mode_runs(ahead=True)`, so that level is the filter).""" + with tomorrow's blocks made as control will meet them. + + Tomorrow's blocks are `homeview.mode_runs()`'s, read at the level the + curve has when each block starts (its `ahead` function), so its own room + and energy filter and `MIN_KWH` decide: no give block where the battery + is at the floor when the run starts, no grid block where it is at its + planned level. The curve in turn follows the blocks, so the two are taken + to a fixed point. + + A cheap run is sized for all the batteries charging together, so a slower + one is still below `modetrade.charge_to()` when it ends, and control then + plans the next cheap slots (`Controller._price_runs()`). That is asked of + the same functions, `modetrade.energy()` and `priceruns.plan()`, with the + packs at the curve's level, and the block is drawn to where control + stops charging. Today's blocks are Home's, as they are.""" packs = {p.name: p for p in c._mode_packs()} targets = c.plan_targets() rp = getattr(c, "mode_runs", None) @@ -161,18 +174,86 @@ def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b): batts = make(rs) return curve(batts, now, end, sun, use, floor_pct=floor, ceiling_pct=ceil, trust=trust) if batts else {} - first = run([runs[0], [r for r in runs[1] if r["kind"] != "grid"]]) - keep = [] - for r in runs[1]: - name = c.inverters[r["battery"]].name - lvl = _level_at(first.get(name, ()), _times(r)[0]) - if r["kind"] == "grid" and lvl is not None and not modetrade.chargers( - [dataclasses.replace(packs[name], soc=lvl)], ceil, - targets=targets): - continue - keep.append(r) - runs = [runs[0], keep] - return run(runs), runs + + def settle(ext, lv): + """Tomorrow's blocks read at the levels of `lv`, and the curve they + make, again until the blocks stop changing.""" + seen = None + for _ in range(4): + def at(name, _kind, start, lv=lv): + return _level_at(lv.get(name, ()), start) + rt = hv.runs_for(c, n0, n1, now, shadow, ahead=at, rp=ext) + lv = run([runs[0], rt]) + sig = [(r["battery"], r["kind"], r["start"], r["end"]) + for r in rt] + if sig == seen: + break + seen = sig + return rt, lv + + lv0 = run([runs[0], []]) + ext = rp + plan = c.fresh_tariff_plan(now) + cheap = None if rp is None else rp.cheap + end_t = None + if plan_on and cheap is not None and rp.charge_useful() \ + and n0 <= cheap.start < n1 and plan is not None: + required = required_spread( + arbitrage.threshold_eur_kwh(c.o), + uncertainty_of(plan.read.product) | {Uncertainty.NO_WEATHER}) + end_t = cheap.end + for _ in range(24): + ext = dataclasses.replace(rp, cheap=priceruns.Run( + cheap.start, end_t, cheap.mean_eur_kwh)) + rt, lv = settle(ext, lv0) + at_end = [dataclasses.replace(packs[n], soc=v) + for n, v in ((n, _level_at(lv.get(n, ()), end_t)) + for n in lv) if v is not None] + e = modetrade.energy(at_end, floor, ceil, targets=targets) + if e[0] <= 1e-6: + break + new = priceruns.plan( + plan.read.points, end_t, required_eur_kwh=required, + charge_kwh=e[0], charge_kw=e[1], sell_kwh=0.0, sell_kw=0.0) + nxt = new.cheap + if nxt is None or not new.charge_useful() or nxt.start > end_t \ + or nxt.end <= end_t: + break + end_t = nxt.end + ext = dataclasses.replace(rp, cheap=priceruns.Run( + cheap.start, end_t, cheap.mean_eur_kwh)) + rt, lv = settle(ext, lv0) + # A run is for all the batteries charging together; each stops when it is + # at its own level (control's `chargers()`), so its block ends there. + names = [inv.name for inv in c.inverters] + rt = [_stops(r, names, lv, packs, targets, floor, ceil, n0) for r in rt] + return lv, [runs[0], rt] + + +def _stops(r, names, lv, packs, targets, floor, ceil, day0): + """`r` ended where its battery stops: a grid block where the curve has it + at the level `modetrade.charge_to()` allows (the curve has a point there), + a give block where the curve has it at the floor, within the 0.1 % the + tab shows (a step end), if that is + before the run ends. A run is for all the batteries together; each leaves + it on its own.""" + if r["kind"] not in ("grid", "give"): + return r + name = names[r["battery"]] + start, end = _times(r) + if r["kind"] == "grid": + to = modetrade.charge_to(packs[name], ceil, targets) + if to is None: + return r + hit = lambda v: v >= to - 1e-6 # noqa: E731 + else: + hit = lambda v: v <= floor + 0.05 # noqa: E731 + done = next((t for t, v in lv.get(name, ()) if start < t <= end + and hit(v)), None) + if done is None or done >= end: + return r + return dict(r, end=done.isoformat(), to=hv.hhmm(done), + end_h=round(_hours_between(day0, done), 3)) def _level_at(pts, t): @@ -246,7 +327,7 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow, if day is not None and not day.periods: notes.append(hv.day_note("estimate")) if k == 1 and fc.tomorrow is None: - notes.append(hv.day_note("no_tomorrow")) + notes.append(hv.day_note("no_tomorrow", at=_sun_from())) if k == 0 and solar is not None and solar.first is not None \ and solar.day == brussels(now).date() \ and _hours_between(d0, solar.first) > 0.5: @@ -270,6 +351,13 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow, "hours": [h for _s, h in hours], "batteries": batts} +def _sun_from() -> str: + """When the sun of a day starts for `solarfc` (its bell's first hour): + with no forecast for tomorrow the sun is unknown from then, and so the + curve stops there (`Forecast.between()` answers None past it).""" + return f"{int(solarfc.BELL_FROM_H):02d}:00" + + def _tomorrow_line(status) -> str: """Home's own line about tomorrow's prices, for the tomorrow tab.""" return (status.get("plan") or {}).get("tomorrow_line") or "" diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index a7eea17..db710f8 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -395,7 +395,7 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False): PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS) -def mode_runs(c, day0, day1, now, shadow, ahead=False): +def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None): """OPT-11 P3: each battery's price runs today, as the control cycle keeps them (`c.mode_runs`): the cheap run on the batteries that may charge from the grid, the give runs on Leftover solar and Cheap hours, and the @@ -405,10 +405,12 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False): control cycle found). Returns `(runs, no_trade_key)`. UX-54 `ahead`: the window has not started, so the level now says nothing about it. The room and energy filter (`room()` and `plan_need()` below) - then reads a charge from the floor and a give from the ceiling, and a - block is drawn wherever the plan and the gate have a run. Home never - passes it.""" - rp = getattr(c, "mode_runs", None) + then reads the level a block starts at: `ahead(name, kind, start)`, a + function giving the expected level in % or None, or for None (and for + `ahead=True`) a charge from the floor and a give from the ceiling. The + same filter, the same `MIN_KWH`. `rp`: a run plan to read instead of + `c.mode_runs`. Home never passes either.""" + rp = rp if rp is not None else getattr(c, "mode_runs", None) if getattr(c, "mode_tick", None) is None: return [], None # no cycle yet: nothing is known if rp is None: @@ -424,16 +426,23 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False): continue soc, cap = inv.soc, inv.capacity_kwh - def room(kind, hrs): + def start_pct(kind, start): + """The level a block of `kind` is read at when `ahead`.""" + got = ahead(inv.name, kind, start) if callable(ahead) else None + return got if got is not None else ( + floor if kind == "grid" else ceiling) + + def room(kind, hrs, start): kwh = inv.max_w / 1000.0 * hrs - lvl = ((floor if kind == "grid" else ceiling) if ahead else soc) + lvl = start_pct(kind, start) if ahead else soc if cap and lvl is not None: kwh = min(kwh, max(0.0, (ceiling - lvl) if kind == "grid" else (lvl - floor)) / 100.0 * cap) return kwh blocks = [] # P4: with a day plan, the grid block is the planned shortfall. - need = plan_need(c, inv.name, floor if ahead else None) + need = plan_need(c, inv.name, start_pct("grid", rp.cheap.start) + if ahead and rp.cheap is not None else None) if gate is None and rp.charge_useful() and ( need > 0.0 if need is not None else pm.mode in GRID_CHARGE_MODES): @@ -448,7 +457,7 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False): if run.end <= now or not (run.start < day1 and run.end > day0): continue hrs = (run.end - run.start).total_seconds() / 3600.0 - kwh = room(kind, hrs) + kwh = room(kind, hrs, run.start) if kind == "grid" and need is not None: kwh = min(kwh, need) if kwh < MIN_KWH: @@ -629,13 +638,13 @@ def region_of(opts): if rid in REGION_PRESETS else None} -def runs_for(c, day0, day1, now, shadow, ahead=False): +def runs_for(c, day0, day1, now, shadow, ahead=False, rp=None): """UX-54: the price blocks of the window `[day0, day1)`, by the same builders `build_plan()` uses for today: the batteries' price runs with modes on, the verdict's legs with them off. Nothing is decided here. `ahead`: see `mode_runs()`; for a day that has not started.""" if getattr(c, "modes_on", False): - return mode_runs(c, day0, day1, now, shadow, ahead)[0] + return mode_runs(c, day0, day1, now, shadow, ahead, rp)[0] fresh = c.fresh_tariff_plan(now) verdict, _why = trade_verdict(c, fresh, now) if verdict is None: diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index a9c47b0..6335d91 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -448,7 +448,7 @@ "not_set": "No solar forecast is set up. Without one, no battery level is planned.", "no_forecast": "The solar forecast is missing or too old. The levels return when it is back.", "learning": "The add-on is learning your house's use. The levels start after one full day.", - "no_tomorrow": "There is no solar forecast for tomorrow yet. The levels stop at 06:00.", + "no_tomorrow": "There is no solar forecast for tomorrow yet. The levels stop at {at}.", "estimate": "The forecast gives one total for the day. The add-on spreads it over the hours.", "trust": "The levels count on {pct} % of the forecast.", "cycle": "{name} does its monthly cycle. Its level is not shown once the cycle starts.", diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index c1a5c54..7e5f968 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -376,7 +376,7 @@ "not_set": "Er is geen zonneprognose ingesteld. Zonder prognose wordt geen laadniveau gepland.", "no_forecast": "De zonneprognose ontbreekt of is te oud. De niveaus komen terug als ze er weer is.", "learning": "De add-on leert het verbruik van uw huis. De niveaus starten na één volle dag.", - "no_tomorrow": "Er is nog geen zonneprognose voor morgen. De niveaus stoppen om 06:00.", + "no_tomorrow": "Er is nog geen zonneprognose voor morgen. De niveaus stoppen om {at}.", "estimate": "De prognose geeft één totaal voor de dag. De add-on verdeelt het over de uren.", "trust": "De niveaus rekenen op {pct} % van de prognose.", "cycle": "{name} doet de maandelijkse cyclus. Het niveau wordt niet getoond zodra de cyclus start.", diff --git a/goodwe_controller/test_ux54.py b/goodwe_controller/test_ux54.py index 4df08cf..f1ad774 100644 --- a/goodwe_controller/test_ux54.py +++ b/goodwe_controller/test_ux54.py @@ -340,6 +340,12 @@ check("a grid block charges to its level at the rating (5 kW x 95 %: 47.5 % " "an hour on 10 kWh, so 80 % in the first hour), and stops there", lv(oc, "c", 2) == 40.0 and abs(lv(oc, "c", 3) - 80.0) < 1e-6 and lv(oc, "c", 5) == 80.0) +reach = T0 + timedelta(hours=2, minutes=50, seconds=31.579) +check("a battery that reaches its grid level inside a step gets a point " + "there (80 % at 02:50:31, not at the next hour), so the line and the " + "block end are exact", + any(abs((t - reach).total_seconds()) < 1.0 + and abs(v - 80.0) < 1e-6 for t, v in oc["c"])) two = [Batt("a", 10.0, 5.0, 50.0, True, False), Batt("b", 10.0, 5.0, 50.0, False, True)] ot = run(two, flat(3.0), flat(1.0)) @@ -474,14 +480,16 @@ check("learning (no house profile yet): a note, the sun is drawn, no house " say("learning") in notes_of(o) and no_charts(o) and o["days"][0]["sun"]["forecast"] and not o["days"][0]["sun"]["house"] and all(h["house_kwh"] is None for h in o["days"][0]["hours"])) +SUN_FROM = f"{int(solarfc.BELL_FROM_H):02d}:00" o = tab(S.make("notomorrow", at(10, 7)), at(10, 7)) tmr = o["days"][1] -check("no forecast for tomorrow: a note on that day only; the curve stops at " - "sunrise; today is whole", - say("no_tomorrow") in tmr["notes"] - and say("no_tomorrow") not in notes_of(o, 0) +check("no forecast for tomorrow: a note on that day only, naming the hour " + "the curve really stops (`solarfc.BELL_FROM_H`, the first hour of the " + "sun: its last point is there); today is whole", + say("no_tomorrow", at=SUN_FROM) in tmr["notes"] + and say("no_tomorrow", at=SUN_FROM) not in notes_of(o, 0) and not tmr["sun"]["forecast"] - and tmr["batteries"][0]["levels"][-1][0] <= 6.0 + 1e-6 + and abs(tmr["batteries"][0]["levels"][-1][0] - solarfc.BELL_FROM_H) < 1e-6 and o["days"][0]["batteries"][0]["levels"][-1][0] > 23.0) c = S.make("owner", at(10, 7)) c.inverters[1].soc = None @@ -721,10 +729,14 @@ Q = timedelta(minutes=15) def level_at(b, h): - """The curve's level at hour `h` of the day the list is for (the last - point at or before it; the first one before the curve starts).""" - pts = [p for p in b["levels"] if p[0] <= h + 1e-9] - return pts[-1][1] if pts else b["levels"][0][1] + """The curve's level at hour `h` of the day the list is for, straight + between its points (the first level before the curve starts, the last + after it ends).""" + pts = b["levels"] + for (h0, v0), (h1, v1) in zip(pts, pts[1:]): + if h0 <= h <= h1: + return v0 if h1 <= h0 else v0 + (v1 - v0) * (h - h0) / (h1 - h0) + return pts[0][1] if h < pts[0][0] else pts[-1][1] def night_evening(h): @@ -733,7 +745,7 @@ def night_evening(h): return 0.08 if 1.0 <= h < 5.0 else 0.34 if 17.0 <= h < 21.0 else 0.20 -def sweep(name, now0, soc, *, guard=None, until=48.0): +def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None): """Walk the real control cycle (`_mode_roles()`) every 15 minutes from `now0` to the end of tomorrow, each battery at the level the tab's curve has at that time, and return the tab and the role seen per battery and @@ -743,6 +755,8 @@ def sweep(name, now0, soc, *, guard=None, until=48.0): c.tariff_at = now0 if guard is not None: c.peak_guard = guard + if sun is not None: + c.solar_fc = S.forecast(now0, sun[0], sun[1]) c._mode_roles(now0, None) out = tab(c, now0) d0, _d1 = homeview.local_day(now0) @@ -758,38 +772,36 @@ def sweep(name, now0, soc, *, guard=None, until=48.0): else inv.soc c.tariff_at = t tick = c._mode_roles(t, None) + floor_, _ceil = arbitrage.soc_bounds(c.o) for inv in c.inverters: - seen[inv.name][t] = tick.roles.get(inv.name) + role = tick.roles.get(inv.name) + # a give from a battery at the floor has no energy behind it: + # Home's own rule (`mode_runs()`, `MIN_KWH`) draws no block + seen[inv.name][t] = "no energy" if role == modetrade.GIVE \ + and inv.soc <= floor_ + 0.05 else role t += Q return c, out, seen, d0 -def tomorrow_match(out, seen, d0, role, kind, tail=0.0): - """(a block of `kind` has a quarter where control had `role`, and no - quarter of that role is outside a block) per battery, tomorrow. `tail`: - hours control may go on after a block ends. A charge does: the run is - sized for all the batteries charging together, so a slower one is still - short when it ends and the next plan carries on in the same cheap hours.""" - ok = True +def tomorrow_match(out, seen, d0, role, kind): + """Per battery, tomorrow: control has `role` in exactly the quarters the + tab has a block of `kind` (no quarter outside a block, none of a block + without it), with no tolerance.""" for i, name in enumerate(seen): blocks = [r for r in out["days"][1]["batteries"][i]["blocks"] if r["kind"] == kind] - inside = lambda h: any( - r["start_h"] - 1e-6 <= h < r["end_h"] + tail - 1e-6 - for r in blocks) quarters = [(t, (t - d0).total_seconds() / 3600.0 - 24.0) for t in seen[name] if (t - d0).total_seconds() >= 86400] - acting = [h for t, h in quarters if seen[name][t] == role] - ok &= all(inside(h) for h in acting) - ok &= all(any(r["start_h"] - 1e-6 <= h < r["end_h"] - 1e-6 - for h in acting) for r in blocks) - ok &= bool(blocks) == bool(acting) - if not ok: - print(" ", name, role, "acting at", acting[:3], "..", - acting[-2:], "blocks", [(r["start_h"], r["end_h"]) - for r in blocks]) - return False - return ok + for t, h in quarters: + acting = seen[name][t] == role + inside = any(r["start_h"] - 1e-6 <= h < r["end_h"] - 1e-6 + for r in blocks) + if acting != inside: + print(" ", name, role, "quarter", round(h, 2), + "control acts" if acting else "tab has a block", + [(r["start_h"], r["end_h"]) for r in blocks]) + return False + return True t22 = at(22, 0, 5) @@ -798,30 +810,57 @@ tm1 = o1["days"][1]["batteries"] print(" 22:00, both batteries at 20 %: tomorrow", [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]] for b in tm1]) check("F1, modes on: at 22:00 with both batteries at 20 % tomorrow's give " "block (17:00-21:00) and the night's grid block show on both", - all([(r["kind"], r["from"], r["to"]) for r in b["blocks"]] - == [("grid", "01:00", "03:30"), ("give", "17:00", "21:00")] - for b in tm1)) + all([(r["kind"], r["from"]) for r in b["blocks"]] + == [("grid", "01:00"), ("give", "17:00")] + and b["blocks"][1]["to"] == "21:00" for b in tm1)) check("F2: ...and control, walked every 15 minutes through tomorrow at the " "level the curve has, gives (role GIVE) exactly in the give block and " "charges (role CHARGE) only in the grid block, on both batteries", tomorrow_match(o1, s1, d01, modetrade.GIVE, "give") - and tomorrow_match(o1, s1, d01, modetrade.CHARGE, "grid", tail=2.6)) + and tomorrow_match(o1, s1, d01, modetrade.CHARGE, "grid")) check("...and today's blocks are still Home's runs there", tab_blocks(o1) == home_blocks(home(c1, t22), 2)) guard = PeakDecision(engaged=True, floor_w=1000.0, ceiling_w=3500.0, ceiling_from="cap", projected_w=3600.0) c2, o2, s2, d02 = sweep("dynamic", t22, (20.0, 20.0), guard=guard) tm2 = o2["days"][1]["batteries"] -check("F2, the UX-53 gate: while the peak guard holds, control never charges " - "tomorrow, and the tab draws no grid block there (the give block stays)", +check("F2, the UX-53 gate: while the peak guard holds control never " + "charges tomorrow and the tab draws no grid block (both batteries stay " + "at the floor, so no give either, which control cannot give from)", c2.mode_gate == "peak" - and all([r["kind"] for r in b["blocks"]] == ["give"] for b in tm2) - and tomorrow_match(o2, s2, d02, modetrade.CHARGE, "grid", tail=2.6) + and all(not b["blocks"] for b in tm2) + and tomorrow_match(o2, s2, d02, modetrade.CHARGE, "grid") and tomorrow_match(o2, s2, d02, modetrade.GIVE, "give")) +c2b, o2b, s2b, d02b = sweep("dynamic", t22, (60.0, 60.0), guard=guard) +check("...with the batteries at 60 % the give block stays and the grid " + "block does not", + all([r["kind"] for r in b["blocks"]] == ["give"] + for b in o2b["days"][1]["batteries"]) + and tomorrow_match(o2b, s2b, d02b, modetrade.CHARGE, "grid") + and tomorrow_match(o2b, s2b, d02b, modetrade.GIVE, "give")) +# Low sun, a battery at the floor when the evening give run starts (no grid +# charge, the peak guard holds): Home's rule draws no give block for it, and +# control has no energy to give from. A little sun fills it a little: a small +# block, and control gives from it. +c4, o4, s4, d04 = sweep("dynamic", t22, (20.0, 20.0), guard=guard, + sun=(0.0, 0.0)) +check("F1, cloudy day, no grid charge, both batteries at the floor at the " + "evening give run: no block on either, and control has no energy to " + "give from (the sweep sees none)", + all(not b["blocks"] for b in o4["days"][1]["batteries"]) + and tomorrow_match(o4, s4, d04, modetrade.GIVE, "give")) +c5, o5, s5, d05 = sweep("dynamic", t22, (20.0, 20.0), guard=guard, + sun=(14.0, 14.0)) +gv5 = [[r for r in b["blocks"] if r["kind"] == "give"] + for b in o5["days"][1]["batteries"]] +check("...a bright day fills them: a give block on both, and control gives " + "in exactly that block", + all(len(g) == 1 and g[0]["from"] == "17:00" for g in gv5) + and tomorrow_match(o5, s5, d05, modetrade.GIVE, "give")) o3 = [sweep("dynamic", t22, soc) for soc in ((55.0, 40.0), (90.0, 70.0))] check("F2: the same with the batteries part full and nearly full at 22:00", all(tomorrow_match(x[1], x[2], x[3], modetrade.GIVE, "give") - and tomorrow_match(x[1], x[2], x[3], modetrade.CHARGE, "grid", tail=2.6) + and tomorrow_match(x[1], x[2], x[3], modetrade.CHARGE, "grid") for x in o3))