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 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
@@ -157,23 +157,12 @@ def _bar_step(pts):
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.
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
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):
@@ -187,7 +176,7 @@ def price_bars(points, day0, day1, windows=(), now=None, kept=None):
that lives a day). Display only."""
pts = sorted(points, key=lambda p: p.start)
step = _bar_step(pts)
cover = _bar_cover(pts, step)
cover = _bar_cover(pts)
n = int((day1 - day0) / step)
bars = []
for k in range(n):
@@ -212,6 +201,17 @@ def price_bars(points, day0, day1, windows=(), now=None, kept=None):
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`
@@ -220,7 +220,7 @@ def price_windows(c, plan, verdict):
`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:
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 []
@@ -1478,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})
+2 -2
View File
@@ -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,