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
+181 -176
View File
@@ -16,7 +16,7 @@ section at the bottom and is held to STYLE.md by test_messages.py.
from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard
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
from .price import Absence, Uncertainty, brussels
@@ -54,18 +54,6 @@ def hhmm(t: datetime) -> str:
return f"{brussels(t):%H:%M}"
def _kw(w) -> str:
return f"{abs(float(w)) / 1000:.1f} kW"
def _eur(v) -> str:
return f"€{float(v):.2f}"
def _kwh(v) -> str:
return f"{float(v):.1f} kWh"
# --------------------------------------------------------------------------- #
# Now
# --------------------------------------------------------------------------- #
@@ -490,8 +478,7 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
"day": brussels(now).date().isoformat(),
"now_h": round(_hours(now, day0), 3),
"hours": int(round(_hours(day1, day0))),
"verdict": (VERDICT[verdict_key] if settled or verdict_key != "held"
else ZERO_GOING),
"verdict": verdict_words(verdict_key, settled),
"verdict_key": verdict_key,
"head": plan_head(verdict_key, shadow, settled),
"prices": bars,
@@ -537,7 +524,7 @@ def battery_rows(c, shadow, running, held=False):
"stored_kwh": (None if cap is None or inv.soc is None
else round(cap * float(inv.soc) / 100.0, 2)),
"state": flow(inv.batt, shadow=shadow),
"mode": MODE_HOUSE,
"mode": mode_house(),
"maintenance": phase,
"now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow,
running=running, held=held,
@@ -549,28 +536,6 @@ def battery_rows(c, shadow, running, held=False):
return rows
# Row id -> the title on Home. The row's own sentence is the body.
ATTN_TITLES = {
"controller": "Control is not running",
"meter": "Meter reading",
"meter_frozen": messages.T_METER_FROZEN,
"freeze_check": "Stuck meter check",
"month_peak": "This month's peak",
"peak_guard": "Peak limit",
"peak_forecast": "Peak limit forecast",
"sensor": "Battery readings",
"control": "Inverter control",
"power_too_high": messages.T_OUTPUT_CLAMP,
"not_keeping_up": messages.T_LOOP_BOUND,
"write": "Power request not accepted",
"direction": "Power went the wrong way",
"site_direction": "Power went the wrong way",
"circulating": messages.T_CIRCULATING,
"delivery": messages.S_NOT_DELIVERING,
"estop": "Emergency stop",
"pv": "Solar reading",
None: "Inverter connection",
}
# Row id -> the DOCS.md heading under Notifications, where one exists.
ATTN_DOCS = {
"meter_frozen": messages.T_METER_FROZEN,
@@ -594,7 +559,7 @@ def attention(checks):
sev = INFO if ok else (WARNING if warn else ALARM)
rid = row.get("id")
out.append({"severity": sev, "id": rid,
"title": ATTN_TITLES.get(rid, ATTN_TITLES[None]),
"title": attn_title(rid),
"body": row.get("text", ""),
"docs": ATTN_DOCS.get(rid)})
out.sort(key=lambda a: rank[a["severity"]])
@@ -605,7 +570,7 @@ def month_peak(c, now):
"""This month's peak against the limit, and the 15-minute period so far."""
limit_w = c.peak.cap_w # the limit the controller parsed at start
local = brussels(now)
out = {"month": f"{local:%B}", "limit_w": limit_w, "peak_w": None,
out = {"month": month_name(local), "limit_w": limit_w, "peak_w": None,
"pct": None, "period_w": None, "state": "not_covered",
"line": ""}
if not c.p1_enabled:
@@ -644,8 +609,18 @@ def state_of(c, level, shadow):
return "running"
def build(c, *, level: str, checks: list, now: datetime | None = None) -> dict:
"""The structured half of `/status`. Pure reads of the controller."""
def build(c, *, level: str, checks: list, now: datetime | None = None,
lang: str = "en") -> dict:
"""The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of
the controller."""
_LANG[0] = uitext.lang_of(lang)
try:
return _build(c, level=level, checks=checks, now=now)
finally:
_LANG[0] = "en"
def _build(c, *, level, checks, now):
now = now or datetime.now(timezone.utc)
shadow = bool(c.shadow)
kind = state_of(c, level, shadow)
@@ -667,7 +642,7 @@ def build(c, *, level: str, checks: list, now: datetime | None = None) -> dict:
"plan": build_plan(c, now, shadow, running, held=held,
settled=settled),
"attention": ([{"severity": INFO, "id": "shadow",
"title": ATTN_SHADOW, "body": attn_shadow_body(),
"title": attn_shadow(), "body": attn_shadow_body(),
"docs": None}] if shadow else []) + attention(checks),
"month_peak": month_peak(c, now),
"control": {"running": bool(c.auto), "shadow": shadow,
@@ -687,279 +662,309 @@ def _docs_url():
# The words. STYLE.md: plain, short, present tense. A plan line is at most
# two sentences of at most 12 words each (UX-13 design, section 4); a title at
# most 5 words; everything else one sentence of at most 25 words. In shadow
# mode a text says what the add-on WOULD do. test_messages.py renders every
# function below through `text_samples()` and checks it.
# mode a text says what the add-on WOULD do. The sentences themselves are in
# app/ui_text/<lang>.json under `home_words` (UX-13 P5: English and Dutch).
# test_messages.py and test_ui_text.py render every function below through
# `text_samples()` in each language and check it.
# =========================================================================== #
MODE_HOUSE = "Follows the house"
ATTN_SHADOW = "Shadow mode is on"
_LANG = ["en"] # the language of the build in progress (build())
_WORDS: dict = {}
def _hw() -> dict:
lang = _LANG[0]
if lang not in _WORDS:
_WORDS[lang] = uitext.words(lang)
return _WORDS[lang]
def _t(path, **values) -> str:
node = _hw()["home_words"]
for k in path.split("."):
node = node[k]
return uitext.say(node, **values)
def _dec(text) -> str:
"""A decimal comma in Dutch."""
return text.replace(".", ",") if _LANG[0] == "nl" else text
def _kw(w) -> str:
return _dec(f"{abs(float(w)) / 1000:.1f}") + " kW"
def _eur(v) -> str:
return "€" + _dec(f"{float(v):.2f}")
def _kwh(v) -> str:
return _dec(f"{float(v):.1f}") + " kWh"
# Row id -> the title on Home. The row's own sentence is the body, and it stays
# in English (app/messages.py). Five titles are the notification's own title,
# which test_ui_text.py checks.
_EN = uitext.words("en")["home_words"]
ATTN_TITLES = {(None if k == "none" else k): v for k, v in _EN["attn"].items()}
ATTN_FROM_MESSAGES = {
"meter_frozen": messages.T_METER_FROZEN,
"power_too_high": messages.T_OUTPUT_CLAMP,
"not_keeping_up": messages.T_LOOP_BOUND,
"circulating": messages.T_CIRCULATING,
"delivery": messages.S_NOT_DELIVERING,
}
VERDICT = dict(_EN["verdict"])
MODE_HOUSE, ATTN_SHADOW = _EN["mode_house"], _EN["attn_shadow"]
ZERO_HELD, ZERO_GOING = _EN["zero_held"], _EN["zero_going"]
def mode_house() -> str:
return _t("mode_house")
def attn_shadow() -> str:
return _t("attn_shadow")
def attn_shadow_body() -> str:
return "The add-on decides everything, but every inverter stays at 0 W."
ZERO_HELD, ZERO_GOING = "Held at 0 W", "Going to 0 W"
VERDICT = {
"held": ZERO_HELD,
"stopped": "Stopped",
"cycle": "Monthly cycle today",
"trade": "Trading on price",
"house": "Follows the house",
}
return _t("attn_shadow_body")
def _w(shadow, live, would):
return would if shadow else live
def verdict_words(key, settled=True) -> str:
return _t("verdict." + key) if settled or key != "held" \
else _t("zero_going")
def attn_title(rid) -> str:
table = _hw()["home_words"]["attn"]
return table.get(rid or "none", table["none"])
def month_name(local) -> str:
return _hw()["home_words"]["months"][local.month - 1]
def _w(shadow, key, **values) -> str:
"""`key`, or its `key_would` form in shadow mode."""
return _t(key + "_would" if shadow else key, **values)
def pill(kind) -> str:
return {"running": "Running", "shadow": "Shadow mode",
"stopped": "Stopped", "alarm": "Needs you now",
"warning": "Running, with a warning"}[kind]
return _t("pill." + kind)
def banner_sub(kind) -> str:
return {
"running": "The batteries follow the plan below.",
"shadow": "The plans below say what the add-on would do.",
"stopped": "Start control below when you are ready.",
"alarm": "See Needs attention below for what to do.",
"warning": "See Needs attention below.",
}[kind]
return _t("banner_sub." + kind)
def tile_solar(pv_w, has_solar) -> str:
if not has_solar:
return "Not set up"
return _t("tile.not_set_up")
if pv_w is None:
return "No reading"
return "Producing" if pv_w > IDLE_W else "Not producing"
return _t("tile.no_reading")
return _t("tile.producing") if pv_w > IDLE_W else _t("tile.not_producing")
def tile_house(house_w, has_solar, disagree=False) -> str:
if not has_solar:
return "Needs a solar reading"
return _t("tile.needs_solar")
if disagree:
return "Readings do not add up"
return "No reading" if house_w is None else "Using"
return _t("tile.not_adding_up")
return _t("tile.no_reading") if house_w is None else _t("tile.using")
def tile_grid(grid_w) -> str:
if grid_w is None:
return "No reading"
return _t("tile.no_reading")
if grid_w > IDLE_W:
return "Taking from the grid"
return _t("tile.taking")
if grid_w < -IDLE_W:
return "Feeding in"
return "Balanced"
return _t("tile.feeding")
return _t("tile.balanced")
def _zero(settled) -> str:
return ZERO_HELD if settled else ZERO_GOING
return _t("zero_held") if settled else _t("zero_going")
def tile_batt(batt_w, shadow, settled=True) -> str:
if shadow:
return _zero(settled)
return {"giving": "Giving power", "charging": "Charging",
"resting": "Resting", None: "No reading"}[flow(batt_w)]
return _t("tile." + {"giving": "giving", "charging": "charging",
"resting": "resting",
None: "no_reading"}[flow(batt_w)])
def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
would_w=None, settled=True) -> str:
if soc is None or batt_w is None:
return "No reading from this battery."
return _t("now.no_reading")
zero = _zero(settled)
if shadow:
if would_w is None or abs(would_w) <= IDLE_W:
return f"{zero}. Would rest now."
verb = "give" if would_w > 0 else "charge"
return f"{zero}. Would {verb} {_kw(would_w)} now."
return _t("now.would_rest", zero=zero)
verb = "would_give" if would_w > 0 else "would_charge"
return _t("now." + verb, zero=zero, kw=_kw(would_w))
if held:
return f"{zero} until the alarm clears."
return _t("now.held", zero=zero)
if not running:
return f"{zero} while control is stopped."
return _t("now.stopped", zero=zero)
if phase != M_IDLE:
return {M_DRAIN: "Monthly cycle: emptying.",
M_CHARGE: "Monthly cycle: refilling.",
M_HOLD: "Monthly cycle: holding full."}.get(
phase, "Monthly cycle under way.")
return _t("now." + {M_DRAIN: "cycle_drain", M_CHARGE: "cycle_charge",
M_HOLD: "cycle_hold"}.get(phase, "cycle"))
st = flow(batt_w)
if st == "giving":
return f"Giving {_kw(batt_w)} now."
return _t("now.giving", kw=_kw(batt_w))
if st == "charging":
return f"Charging {_kw(batt_w)} now."
return "Resting now."
return _t("now.charging", kw=_kw(batt_w))
return _t("now.resting")
def plan_head(key, shadow, settled=True) -> str:
if key == "held":
return ("Every inverter is at 0 W until the alarm clears." if settled
else "Every inverter goes to 0 W until the alarm clears.")
return _t("head.held" if settled else "head.held_going")
if key == "stopped":
return "Control is stopped, so nothing is planned."
return _t("head.stopped")
if not shadow:
return {
"cycle": "A battery runs its monthly cycle today.",
"trade": "Charges in the cheapest hour, gives power in the dearest.",
"house": "Charges from spare solar and covers the house.",
}[key]
return "Shadow mode: this is what the add-on would do. " + {
"cycle": "A battery would run its monthly cycle today.",
"trade": "Would charge when cheapest and give power when dearest.",
"house": "Would charge from spare solar and cover the house.",
}[key]
return _t("head." + key)
return _t("head.shadow") + " " + _t("head." + key + "_would")
def run_grid_what(kwh, shadow) -> str:
return _w(shadow, "Charges", "Would charge") + \
f" up to {_kwh(kwh)} from the grid."
return _w(shadow, "run.grid_what", kwh=_kwh(kwh))
# The trade legs and the price line use the feed's own points (a 15-minute
# period on a quarter-hourly feed), so both name the same price and time.
def run_grid_why(price) -> str:
return f"Lowest published price, {_eur(price)} per kWh."
return _t("run.grid_why", eur=_eur(price))
def run_grid_amount(eur) -> str:
return f"Costs up to {_eur(eur)}."
return _t("run.grid_amount", eur=_eur(eur))
def run_give_what(kwh, shadow) -> str:
return _w(shadow, "Gives", "Would give") + \
f" up to {_kwh(kwh)}, to the house first."
return _w(shadow, "run.give_what", kwh=_kwh(kwh))
def run_give_why(price) -> str:
return f"Highest published price, {_eur(price)} per kWh."
return _t("run.give_why", eur=_eur(price))
def run_give_amount(eur) -> str:
return f"Worth up to {_eur(eur)} at that price."
return _t("run.give_amount", eur=_eur(eur))
def run_cycle_what(phase, shadow) -> str:
if phase == M_IDLE:
return _w(shadow, "Starts", "Would start") + \
" the monthly battery cycle."
return _w(shadow, "Runs", "Would run") + " the monthly battery cycle now."
return _w(shadow, "run.cycle_start" if phase == M_IDLE else "run.cycle_now")
def run_cycle_why(shadow=False) -> str:
return _w(shadow, "Empties, refills and holds full",
"Would empty, refill and hold full") + \
", so the level reads right."
return _w(shadow, "run.cycle_why")
def run_cycle_amount(hours) -> str:
if hours is None:
return "How long depends on the battery."
return f"Takes about {max(1, round(hours))} hours."
return _t("run.cycle_amount_unknown")
return _t("run.cycle_amount", hours=max(1, round(hours)))
def run_peak_what(limit_w, shadow) -> str:
return _w(shadow, "Gives", "Would give") + \
f" power to stay under {_kw(limit_w)}."
return _w(shadow, "run.peak_what", kw=_kw(limit_w))
def run_peak_why() -> str:
return "The peak limit comes before everything else."
return _t("run.peak_why")
def run_house_what(shadow) -> str:
return _w(shadow, "Follows", "Would follow") + " the house."
return _w(shadow, "run.house_what")
def run_house_why(shadow=False) -> str:
return _w(shadow, "Charges from spare solar, gives power",
"Would charge from spare solar, give power") + \
" when the house needs it."
return _w(shadow, "run.house_why")
def run_house_amount() -> str:
return "Amounts depend on the sun and the house."
return _t("run.house_amount")
def line_no_trade(key) -> str:
return {
"off": "",
"not_covered": "No trading on price: nothing measures the 15-minute "
"peak.",
"no_prices": "No trading on price yet: no prices have arrived.",
"peak": "No trading on price now: the peak limit comes first.",
"not_firm": "No trading on price yet: the prices are not published.",
"not_worth": "No trading on price today. The price gap is too small.",
"no_level": "No trading on price now: a battery level is unknown.",
}[key]
return "" if key == "off" else _t("no_trade." + key)
def line_trade_tomorrow(kind, at, price, shadow) -> str:
verb = {"grid": _w(shadow, "Charges", "Would charge"),
"give": _w(shadow, "Gives power", "Would give power")}[kind]
return f"Tomorrow: {verb.lower()} at {at}, {_eur(price)} per kWh."
return _w(shadow, "tomorrow_trade." + kind, at=at, eur=_eur(price))
def line_cycle_waits(name) -> str:
return f"{name} waits its turn for the monthly cycle."
return _t("cycle_waits", name=name)
def line_peak_limit(limit_w, shadow) -> str:
return _w(shadow, "The batteries step in", "The batteries would step in") \
+ f" above {_kw(limit_w)}."
return _w(shadow, "step_in", kw=_kw(limit_w))
def line_prices(now_eur, lo, hi) -> str:
parts = []
if now_eur is not None:
parts.append(f"Price now {_eur(now_eur)} per kWh, all in.")
parts.append(_t("prices.now", eur=_eur(now_eur)))
if lo is not None and hi is not None:
parts.append(f"Today: lowest {_eur(lo['eur_kwh'])} at "
f"{lo['label']}, highest {_eur(hi['eur_kwh'])} at "
f"{hi['label']}.")
return " ".join(parts) or "No prices for today."
parts.append(_t("prices.today", lo=_eur(lo["eur_kwh"]),
lo_at=lo["label"], hi=_eur(hi["eur_kwh"]),
hi_at=hi["label"]))
return " ".join(parts) or _t("prices.none")
def line_tomorrow(known) -> str:
return ("Tomorrow's prices are in." if known else
"Tomorrow's prices usually arrive after 13:00.")
return _t("tomorrow.known" if known else "tomorrow.unknown")
def line_region(rid, year) -> str:
if rid in REGION_PRESETS and year:
return f"Levies and VAT: {REGION_WORDS.get(rid, rid)}, {year} figures."
return "Levies and VAT: your own figure."
REGION_WORDS = {"flanders": "Flanders, Fluvius Antwerpen"}
name = _hw()["choices"]["region"].get(rid, rid)
return _t("region.preset", region=name, year=year)
return _t("region.own")
def line_peak_not_covered() -> str:
return "No peak limit: this meter does not measure 15-minute periods."
return _t("peak_not_covered")
def line_period(period_w, limit_w, shadow) -> str:
step = _w(shadow, "The batteries step in", "The batteries would step in")
step = _w(shadow, "step_in", kw=_kw(limit_w))
if period_w is None:
return f"{step} above {_kw(limit_w)}."
return (f"This 15-minute period is heading for {_kw(period_w)}. "
f"{step} above {_kw(limit_w)}.")
return step
return _t("period", kw=_kw(period_w)) + " " + step
def control_text(running, shadow) -> str:
if shadow:
return "Shadow mode: every inverter stays at 0 W."
return _t("control.shadow")
if running:
return "Control is running."
return "Control is stopped. Every inverter is at 0 W."
return _t("control.running")
return _t("control.stopped")
def text_samples() -> list:
"""`(kind, text)` for every sentence above, rendered with real values, so
test_messages.py can hold each to its STYLE limit. Kinds: title, plan
(2 sentences of 12 words), line (1 sentence of 25 words)."""
out = [("title", t) for t in VERDICT.values()] + [("title", ZERO_GOING)]
out += [("title", t) for t in ATTN_TITLES.values()]
out += [("title", MODE_HOUSE), ("title", ATTN_SHADOW),
def text_samples(lang="en") -> list:
"""`(kind, text)` for every sentence above in `lang`, rendered with real
values, so test_messages.py can hold each to its STYLE limit. Kinds: title,
plan (2 sentences of 12 words), line (1 sentence of 25 words)."""
_LANG[0] = uitext.lang_of(lang)
try:
return _samples()
finally:
_LANG[0] = "en"
def _samples() -> list:
words = _hw()["home_words"]
out = [("title", t) for t in words["verdict"].values()]
out += [("title", _t("zero_going")), ("title", _t("zero_held"))]
out += [("title", t) for t in words["attn"].values()]
out += [("title", mode_house()), ("title", attn_shadow()),
("line", attn_shadow_body())]
for k in ("running", "shadow", "stopped", "alarm", "warning"):
out += [("title", pill(k)), ("line", banner_sub(k))]