UX-45: Home price chart shows the 15-minute prices the add-on uses #202
@@ -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)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 + "."]);
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user