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")