Merge pull request 'UX-45: Home price chart shows the 15-minute prices the add-on uses' (#202) from UX-45 into release/1.0
infra-pin / fresh (push) Successful in 1s

This commit was merged in pull request #202.
This commit is contained in:
2026-10-19 12:15:56 +02:00
7 changed files with 375 additions and 51 deletions
+92 -38
View File
@@ -22,16 +22,13 @@ from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD 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 .maintenance import IDLE as M_IDLE, next_slot_start
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST 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 .static_tariff import static_plan
from .tariff import BILLING_TZ from .tariff import BILLING_TZ
from .uimodel import REGION_PRESETS, options_to_ui 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):
"""`[(start, end, point)]`: what each point is the price of on the chart.
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):
"""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)
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_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`
(`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 or not _price_mode_in_use(c):
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)}
@@ -1431,3 +1478,10 @@ def _samples() -> list:
("not_covered", "no_prices", "peak", "not_firm", "not_worth", ("not_covered", "no_prices", "peak", "not_firm", "not_worth",
"no_level")] "no_level")]
return out 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})
+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"},
+2 -2
View File
@@ -50,7 +50,7 @@ from aiohttp import web
from . import messages, price, selfconfig, uipage, uitext from . import messages, price, selfconfig, uipage, uitext
from .haentities import EntityCache from .haentities import EntityCache
from .homeview import region_of from .homeview import priced_hours, region_of
from .validate import config_notes, setup_checks from .validate import config_notes, setup_checks
from .config_page import CONFIG_PAGE from .config_page import CONFIG_PAGE
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets, from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
@@ -328,7 +328,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
try: try:
prices = (controller.status_payload().get("plan") or {}).get( prices = (controller.status_payload().get("plan") or {}).get(
"prices") or [] "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 except Exception: # noqa: BLE001 - a display figure, never a failure
known = None known = None
return setup_checks(options, entities=rows, running=controller.o, return setup_checks(options, entities=rows, running=controller.o,
+275 -6
View File
@@ -33,7 +33,9 @@ from app import homeview, web
from app.main import Controller from app.main import Controller
from app.peakguard import PeakDecision from app.peakguard import PeakDecision
from app.price import PricePoint, PriceProduct, PriceRead 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 app.uimodel import REGION_PRESETS
from named_absence import field, nth, present 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) 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): def status(c, now=NOW):
s = c.status_payload() s = c.status_payload()
# The status route builds at wall-clock time; rebuild the structured half # The status route builds at wall-clock time; rebuild the structured half
@@ -317,7 +327,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 +528,269 @@ 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
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)]
_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
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)
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
# 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
_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)
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()
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) 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"]
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)
_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") print("\n4. a fault in the Home view never costs the page its rows")