From 17c60484172c9f8af79468b02a29fcccbc0f8684 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 19 Oct 2026 00:35:29 +0200 Subject: [PATCH 1/2] UX-45: Home price chart draws one bar per price step the add-on uses With 15-minute prices the chart now has one bar per quarter (92/96/100), not hour averages; hourly and static tariffs still draw one bar per hour. Cheap and dear marks come from the kept priceruns result (battery modes on) or the arbitrage verdict's two points (off), not a thirds split. A missing quarter is an empty bar. Removes plan.average_tip (EN/NL). Co-Authored-By: Claude --- goodwe_controller/app/homeview.py | 121 ++++++++++++++------- goodwe_controller/app/static/app.css | 2 + goodwe_controller/app/static/app.js | 3 +- goodwe_controller/app/ui_text/en.json | 3 +- goodwe_controller/app/ui_text/nl.json | 3 +- goodwe_controller/test_home.py | 148 +++++++++++++++++++++++++- 6 files changed, 233 insertions(+), 47 deletions(-) diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index d63c552..2a72602 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -29,9 +29,6 @@ from .uimodel import REGION_PRESETS, options_to_ui # A battery at or under this many watts is resting, not charging or giving. IDLE_W = 50.0 -# Display only: the lowest third of today's price range is "cheap", the top -# third "expensive". A price range under this is flat, and nothing is marked. -FLAT_EUR_KWH = 0.01 # Severity words the page shows; the same four as STYLE.md section 3. ALARM, WARNING, INFO = messages.ALARM, messages.WARNING, messages.INFO @@ -142,55 +139,103 @@ class DayPrices: def __init__(self): self.day = None self.points = {} + self.kinds = {} def see(self, points, day0, day1): if self.day != day0: - self.day, self.points = day0, {} + self.day, self.points, self.kinds = day0, {}, {} for p in points: if day0 <= p.start < day1: self.points[p.start] = p return sorted(self.points.values(), key=lambda p: p.start) -def hour_bars(points, day0, day1): - """One bar per local hour: the mean of its prices, or None when unknown. - `kind` is cheap / dear / mid by thirds of today's range (display only). - The page calls these "low price" and "high price", not cheap hours: the - add-on trades in the one cheapest and one dearest hour, not in a third.""" - n = int(round(_hours(day1, day0))) +def _bar_step(pts): + """The series' own spacing: its smallest gap, never over an hour.""" + gaps = [y.start - x.start for x, y in zip(pts, pts[1:]) + if y.start > x.start] + return min(gaps + [timedelta(hours=1)]) + + +def _bar_cover(pts, step): + """`[(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 + + +def price_bars(points, day0, day1, windows=(), now=None, kept=None): + """One bar per price step of the day: a quarter for a 15-minute series + (96; 92 and 100 on the clock-change days), an hour for an hourly one or a + static tariff. A bar is the price of the point that covers it, never an + average, and a step with no price is a bar with `eur_kwh` None. + `kind` is "cheap" or "dear" where `windows` (`[(kind, start, end)]`, the + runs the add-on itself uses) cover the bar's start, else "mid". A bar that + is over keeps the kind it had while it was still ahead (`kept`, a dict + that lives a day). Display only.""" + pts = sorted(points, key=lambda p: p.start) + step = _bar_step(pts) + cover = _bar_cover(pts, step) + n = int((day1 - day0) / step) bars = [] for k in range(n): - a, b = day0 + timedelta(hours=k), day0 + timedelta(hours=k + 1) - inside = [p for p in points if a <= p.start < b] - price = (sum(p.eur_per_kwh for p in inside) / len(inside) - if inside else None) + a = day0 + k * step + z = a + step + hit = next((p for s, e, p in cover if s <= a < e), None) + kind = None + if hit is not None: + if kept is not None and now is not None and z <= now: + kind = kept.get(a, "mid") + else: + kind = next((w[0] for w in windows if w[1] <= a < w[2]), + "mid") + if kept is not None: + kept[a] = kind bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a), - "end_label": hhmm(b), "points": len(inside), - "eur_kwh": None if price is None else round(price, 4), - "estimate": bool(inside) and any(p.provisional - for p in inside), - "kind": None}) - known = [b["eur_kwh"] for b in bars if b["eur_kwh"] is not None] - if known and max(known) - min(known) >= FLAT_EUR_KWH: - lo, hi = min(known), max(known) - third = (hi - lo) / 3.0 - for b in bars: - p = b["eur_kwh"] - if p is None: - continue - b["kind"] = ("cheap" if p <= lo + third else - "dear" if p >= hi - third else "mid") - elif known: - for b in bars: - if b["eur_kwh"] is not None: - b["kind"] = "mid" + "end_label": hhmm(z), + "eur_kwh": None if hit is None + else round(hit.eur_per_kwh, 4), + "estimate": hit is not None and bool(hit.provisional), + "kind": kind}) return bars +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` + (`c.mode_runs`): the cheap run when charging in it pays, and every give + run. With them off it is the arbitrage verdict's two points, the ones + `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: + return [] + out = [("cheap", rp.cheap.start, rp.cheap.end)] \ + if rp.charge_useful() else [] + return out + [("dear", g.start, g.end) for g in rp.give] + if verdict is None: + return [] + return [("cheap", verdict.cheapest.start, + _point_end(plan, verdict.cheapest)), + ("dear", verdict.dearest.start, + _point_end(plan, verdict.dearest))] + + def day_range(points): """(lowest, highest) of today's published points, as {eur_kwh, label}, - or (None, None). The feed's own points, like the trade legs, not the - chart's hour averages.""" + or (None, None). The feed's own points, like the trade legs.""" firm = [p for p in points if not p.provisional] if not firm: return None, None @@ -551,13 +596,15 @@ def build_plan(c, now, shadow, running, held=False, settled=True): plan = static except Exception: # noqa: BLE001 pass - seen = _day_prices(c).see(_points(plan), day0, day1) - bars = hour_bars(seen, day0, day1) + days = _day_prices(c) + seen = days.see(_points(plan), day0, day1) limit_w = c.peak.cap_w # the limit the controller parsed at start runs, lines = [], [] # The control cycle trades on the fresh plan only, so this does too. verdict, trade_why = trade_verdict(c, fresh) + bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict), + now, days.kinds) modes_on = bool(getattr(c, "modes_on", False)) if running: off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)} diff --git a/goodwe_controller/app/static/app.css b/goodwe_controller/app/static/app.css index db67144..2a38a93 100644 --- a/goodwe_controller/app/static/app.css +++ b/goodwe_controller/app/static/app.css @@ -78,6 +78,8 @@ h2{font-size:15px;margin:0 0 10px;font-weight:650} .bars i.cheap{background:var(--cheap)} /* not by colour alone: expensive hours are striped, estimates are faded */ .bars i.dear{background:repeating-linear-gradient(135deg,var(--dear) 0 4px,color-mix(in srgb,var(--dear) 60%,var(--card)) 4px 7px)} +.bars.fine{gap:1px} +.bars.fine i{border-radius:1px 1px 0 0} .bars i.est{opacity:.55} .bars i.none{background:none;border-bottom:2px dotted var(--faint);height:2px!important} .lane{position:relative;height:18px;margin-top:4px;background:var(--bg);border-radius:4px} diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js index 0897f15..faa8b1b 100644 --- a/goodwe_controller/app/static/app.js +++ b/goodwe_controller/app/static/app.js @@ -177,11 +177,12 @@ const known = p.prices.filter((b) => b.eur_kwh != null); const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh))); bars.setAttribute("aria-label", t("plan.name")); + bars.classList.toggle("fine", p.prices.length > 48); bars.replaceChildren(...p.prices.map((b) => { const title = b.label + " " + t("plan.time_to") + " " + b.end_label; if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]); const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : ""; - const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : ""); + const est = (b.estimate ? ", " + t("plan.estimate_tip") : ""); return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""), "height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%", title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]); diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index 102e311..4c1ea93 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -66,8 +66,7 @@ "estimate_tip": "estimate from yesterday", "none_tip": "No price known", "no_prices": {"description": "No prices for today, so the chart shows only the plan."}, - "list": "The plan in words", - "average_tip": "hour average" + "list": "The plan in words" }, "peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"}, "attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"}, diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index e027d6e..1cd3e24 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -63,8 +63,7 @@ "estimate_tip": "schatting op basis van gisteren", "none_tip": "Geen prijs bekend", "no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."}, - "list": "Het plan in woorden", - "average_tip": "gemiddelde van het uur" + "list": "Het plan in woorden" }, "peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"}, "attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"}, diff --git a/goodwe_controller/test_home.py b/goodwe_controller/test_home.py index 29fcbe5..3fd88b1 100644 --- a/goodwe_controller/test_home.py +++ b/goodwe_controller/test_home.py @@ -317,7 +317,7 @@ check("a plan with no published prices yet says so, not 'the price gap is " _t == [] and homeview.line_no_trade("not_firm") in _l and homeview.line_no_trade("not_worth") not in _l) check("the price bars are today's 24 hours, from the real series, with the " - "cheap and expensive thirds marked", + "cheap and dear hour marked", [b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK and s_arb["plan"]["prices"][13]["kind"] == "cheap" and s_arb["plan"]["prices"][19]["kind"] == "dear" @@ -518,10 +518,148 @@ check("quarter-hour prices: the charge block, its price and the price line's " and f"Today: lowest €{_lo.eur_per_kwh:.2f} at " f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"] and "the list" not in json.dumps(s_q["plan"])) -check("...and the hour bar says it is an hour average", - s_q["plan"]["prices"][13]["points"] == 4 - and '"plan.average_tip"' in io.open(os.path.join( - HERE, "app", "static", "app.js"), encoding="utf-8").read()) + +# UX-45: the chart draws the steps the add-on decides on. +_pq = s_q["plan"]["prices"] +check("UX-45: 96 quarter prices -> 96 bars, each one quarter, labelled " + "from its own start, with its own price and no average", + len(_pq) == 96 and _pq[0]["label"] == "00:00" + and _pq[0]["end_label"] == "00:15" and _pq[57]["label"] == "14:15" + and _pq[57]["end_label"] == "14:30" + and [b["eur_kwh"] for b in _pq] == [round(p.eur_per_kwh, 4) + for p in _q[:96]] + and all("points" not in b for b in _pq) + and s_q["plan"]["hours"] == 24) +check("...the quarters of one hour differ (the chart shows the steps)", + len({b["eur_kwh"] for b in _pq[52:56]}) > 1) +check("...and the page has no 'hour average' tip any more", + "average_tip" not in io.open(os.path.join( + HERE, "app", "static", "app.js"), encoding="utf-8").read() + and "average_tip" not in io.open(os.path.join( + HERE, "app", "ui_text", "en.json"), encoding="utf-8").read() + and "average_tip" not in io.open(os.path.join( + HERE, "app", "ui_text", "nl.json"), encoding="utf-8").read()) +_cheap_q = [b["start"] for b in _pq if b["kind"] == "cheap"] +_dear_q = [b["start"] for b in _pq if b["kind"] == "dear"] +_hi = max(_q[:96], key=lambda p: p.eur_per_kwh) +check("...without battery modes the marks are the two quarters the " + "arbitrage verdict trades in, and no others", + _cheap_q == [_lo.start.isoformat()] and _dear_q == [_hi.start.isoformat()] + and sum(1 for b in _pq if b["kind"] == "mid") == 94) + +# Battery modes on: the marks ARE the kept priceruns result. +from app import priceruns +_rp = priceruns.plan(tuple(_q), NOW, required_eur_kwh=0.05, charge_kwh=2.0, + charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0) +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 +_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)] +_want_c = {a.isoformat() for a, z in _today + if _rp.cheap.start <= a < _rp.cheap.end} +_want_d = {a.isoformat() for g in _rp.give for a, z in _today + if g.start <= a < g.end} +check("UX-45: with battery modes on, the cheap and dear quarters are the " + "priceruns cheap run and give runs for the same input", + _rp.on and _rp.charge_useful() and _want_c and _want_d + and {b["start"] for b in _pm if b["kind"] == "cheap"} == _want_c + and {b["start"] for b in _pm if b["kind"] == "dear"} == _want_d + and len(_pm) == 96) +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 +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) +check("...a run plan that says no trade marks nothing", + not c_m2.mode_runs.on + and {b["kind"] for b in status(c_m2)["plan"]["prices"]} == {"mid"}) + +# A quarter that is over keeps the mark it had while it was ahead. +# (`status()` also builds at wall-clock time, which starts the day's memory +# over, so the two builds below go straight to `homeview.build`.) +_later = NOW + timedelta(hours=4) +_s0 = c_q.status_payload() +homeview.build(c_q, level=_s0["level"], checks=_s0["checks"], now=NOW) +c_q.tariff_at = _later +_pl = homeview.build(c_q, level=_s0["level"], checks=_s0["checks"], + now=_later)["plan"]["prices"] +check("...and a quarter already over keeps the mark it had while ahead", + [b["kind"] for b in _pl if b["start"] == _lo.start.isoformat()] + == ["cheap"] + 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) + +# A cheap run that is not worth charging in is not marked; an estimate is. +import types +_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) +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() + and [w[0] for w in _w2] == ["dear"]) +_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) + +# Hourly prices -> 24 bars (above); a day/night tariff -> 24 bars. +_ph1 = s_arb["plan"]["prices"] +check("UX-45: hourly prices -> 24 hour bars with the hour's own price", + len(_ph1) == 24 and _ph1[13]["end_label"] == "14:00" + and "points" not in _ph1[13]) +c_dn = make(dict(ARB, static_profile=( + '{"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}]}'))) +c_dn.peak_guard = quiet_guard() +_pd = status(c_dn)["plan"]["prices"] +check("UX-45: a day/night tariff -> 24 bars, 0.26 at night and 0.34 by day", + len(_pd) == 24 + and [b["eur_kwh"] for b in _pd] + == [.26] * 7 + [.34] * 15 + [.26] * 2 + and all(b["kind"] == "mid" for b in _pd)) + +# Clock-change days: 92 and 100 quarters. +_d0 = datetime(2026, 3, 28, 23, 0, tzinfo=timezone.utc) +_d1 = datetime(2026, 3, 29, 22, 0, tzinfo=timezone.utc) +_sp = [PricePoint(_d0 + timedelta(minutes=15 * i), .1, False) + for i in range(92)] +check("UX-45: a 23-hour day has 92 quarter bars, a 25-hour day 100", + len(homeview.price_bars(_sp, _d0, _d1)) == 92 + and len(homeview.price_bars( + _sp, _d0, _d0 + timedelta(hours=25))) == 100) # --------------------------------------------------------------------------- # print("\n4. a fault in the Home view never costs the page its rows") From eb8375157db39c45c65b5db4d0b9a451351fe25e Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 19 Oct 2026 10:01:58 +0200 Subject: [PATCH 2/2] 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") # --------------------------------------------------------------------------- #