UX-45: Home price chart shows the 15-minute prices the add-on uses #202

Merged
admin merged 2 commits from UX-45 into release/1.0 2026-10-19 12:15:57 +02:00
6 changed files with 233 additions and 47 deletions
Showing only changes of commit 17c6048417 - Show all commits
+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. # A battery at or under this many watts is resting, not charging or giving.
IDLE_W = 50.0 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. # Severity words the page shows; the same four as STYLE.md section 3.
ALARM, WARNING, INFO = messages.ALARM, messages.WARNING, messages.INFO ALARM, WARNING, INFO = messages.ALARM, messages.WARNING, messages.INFO
@@ -142,55 +139,103 @@ class DayPrices:
def __init__(self): def __init__(self):
self.day = None self.day = None
self.points = {} self.points = {}
self.kinds = {}
def see(self, points, day0, day1): def see(self, points, day0, day1):
if self.day != day0: if self.day != day0:
self.day, self.points = day0, {} self.day, self.points, self.kinds = day0, {}, {}
for p in points: for p in points:
if day0 <= p.start < day1: if day0 <= p.start < day1:
self.points[p.start] = p self.points[p.start] = p
return sorted(self.points.values(), key=lambda p: p.start) return sorted(self.points.values(), key=lambda p: p.start)
def hour_bars(points, day0, day1): def _bar_step(pts):
"""One bar per local hour: the mean of its prices, or None when unknown. """The series' own spacing: its smallest gap, never over an hour."""
`kind` is cheap / dear / mid by thirds of today's range (display only). gaps = [y.start - x.start for x, y in zip(pts, pts[1:])
The page calls these "low price" and "high price", not cheap hours: the if y.start > x.start]
add-on trades in the one cheapest and one dearest hour, not in a third.""" return min(gaps + [timedelta(hours=1)])
n = int(round(_hours(day1, day0)))
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 = [] bars = []
for k in range(n): for k in range(n):
a, b = day0 + timedelta(hours=k), day0 + timedelta(hours=k + 1) a = day0 + k * step
inside = [p for p in points if a <= p.start < b] z = a + step
price = (sum(p.eur_per_kwh for p in inside) / len(inside) hit = next((p for s, e, p in cover if s <= a < e), None)
if inside else 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), bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a),
"end_label": hhmm(b), "points": len(inside), "end_label": hhmm(z),
"eur_kwh": None if price is None else round(price, 4), "eur_kwh": None if hit is None
"estimate": bool(inside) and any(p.provisional else round(hit.eur_per_kwh, 4),
for p in inside), "estimate": hit is not None and bool(hit.provisional),
"kind": None}) "kind": kind})
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"
return bars 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): def day_range(points):
"""(lowest, highest) of today's published points, as {eur_kwh, label}, """(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 or (None, None). The feed's own points, like the trade legs."""
chart's hour averages."""
firm = [p for p in points if not p.provisional] firm = [p for p in points if not p.provisional]
if not firm: if not firm:
return None, None return None, None
@@ -551,13 +596,15 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
plan = static plan = static
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass pass
seen = _day_prices(c).see(_points(plan), day0, day1) days = _day_prices(c)
bars = hour_bars(seen, day0, day1) seen = days.see(_points(plan), day0, day1)
limit_w = c.peak.cap_w # the limit the controller parsed at start limit_w = c.peak.cap_w # the limit the controller parsed at start
runs, lines = [], [] runs, lines = [], []
# The control cycle trades on the fresh plan only, so this does too. # The control cycle trades on the fresh plan only, so this does too.
verdict, trade_why = trade_verdict(c, fresh) 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)) modes_on = bool(getattr(c, "modes_on", False))
if running: if running:
off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)} 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)} .bars i.cheap{background:var(--cheap)}
/* not by colour alone: expensive hours are striped, estimates are faded */ /* 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 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.est{opacity:.55}
.bars i.none{background:none;border-bottom:2px dotted var(--faint);height:2px!important} .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} .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 known = p.prices.filter((b) => b.eur_kwh != null);
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh))); const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
bars.setAttribute("aria-label", t("plan.name")); bars.setAttribute("aria-label", t("plan.name"));
bars.classList.toggle("fine", p.prices.length > 48);
bars.replaceChildren(...p.prices.map((b) => { bars.replaceChildren(...p.prices.map((b) => {
const title = b.label + " " + t("plan.time_to") + " " + b.end_label; 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") + "."]); 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 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" : ""), return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%", "height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]); title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
+1 -2
View File
@@ -66,8 +66,7 @@
"estimate_tip": "estimate from yesterday", "estimate_tip": "estimate from yesterday",
"none_tip": "No price known", "none_tip": "No price known",
"no_prices": {"description": "No prices for today, so the chart shows only the plan."}, "no_prices": {"description": "No prices for today, so the chart shows only the plan."},
"list": "The plan in words", "list": "The plan in words"
"average_tip": "hour average"
}, },
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"}, "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"}, "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", "estimate_tip": "schatting op basis van gisteren",
"none_tip": "Geen prijs bekend", "none_tip": "Geen prijs bekend",
"no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."}, "no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."},
"list": "Het plan in woorden", "list": "Het plan in woorden"
"average_tip": "gemiddelde van het uur"
}, },
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"}, "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"}, "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 _t == [] and homeview.line_no_trade("not_firm") in _l
and homeview.line_no_trade("not_worth") not 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 " 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 [b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
and s_arb["plan"]["prices"][13]["kind"] == "cheap" and s_arb["plan"]["prices"][13]["kind"] == "cheap"
and s_arb["plan"]["prices"][19]["kind"] == "dear" 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 " and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"] f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
and "the list" not in json.dumps(s_q["plan"])) 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 # UX-45: the chart draws the steps the add-on decides on.
and '"plan.average_tip"' in io.open(os.path.join( _pq = s_q["plan"]["prices"]
HERE, "app", "static", "app.js"), encoding="utf-8").read()) 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") print("\n4. a fault in the Home view never costs the page its rows")