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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
243a756e7b
commit
17c6048417
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 + "."]);
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user