diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index d63c552..9501c7a 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -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 IDLE as M_IDLE, next_slot_start 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 .tariff import BILLING_TZ 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): + """`[(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 = [] 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_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): """(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)} @@ -1431,3 +1478,10 @@ def _samples() -> list: ("not_covered", "no_prices", "peak", "not_firm", "not_worth", "no_level")] 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}) diff --git a/goodwe_controller/app/static/app.css b/goodwe_controller/app/static/app.css index db67144..2a38a93 100644 --- a/goodwe_controller/app/static/app.css +++ b/goodwe_controller/app/static/app.css @@ -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} diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js index 0897f15..faa8b1b 100644 --- a/goodwe_controller/app/static/app.js +++ b/goodwe_controller/app/static/app.js @@ -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 + "."]); diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index 102e311..4c1ea93 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -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"}, diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index e027d6e..1cd3e24 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -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"}, diff --git a/goodwe_controller/app/web.py b/goodwe_controller/app/web.py index e301e34..77093dd 100644 --- a/goodwe_controller/app/web.py +++ b/goodwe_controller/app/web.py @@ -50,7 +50,7 @@ from aiohttp import web from . import messages, price, selfconfig, uipage, uitext from .haentities import EntityCache -from .homeview import region_of +from .homeview import priced_hours, region_of from .validate import config_notes, setup_checks from .config_page import CONFIG_PAGE from .configui import (changed_keys, field_model, hide_secrets, keep_secrets, @@ -328,7 +328,7 @@ def build_app(controller, selfcfg=None) -> web.Application: try: prices = (controller.status_payload().get("plan") or {}).get( "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 known = None return setup_checks(options, entities=rows, running=controller.o, diff --git a/goodwe_controller/test_home.py b/goodwe_controller/test_home.py index 29fcbe5..8a00330 100644 --- a/goodwe_controller/test_home.py +++ b/goodwe_controller/test_home.py @@ -33,7 +33,9 @@ from app import homeview, web from app.main import Controller from app.peakguard import PeakDecision 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 named_absence import field, nth, present @@ -152,6 +154,14 @@ def quiet_guard(projected_w=1240.0): 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): s = c.status_payload() # 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 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 +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 " 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 +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")