From eb8375157db39c45c65b5db4d0b9a451351fe25e Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 19 Oct 2026 10:01:58 +0200 Subject: [PATCH] UX-45: count priced hours in the wizard, fill chart holes by the add-on's cover rule, mark only for price-mode batteries Co-Authored-By: Claude --- goodwe_controller/app/homeview.py | 45 ++++---- goodwe_controller/app/web.py | 4 +- goodwe_controller/test_home.py | 181 +++++++++++++++++++++++++----- 3 files changed, 184 insertions(+), 46 deletions(-) diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 2a72602..9501c7a 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -22,7 +22,7 @@ from . import arbitrage, messages, peakguard, uitext from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD from .maintenance import IDLE as M_IDLE, next_slot_start from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST -from .price import Absence, Uncertainty, brussels +from .price import Absence, Uncertainty, brussels, _cover_table from .static_tariff import static_plan from .tariff import BILLING_TZ from .uimodel import REGION_PRESETS, options_to_ui @@ -157,23 +157,12 @@ def _bar_step(pts): return min(gaps + [timedelta(hours=1)]) -def _bar_cover(pts, step): +def _bar_cover(pts): """`[(start, end, point)]`: what each point is the price of on the chart. - Its own gap to the next point when that is one step or one hour, else one - step, so a missing quarter stays empty and does not stretch its - neighbour. A carried (provisional) last point is hourly.""" - hour = timedelta(hours=1) - out = [] - for i, p in enumerate(pts): - gap = pts[i + 1].start - p.start if i + 1 < len(pts) else None - if gap in (step, hour): - end = p.start + gap - elif gap is None and p.provisional: - end = p.start + hour - else: - end = p.start + step - out.append((p.start, end, p)) - return out + The add-on's own rule, not a copy: `price._cover_table()` (TARIFF-14), so + a point covers until the next one starts and never over an hour, and the + last one for the span of the one before it.""" + return _cover_table(pts) def price_bars(points, day0, day1, windows=(), now=None, kept=None): @@ -187,7 +176,7 @@ def price_bars(points, day0, day1, windows=(), now=None, kept=None): that lives a day). Display only.""" pts = sorted(points, key=lambda p: p.start) step = _bar_step(pts) - cover = _bar_cover(pts, step) + cover = _bar_cover(pts) n = int((day1 - day0) / step) bars = [] for k in range(n): @@ -212,6 +201,17 @@ def price_bars(points, day0, day1, windows=(), now=None, kept=None): return bars +def _price_mode_in_use(c): + """True when at least one battery is on and uses a price mode. The runs + belong to those batteries; with all on Solar first or off, nothing acts + on a price.""" + for inv in c.inverters: + pm = c.pack_mode(inv) + if pm.use and pm.mode in PRICE_MODES: + return True + return False + + def price_windows(c, plan, verdict): """`[(kind, start, end)]`: where the add-on itself buys ("cheap") and gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan` @@ -220,7 +220,7 @@ def price_windows(c, plan, verdict): `trade_runs()` draws. Nothing is classified here.""" if getattr(c, "modes_on", False): rp = getattr(c, "mode_runs", None) - if rp is None or not rp.on: + if rp is None or not rp.on or not _price_mode_in_use(c): return [] out = [("cheap", rp.cheap.start, rp.cheap.end)] \ if rp.charge_useful() else [] @@ -1478,3 +1478,10 @@ def _samples() -> list: ("not_covered", "no_prices", "peak", "not_firm", "not_worth", "no_level")] return out + + +def priced_hours(bars): + """How many clock hours of the day have a price: distinct hours among the + bars that have one, so 96 quarters count 24, not 96. The setup wizard's + "N of 24 hours". A 23-hour day gives 23, a 25-hour day 24.""" + return len({b["label"][:2] for b in bars if b.get("eur_kwh") is not None}) diff --git a/goodwe_controller/app/web.py b/goodwe_controller/app/web.py index e301e34..77093dd 100644 --- a/goodwe_controller/app/web.py +++ b/goodwe_controller/app/web.py @@ -50,7 +50,7 @@ from aiohttp import web from . import messages, price, selfconfig, uipage, uitext from .haentities import EntityCache -from .homeview import region_of +from .homeview import priced_hours, region_of from .validate import config_notes, setup_checks from .config_page import CONFIG_PAGE from .configui import (changed_keys, field_model, hide_secrets, keep_secrets, @@ -328,7 +328,7 @@ def build_app(controller, selfcfg=None) -> web.Application: try: prices = (controller.status_payload().get("plan") or {}).get( "prices") or [] - known = sum(1 for p in prices if p.get("eur_kwh") is not None) + known = priced_hours(prices) except Exception: # noqa: BLE001 - a display figure, never a failure known = None return setup_checks(options, entities=rows, running=controller.o, diff --git a/goodwe_controller/test_home.py b/goodwe_controller/test_home.py index 3fd88b1..8a00330 100644 --- a/goodwe_controller/test_home.py +++ b/goodwe_controller/test_home.py @@ -33,7 +33,9 @@ from app import homeview, web from app.main import Controller from app.peakguard import PeakDecision from app.price import PricePoint, PriceProduct, PriceRead -from app.static_tariff import TariffPlan +from app.static_tariff import TariffPlan, static_plan +from app import packmode +from app.store import unit_key from app.uimodel import REGION_PRESETS from named_absence import field, nth, present @@ -152,6 +154,14 @@ def quiet_guard(projected_w=1240.0): ceiling_from="cap", projected_w=projected_w) +def set_modes(c, *modes): + """File each battery's mode in the store (None leaves the default, which + follows the house).""" + for inv, m in zip(c.inverters, modes): + if m is not None: + c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = m + + def status(c, now=NOW): s = c.status_payload() # The status route builds at wall-clock time; rebuild the structured half @@ -555,6 +565,7 @@ c_m = make(ARB) c_m.tariff_plan, c_m.tariff_at = c_q.tariff_plan, NOW c_m.peak_guard = quiet_guard() c_m.modes_on, c_m.mode_runs = True, _rp +set_modes(c_m, packmode.SOLAR_FIRST, packmode.CHEAP_HOURS) _pm = status(c_m)["plan"]["prices"] _today = [(a, z) for a, z, _p in priceruns.firm_slots(_q, NOW) if a < DAY0 + timedelta(hours=24)] @@ -572,6 +583,7 @@ c_m2 = make(ARB) c_m2.tariff_plan, c_m2.tariff_at = c_q.tariff_plan, NOW c_m2.peak_guard = quiet_guard() c_m2.modes_on = True +set_modes(c_m2, packmode.CHEAP_HOURS, None) c_m2.mode_runs = priceruns.plan(tuple(_q), NOW, required_eur_kwh=5.0, charge_kwh=2.0, charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0) @@ -594,22 +606,38 @@ check("...and a quarter already over keeps the mark it had while ahead", and [b["kind"] for b in _pl if b["label"] == "19:00"] == ["dear"]) c_q.tariff_at = NOW -# A missing quarter is an empty bar in its place, nothing shifts. -_hole = [p for p in _q if p.start != DAY0 + timedelta(hours=10, minutes=30)] -c_h = make(ARB) -c_h.tariff_plan = TariffPlan(PriceRead.series(tuple(_hole), PriceProduct.DAY_AHEAD, - "generic_dayahead", NOW), None, - "provider") -c_h.tariff_at = NOW -c_h.peak_guard = quiet_guard() -_ph = status(c_h)["plan"]["prices"] -check("UX-45: a missing quarter is a 'none' bar, and the others stay where " - "they were", - len(_ph) == 96 and _ph[42]["eur_kwh"] is None - and _ph[42]["kind"] is None and _ph[42]["label"] == "10:30" - and _ph[41]["eur_kwh"] == _pq[41]["eur_kwh"] - and _ph[43]["eur_kwh"] == _pq[43]["eur_kwh"] and _ph[43]["label"] == "10:45" - and sum(1 for b in _ph if b["eur_kwh"] is None) == 1) +# UX-45 round 2 (F2): a hole is covered by the add-on's own rule, +# `price._cover_table()`: the point before it runs on, never over an hour. +def _hole_bars(missing): + pts = [PricePoint(DAY0 + timedelta(minutes=15 * i), .10 + .001 * i, False) + for i in range(96)] + gone = {DAY0 + timedelta(hours=10, minutes=15 * (1 + j)) + for j in range(missing)} + pts = [p for p in pts if p.start not in gone] + return homeview.price_bars(pts, DAY0, DAY0 + timedelta(hours=24)) + + +_gap = {} +for _n in (1, 2, 3, 4, 5): + _bb = _hole_bars(_n) + _gap[_n] = ([b["eur_kwh"] is None for b in _bb[41:41 + _n]], + len(_bb)) +_b1 = _hole_bars(1) +check("UX-45: 1, 2 or 3 missing quarters show the price before them (the " + "add-on's own cover rule), and nothing shifts", + all(len(_hole_bars(n)) == 96 for n in (1, 2, 3)) + and _gap[1][0] == [False] and _gap[2][0] == [False, False] + and _gap[3][0] == [False, False, False] + and [b["eur_kwh"] for b in _hole_bars(3)[40:44]] + == [_hole_bars(3)[40]["eur_kwh"]] * 4 + and _b1[42]["label"] == "10:30" and _b1[43]["label"] == "10:45" + and _b1[43]["eur_kwh"] == round(.10 + .001 * 43, 4)) +check("...4 missing quarters leave 1 bar empty, 5 leave 2: a hole over an " + "hour is not filled past the hour", + _gap[4][0] == [False, False, False, True] + and _gap[5][0] == [False, False, False, True, True] + and sum(b["eur_kwh"] is None for b in _hole_bars(4)) == 1 + and sum(b["eur_kwh"] is None for b in _hole_bars(5)) == 2) # A cheap run that is not worth charging in is not marked; an estimate is. import types @@ -617,8 +645,10 @@ _pts = [PricePoint(DAY0 + timedelta(minutes=15 * i), .30 if i < 4 else .10, False) for i in range(8)] _rp2 = priceruns.plan(tuple(_pts), DAY0, required_eur_kwh=0.05, charge_kwh=0.5, charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0) -_w2 = homeview.price_windows(types.SimpleNamespace(modes_on=True, - mode_runs=_rp2), None, None) +c_w2 = make(ARB) +c_w2.modes_on, c_w2.mode_runs = True, _rp2 +set_modes(c_w2, packmode.LEFTOVER_SOLAR, None) +_w2 = homeview.price_windows(c_w2, None, None) check("UX-45: a cheap run that comes after every dear slot is not worth a " "charge, so it is not marked (the dear run is)", _rp2.on and _rp2.cheap is not None and not _rp2.charge_useful() @@ -627,12 +657,17 @@ _est = [PricePoint(DAY0 + timedelta(minutes=15 * i), .2, False) for i in range(80)] + [PricePoint(DAY0 + timedelta(hours=20), .25, True)] _be = homeview.price_bars(_est, DAY0, DAY0 + timedelta(hours=24)) -check("UX-45: a carried (estimate) hourly price after quarter prices covers " - "its own hour as four estimate bars, and the rest stays empty", - [b["estimate"] for b in _be[78:85]] - == [False, False, True, True, True, True, False] - and [b["eur_kwh"] for b in _be[80:84]] == [.25] * 4 - and _be[84]["eur_kwh"] is None) +check("UX-45: a carried (estimate) price after quarter prices is the last " + "point, so it covers one quarter (the add-on's tail rule), as an " + "estimate bar; the rest stays empty", + [b["estimate"] for b in _be[78:83]] == [False, False, True, False, False] + and _be[80]["eur_kwh"] == .25 and _be[81]["eur_kwh"] is None) +_est2 = _est[:80] + [PricePoint(DAY0 + timedelta(hours=20 + k), .25, True) + for k in range(3)] +_be2 = homeview.price_bars(_est2, DAY0, DAY0 + timedelta(hours=24)) +check("...carried hourly points one after another cover an hour each", + [b["eur_kwh"] for b in _be2[80:92]] == [.25] * 12 + and _be2[92]["eur_kwh"] is None) # Hourly prices -> 24 bars (above); a day/night tariff -> 24 bars. _ph1 = s_arb["plan"]["prices"] @@ -661,6 +696,102 @@ check("UX-45: a 23-hour day has 92 quarter bars, a 25-hour day 100", and len(homeview.price_bars( _sp, _d0, _d0 + timedelta(hours=25))) == 100) +_f0 = datetime(2026, 10, 24, 22, 0, tzinfo=timezone.utc) # 25-hour day +_fb = [PricePoint(_f0 + timedelta(minutes=15 * i), .1, False) + for i in range(100)] + +# F5: the day/night chart with the stored static plan, as in production +# (the cycle stores `static_plan()` when the provider is off). +_PROF = ('{"periods": [{"name":"day","from":"07:00","to":"22:00",' + '"days":"all","eur_per_kwh":0.34},{"name":"night","from":"22:00",' + '"to":"07:00","days":"all","eur_per_kwh":0.26}]}') + + +def _dn(arb): + c = make(dict(ARB if arb else TWO, static_profile=_PROF)) + c.peak_guard = quiet_guard() + c.tariff_plan = static_plan(c.o, NOW, NOW + timedelta(hours=36), now=NOW) + c.tariff_at = NOW + return c, status(c)["plan"]["prices"] + + +_c_off, _p_off = _dn(False) +_c_on, _p_on = _dn(True) +_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW)) +check("UX-45: day/night tariff with the stored plan and price trading off: " + "24 bars, no cheap or dear mark", + len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"}) +check("...and with price trading on, the marks are the verdict's cheapest " + "and dearest hour (one each)", + _v_on is not None and len(_p_on) == 24 + and [datetime.fromisoformat(b["start"]) for b in _p_on + if b["kind"] == "cheap"] == [_v_on.cheapest.start] + and [datetime.fromisoformat(b["start"]) for b in _p_on + if b["kind"] == "dear"] == [_v_on.dearest.start] + and sum(b["kind"] == "mid" for b in _p_on) == 22) + +# F3 / owner decision 2a: with battery modes on, marks only when a battery +# uses a price mode. +def _modes(*modes, use=(True, True)): + c = make(ARB) + c.tariff_plan, c.tariff_at = c_q.tariff_plan, NOW + c.peak_guard = quiet_guard() + c.modes_on, c.mode_runs = True, _rp + set_modes(c, *modes) + for inv, u in zip(c.inverters, use): + c.store.data[unit_key(inv.name, packmode.USE_KEY)] = u + return {b["kind"] for b in status(c)["plan"]["prices"]} + + +_F, _L, _C = (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR, + packmode.CHEAP_HOURS) +check("UX-45: battery modes on, every battery follows the house: no marks", + _modes(_F, _F) == {"mid"} and _modes(None, None) == {"mid"}) +check("...one battery on Cheap hours or Leftover solar: the marks show", + {"cheap", "dear"} <= _modes(_F, _C) and {"cheap", "dear"} <= _modes(_L, _F)) +check("...a price mode on a battery that is switched off does not count", + _modes(_F, _C, use=(True, False)) == {"mid"} + and {"cheap", "dear"} <= _modes(_F, _C, use=(False, True))) + +# F4: the day's memory of past marks starts over on a new day. +_s4 = c_q.status_payload() +homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=NOW) +_k0 = dict(c_q.home_prices.kinds) +_tom = NOW + timedelta(days=1) +c_q.tariff_at = _tom +homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=_tom) +c_q.tariff_at = NOW +check("UX-45: past marks start over on a new day (nothing of yesterday is " + "kept)", + len(_k0) > 0 and c_q.home_prices.day != DAY0 + and c_q.home_prices.kinds + and all(k >= DAY0 + timedelta(days=1) for k in c_q.home_prices.kinds) + and len(c_q.home_prices.kinds) <= 96) + +# F1: the setup wizard counts hours, not bars. +from app.validate import setup_checks +_o = dict(ARB, price_provider="entsoe_be") +_say = lambda n: [x for x in map(repr, setup_checks( + _o, entities={}, running=_o, prices_known=n)) if "Prices for today" in x] +check("UX-45: 96 quarter prices count as 24 hours in the setup wizard, " + "not 96", + homeview.priced_hours(_pq) == 24 + and homeview.priced_hours(s_arb["plan"]["prices"]) == 24 + and "for 24 of 24 hours" in "".join(_say(homeview.priced_hours(_pq))) + and "96 of 24" not in "".join(_say(homeview.priced_hours(_pq)))) +check("...a half-priced day counts its hours; a hole of one quarter loses " + "no hour", + homeview.priced_hours(_pq[:48]) == 12 + and homeview.priced_hours(_hole_bars(5)) == 24 + and homeview.priced_hours([dict(b, eur_kwh=None) for b in _pq]) == 0) +check("...a 23-hour day gives 23 and a 25-hour day 24 (clock hours)", + homeview.priced_hours(homeview.price_bars(_sp, _d0, _d1)) == 23 + and homeview.priced_hours(homeview.price_bars( + _fb, _f0, _f0 + timedelta(hours=25))) == 24) +check("...and the wizard takes that count", + "known = priced_hours(prices)" in io.open(os.path.join( + HERE, "app", "web.py"), encoding="utf-8").read()) + # --------------------------------------------------------------------------- # print("\n4. a fault in the Home view never costs the page its rows") # --------------------------------------------------------------------------- #