UX-45: count priced hours in the wizard, fill chart holes by the add-on's cover rule, mark only for price-mode batteries
checks / unit (pull_request) Successful in 40m20s
infra-pin / fresh (pull_request) Successful in 1s

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-19 10:01:58 +02:00
co-authored by Claude
parent 17c6048417
commit eb8375157d
3 changed files with 184 additions and 46 deletions
+26 -19
View File
@@ -22,7 +22,7 @@ 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
@@ -157,23 +157,12 @@ def _bar_step(pts):
return min(gaps + [timedelta(hours=1)]) return min(gaps + [timedelta(hours=1)])
def _bar_cover(pts, step): def _bar_cover(pts):
"""`[(start, end, point)]`: what each point is the price of on the chart. """`[(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 The add-on's own rule, not a copy: `price._cover_table()` (TARIFF-14), so
step, so a missing quarter stays empty and does not stretch its a point covers until the next one starts and never over an hour, and the
neighbour. A carried (provisional) last point is hourly.""" last one for the span of the one before it."""
hour = timedelta(hours=1) return _cover_table(pts)
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): def price_bars(points, day0, day1, windows=(), now=None, kept=None):
@@ -187,7 +176,7 @@ def price_bars(points, day0, day1, windows=(), now=None, kept=None):
that lives a day). Display only.""" that lives a day). Display only."""
pts = sorted(points, key=lambda p: p.start) pts = sorted(points, key=lambda p: p.start)
step = _bar_step(pts) step = _bar_step(pts)
cover = _bar_cover(pts, step) cover = _bar_cover(pts)
n = int((day1 - day0) / step) n = int((day1 - day0) / step)
bars = [] bars = []
for k in range(n): for k in range(n):
@@ -212,6 +201,17 @@ def price_bars(points, day0, day1, windows=(), now=None, kept=None):
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): def price_windows(c, plan, verdict):
"""`[(kind, start, end)]`: where the add-on itself buys ("cheap") and """`[(kind, start, end)]`: where the add-on itself buys ("cheap") and
gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan` gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan`
@@ -220,7 +220,7 @@ def price_windows(c, plan, verdict):
`trade_runs()` draws. Nothing is classified here.""" `trade_runs()` draws. Nothing is classified here."""
if getattr(c, "modes_on", False): if getattr(c, "modes_on", False):
rp = getattr(c, "mode_runs", None) rp = getattr(c, "mode_runs", None)
if rp is None or not rp.on: if rp is None or not rp.on or not _price_mode_in_use(c):
return [] return []
out = [("cheap", rp.cheap.start, rp.cheap.end)] \ out = [("cheap", rp.cheap.start, rp.cheap.end)] \
if rp.charge_useful() else [] if rp.charge_useful() else []
@@ -1478,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 -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,
+156 -25
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
@@ -555,6 +565,7 @@ c_m = make(ARB)
c_m.tariff_plan, c_m.tariff_at = c_q.tariff_plan, NOW c_m.tariff_plan, c_m.tariff_at = c_q.tariff_plan, NOW
c_m.peak_guard = quiet_guard() c_m.peak_guard = quiet_guard()
c_m.modes_on, c_m.mode_runs = True, _rp 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"] _pm = status(c_m)["plan"]["prices"]
_today = [(a, z) for a, z, _p in priceruns.firm_slots(_q, NOW) _today = [(a, z) for a, z, _p in priceruns.firm_slots(_q, NOW)
if a < DAY0 + timedelta(hours=24)] if a < DAY0 + timedelta(hours=24)]
@@ -572,6 +583,7 @@ c_m2 = make(ARB)
c_m2.tariff_plan, c_m2.tariff_at = c_q.tariff_plan, NOW c_m2.tariff_plan, c_m2.tariff_at = c_q.tariff_plan, NOW
c_m2.peak_guard = quiet_guard() c_m2.peak_guard = quiet_guard()
c_m2.modes_on = True 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, 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, charge_kwh=2.0, charge_kw=4.0, sell_kwh=0.0,
sell_kw=0.0) sell_kw=0.0)
@@ -594,22 +606,38 @@ check("...and a quarter already over keeps the mark it had while ahead",
and [b["kind"] for b in _pl if b["label"] == "19:00"] == ["dear"]) and [b["kind"] for b in _pl if b["label"] == "19:00"] == ["dear"])
c_q.tariff_at = NOW c_q.tariff_at = NOW
# A missing quarter is an empty bar in its place, nothing shifts. # UX-45 round 2 (F2): a hole is covered by the add-on's own rule,
_hole = [p for p in _q if p.start != DAY0 + timedelta(hours=10, minutes=30)] # `price._cover_table()`: the point before it runs on, never over an hour.
c_h = make(ARB) def _hole_bars(missing):
c_h.tariff_plan = TariffPlan(PriceRead.series(tuple(_hole), PriceProduct.DAY_AHEAD, pts = [PricePoint(DAY0 + timedelta(minutes=15 * i), .10 + .001 * i, False)
"generic_dayahead", NOW), None, for i in range(96)]
"provider") gone = {DAY0 + timedelta(hours=10, minutes=15 * (1 + j))
c_h.tariff_at = NOW for j in range(missing)}
c_h.peak_guard = quiet_guard() pts = [p for p in pts if p.start not in gone]
_ph = status(c_h)["plan"]["prices"] return homeview.price_bars(pts, DAY0, DAY0 + timedelta(hours=24))
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 _gap = {}
and _ph[42]["kind"] is None and _ph[42]["label"] == "10:30" for _n in (1, 2, 3, 4, 5):
and _ph[41]["eur_kwh"] == _pq[41]["eur_kwh"] _bb = _hole_bars(_n)
and _ph[43]["eur_kwh"] == _pq[43]["eur_kwh"] and _ph[43]["label"] == "10:45" _gap[_n] = ([b["eur_kwh"] is None for b in _bb[41:41 + _n]],
and sum(1 for b in _ph if b["eur_kwh"] is None) == 1) 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. # A cheap run that is not worth charging in is not marked; an estimate is.
import types import types
@@ -617,8 +645,10 @@ _pts = [PricePoint(DAY0 + timedelta(minutes=15 * i),
.30 if i < 4 else .10, False) for i in range(8)] .30 if i < 4 else .10, False) for i in range(8)]
_rp2 = priceruns.plan(tuple(_pts), DAY0, required_eur_kwh=0.05, _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) 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, c_w2 = make(ARB)
mode_runs=_rp2), None, None) 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 " 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)", "charge, so it is not marked (the dear run is)",
_rp2.on and _rp2.cheap is not None and not _rp2.charge_useful() _rp2.on and _rp2.cheap is not None and not _rp2.charge_useful()
@@ -627,12 +657,17 @@ _est = [PricePoint(DAY0 + timedelta(minutes=15 * i), .2, False)
for i in range(80)] + [PricePoint(DAY0 + timedelta(hours=20), .25, for i in range(80)] + [PricePoint(DAY0 + timedelta(hours=20), .25,
True)] True)]
_be = homeview.price_bars(_est, DAY0, DAY0 + timedelta(hours=24)) _be = homeview.price_bars(_est, DAY0, DAY0 + timedelta(hours=24))
check("UX-45: a carried (estimate) hourly price after quarter prices covers " check("UX-45: a carried (estimate) price after quarter prices is the last "
"its own hour as four estimate bars, and the rest stays empty", "point, so it covers one quarter (the add-on's tail rule), as an "
[b["estimate"] for b in _be[78:85]] "estimate bar; the rest stays empty",
== [False, False, True, True, True, True, False] [b["estimate"] for b in _be[78:83]] == [False, False, True, False, False]
and [b["eur_kwh"] for b in _be[80:84]] == [.25] * 4 and _be[80]["eur_kwh"] == .25 and _be[81]["eur_kwh"] is None)
and _be[84]["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. # Hourly prices -> 24 bars (above); a day/night tariff -> 24 bars.
_ph1 = s_arb["plan"]["prices"] _ph1 = s_arb["plan"]["prices"]
@@ -661,6 +696,102 @@ check("UX-45: a 23-hour day has 92 quarter bars, a 25-hour day 100",
and len(homeview.price_bars( and len(homeview.price_bars(
_sp, _d0, _d0 + timedelta(hours=25))) == 100) _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")
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #