UX-13 P5: Dutch text, STYLE check, DOCS screenshots, period editor
checks / unit (pull_request) Successful in 10m45s
infra-pin / fresh (pull_request) Successful in 1s

- app/ui_text/nl.json and app/uitext.py: every page word in Dutch, English
  fallback per key; the page picks by browser language. Home's sentences
  move into ui_text `home_words`, so /status?lang=nl builds them in Dutch.
- test_ui_text.py: EN/NL key and placeholder parity, STYLE limits, jargon
  lists for both languages, no inline details, untranslated Dutch caught.
  Shared rules moved unchanged to tools/style_rules.py.
- tools/ux13_screenshots.sh/.py: DOCS screenshots from the demo fixtures in
  headless Chromium; docs/img/ux13/. Battery modes (P3): no screenshot.
- "Other time windows": a list editor (ui.periods) that builds
  static_profile and names bad times, empty prices, overlaps and gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude dev agent
2026-09-28 16:33:32 +02:00
parent 8c34647d05
commit 667b8e9145
38 changed files with 1633 additions and 300 deletions
+129 -10
View File
@@ -2,8 +2,8 @@
`options_to_ui()` turns the stored blob into what the page edits: always a list
of inverter cards, solar rows with a "reads negative" toggle, a contract and
simple prices instead of `static_profile` JSON, and a region preset instead of
the levies figure. `ui_to_options()` turns it back, so the validator and the
simple prices or time windows (P5) instead of `static_profile` JSON, and a
region preset instead of the levies figure. `ui_to_options()` turns it back, so the validator and the
Supervisor always see the real blob.
The rule that makes the round trip exact: the model carries the stored values
@@ -16,12 +16,14 @@ See DECISIONS/UX-13-one-layout-table.md.
import copy
import json
import re
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH
from .static_tariff import ProfileInvalid, parse_static_profile
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH, MAX_PLAUSIBLE_EUR_KWH
from .static_tariff import (DAY_NAMES, MINUTES_PER_DAY, Period, ProfileInvalid,
parse_static_profile)
__all__ = ["options_to_ui", "ui_to_options", "ui_to_options_partial",
"REGION_PRESETS",
"REGION_PRESETS", "PERIOD_DAYS",
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
@@ -30,9 +32,10 @@ class DeriveError(ValueError):
the sentence the page shows (app/ui_text `derive.<code>`), and `field` the
page field it is shown beside."""
def __init__(self, text, code="", field=""):
def __init__(self, text, code="", field="", **values):
super().__init__(text)
self.code, self.field = code, field
self.values = values # the sentence's {placeholders} (P5)
self.keys = () # the option keys the failed derivation writes
@@ -213,14 +216,125 @@ def _profile_decode(raw):
return "custom", None, None, None
# --- 3b. the time-window editor (UX-13 P5) ---------------------------------- #
# Each row is {"from", "to", "days", "price"} and, when read from a stored
# profile, its "name". `days` is one of these; a profile with any other day set
# is edited in the Configuration tab only (`ui.periods` is None).
PERIOD_DAYS = ("mon-sun", "mon-fri", "sat,sun") + DAY_NAMES
_DAYSETS = {d: frozenset(range(7)) if d == "mon-sun" else
frozenset(range(5)) if d == "mon-fri" else
frozenset((5, 6)) if d == "sat,sun" else
frozenset((DAY_NAMES.index(d),)) for d in PERIOD_DAYS}
_TIME = re.compile(r"^\s*(\d{1,2}):(\d{2})\s*$")
def _hhmm(minute) -> str:
return f"{minute // 60:02d}:{minute % 60:02d}"
def _periods_decode(raw):
"""The rows for a stored profile: [] when unset, None when it cannot be
shown as rows (unparsable, or days the editor has no choice for)."""
try:
prof = parse_static_profile(raw) if isinstance(raw, str) else None
except ProfileInvalid:
return None
if prof is None:
return [] if isinstance(raw, str) else None
rows = []
for p in prof.periods:
days = next((d for d, s in _DAYSETS.items() if s == p.days), None)
if days is None:
return None
rows.append({"from": _hhmm(p.start_min), "to": _hhmm(p.end_min),
"days": days, "price": p.eur_per_kwh, "name": p.name})
return rows
def _minute(text, *, end):
""""07:00" -> 420, "24:00" -> 1440 as an end, else None."""
m = _TIME.match(str(text or ""))
if not m:
return None
h, mi = int(m.group(1)), int(m.group(2))
if mi > 59 or h > 24 or (h == 24 and (mi or not end)):
return None
return h * 60 + mi
def _periods_json(rows) -> str:
"""The static_profile for the editor's rows, or DeriveError naming the
first problem: a bad time, an empty price, an overlap or a gap."""
if not rows:
raise DeriveError("add at least one time window", "no_periods",
"ui.periods")
periods = []
for i, row in enumerate(rows, start=1):
row = row if isinstance(row, dict) else {}
start = _minute(row.get("from"), end=False)
end = _minute(row.get("to"), end=True)
if start is None or end is None:
raise DeriveError(f"window {i} has a bad time", "period_time",
"ui.periods", row=i)
end = end or MINUTES_PER_DAY # "00:00" as an end
if start == end:
raise DeriveError(f"window {i} is empty", "period_zero",
"ui.periods", row=i)
price = _num(row.get("price"))
if price is None:
raise DeriveError(f"window {i} needs a price", "period_price",
"ui.periods", row=i)
if abs(price) > MAX_PLAUSIBLE_EUR_KWH:
raise DeriveError(f"window {i}'s price is out of range",
"period_price_range", "ui.periods", row=i)
if row.get("days") not in _DAYSETS:
raise DeriveError(f"window {i} has no days", "unknown",
"ui.periods")
name = str(row.get("name") or "").strip() or f"window{i}"
periods.append(Period(name=name, start_min=start, end_min=end,
days=_DAYSETS[row["days"]],
eur_per_kwh=price))
_tiling(periods)
return json.dumps({"periods": [
{"name": p.name, "from": _hhmm(p.start_min),
"to": _hhmm(p.end_min), "days": row["days"],
"eur_per_kwh": p.eur_per_kwh}
for p, row in zip(periods, rows)]})
def _tiling(periods):
"""Each minute of the week in exactly one window, as static_tariff.py
requires. The first overlap or gap, by day and time, is the error."""
for day in range(7):
start, kind, rows = None, None, None
for minute in range(MINUTES_PER_DAY + 1):
here = [i for i, p in enumerate(periods, start=1)
if minute < MINUTES_PER_DAY and p.covers(day, minute)]
now = ("gap" if not here else "overlap" if len(here) > 1
else None) if minute < MINUTES_PER_DAY else "end"
if kind is not None and (now != kind or (kind == "overlap"
and here != rows)):
values = {"day": DAY_NAMES[day], "from": _hhmm(start),
"to": _hhmm(minute)}
if kind == "gap":
raise DeriveError("a time has no window", "period_gap",
"ui.periods", **values)
raise DeriveError("two windows cover one time",
"period_overlap", "ui.periods",
a=rows[0], b=rows[1], **values)
if now in ("gap", "overlap") and kind is None:
start, kind, rows = minute, now, here
def _tariff_decode(o):
kind, fixed, day, night = _profile_decode(o.get("static_profile", ""))
raw = o.get("static_profile", "")
kind, fixed, day, night = _profile_decode(raw)
provider = o.get("price_provider")
dynamic = not _blank(provider) and provider != "off"
return {"ui.contract": "dynamic" if dynamic else kind,
"price_provider": provider, "ui.fallback": kind,
"ui.fixed_price": fixed, "ui.day_price": day,
"ui.night_price": night}
"ui.night_price": night, "ui.periods": _periods_decode(raw)}
def _tariff_encode(m, keep):
@@ -243,11 +357,16 @@ def _tariff_encode(m, keep):
out["static_profile"] = _daynight_json(_price_of(m, "ui.day_price"),
_price_of(m, "ui.night_price"))
elif kind == "custom":
if "static_profile" not in keep:
rows = m.get("ui.periods")
if rows is not None:
out["static_profile"] = _periods_json(rows)
elif "static_profile" not in keep:
raise DeriveError("odd tariff windows are edited in the "
"Configuration tab", "custom_profile",
"ui.contract")
out["static_profile"] = keep["static_profile"]
else:
# Days the editor cannot show: kept as stored.
out["static_profile"] = keep["static_profile"]
else:
raise DeriveError(f"unknown contract {kind!r}", "unknown",
"ui.contract")