UX-45: Home price chart draws one bar per price step the add-on uses
checks / unit (pull_request) Successful in 40m15s
infra-pin / fresh (pull_request) Successful in 1s

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 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-19 00:35:29 +02:00
co-authored by Claude
parent 243a756e7b
commit 17c6048417
6 changed files with 233 additions and 47 deletions
+84 -37
View File
@@ -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)}
+2
View File
@@ -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}
+2 -1
View File
@@ -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 + "."]);
+1 -2
View File
@@ -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"},
+1 -2
View File
@@ -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"},
+143 -5
View File
@@ -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")