"""UX-13 P1: the Home screen's part of `/status`, built from what the add-on already knows and already plans. Reads only; commands nothing. `build(controller)` returns `now`, `batteries`, `plan`, `attention`, `month_peak`, `control` and `state`. The old fields (`banner`, `rows`, `checks`, ...) stay beside them for the installer details and for anything that reads the JSON. See DECISIONS/UX-13-one-layout-table.md and the design note, section 3.3. The plan shows only what the add-on does today: trading on price (the arbitrage verdict, the same call the control cycle makes), the monthly battery cycle, the peak limit and "follows the house". Battery modes (OPT-11) are not built, so nothing here invents a mode plan. Every sentence is in the words 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, 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 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 # --------------------------------------------------------------------------- # # Time: today's local day, on the billing calendar # --------------------------------------------------------------------------- # def local_day(now: datetime): """`(midnight_utc, next_midnight_utc)` of the local day holding `now`.""" here = now.astimezone(BILLING_TZ) start = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ) nxt = start.date() + timedelta(days=1) end = datetime(nxt.year, nxt.month, nxt.day, tzinfo=BILLING_TZ) return start.astimezone(timezone.utc), end.astimezone(timezone.utc) def _hours(t: datetime, day0: datetime) -> float: return (t - day0).total_seconds() / 3600.0 def hhmm(t: datetime) -> str: return f"{brussels(t):%H:%M}" # --------------------------------------------------------------------------- # # Now # --------------------------------------------------------------------------- # def flow(w, *, shadow=False): """`charging` / `giving` / `resting` / None for one battery power.""" if w is None: return None if shadow: return "resting" if w > IDLE_W: return "giving" if w < -IDLE_W: return "charging" return "resting" def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow, settled=True): """The four tiles. `house_w` needs all three readings, so without a solar reading it is None rather than a number that leaves the sun out.""" house, disagree = None, False if has_solar and None not in (grid_w, pv_w, batt_w): house = float(grid_w) + float(pv_w) + float(batt_w) # The three are read at slightly different moments, so the sum can dip # under zero. Inside the idle band that is 0 W; beyond it the readings # disagree and no house figure is shown. if house < -IDLE_W: house, disagree = None, True elif house < 0.0: house = 0.0 return { "solar_w": None if pv_w is None else float(pv_w), "house_w": house, "grid_w": None if grid_w is None else float(grid_w), "batt_w": None if batt_w is None else float(batt_w), "has_solar": bool(has_solar), "lines": { "solar": tile_solar(pv_w, has_solar), "house": tile_house(house, has_solar, disagree), "grid": tile_grid(grid_w), "batt": tile_batt(batt_w, shadow, settled), }, } # --------------------------------------------------------------------------- # # Prices for today's chart # --------------------------------------------------------------------------- # def _points(plan): if plan is None: return () return plan.read.match(series=lambda r: tuple(r.points), no_data=lambda _r: ()) def _spans(points): """`[(point, end)]`: each point runs until the next one starts, and the last one for the series' own spacing (15 minutes or an hour).""" pts = sorted(points, key=lambda p: p.start) gaps = [b.start - a.start for a, b in zip(pts, pts[1:])] step = min([g for g in gaps if g > timedelta(0)] or [timedelta(hours=1)]) step = min(step, timedelta(hours=1)) out = [] for i, p in enumerate(pts): nxt = pts[i + 1].start if i + 1 < len(pts) else None end = nxt if nxt is not None and nxt - p.start <= timedelta(hours=1) \ else p.start + step out.append((p, end)) return out def _day_prices(c): cache = getattr(c, "home_prices", None) if cache is None: cache = c.home_prices = DayPrices() return cache class DayPrices: """Today's prices as seen so far. The refresh keeps only the hours ahead, so the hours already past are remembered here for the chart. Display only: nothing reads this back into a decision.""" def __init__(self): self.day = None self.points = {} def see(self, points, day0, day1): if self.day != day0: self.day, self.points = 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))) 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) 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" return bars 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.""" firm = [p for p in points if not p.provisional] if not firm: return None, None lo = min(firm, key=lambda p: p.eur_per_kwh) hi = max(firm, key=lambda p: p.eur_per_kwh) return ({"eur_kwh": round(lo.eur_per_kwh, 4), "label": hhmm(lo.start)}, {"eur_kwh": round(hi.eur_per_kwh, 4), "label": hhmm(hi.start)}) # --------------------------------------------------------------------------- # # The plan, per battery # --------------------------------------------------------------------------- # def _run(i, kind, start, end, day0, day1, what, why, amount, *, kwh=None, eur=None, price=None): a = max(0.0, _hours(start, day0)) z = min(_hours(day1, day0), _hours(end, day0)) return {"battery": i, "kind": kind, "start": start.isoformat(), "end": end.isoformat(), "from": hhmm(start), "to": "24:00" if end == day1 else hhmm(end), "start_h": round(a, 3), "end_h": round(z, 3), "what": what, "why": why, "amount": amount, "kwh": None if kwh is None else round(kwh, 2), "eur": None if eur is None else round(eur, 2), "eur_kwh": None if price is None else round(price, 4)} def trade_verdict(c, plan): """The arbitrage verdict the control cycle would reach on this plan, or a reason there is none. The SAME call and inputs as `Controller.cycle()`: `plan.arbitrage()` with the peak guard's state, the cycle cost and the no-weather uncertainty. Returns `(verdict_or_None, why_key)`.""" if not arbitrage.enabled(c.o): return None, "off" guard = arbitrage.guard_state(c.peak_guard) if guard is None: return None, "not_covered" if plan is None: return None, "no_prices" verdict = plan.arbitrage(peak_guard_active=guard, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o), uncertainty=frozenset({Uncertainty.NO_WEATHER})) return verdict.match( allowed=lambda v: (v, "allowed"), refused=lambda _v: (None, refusal_key(plan, guard))) def refusal_key(plan, guard): """Which of `TariffPlan.arbitrage()`'s refusals fired, in its own order: the peak guard, no prices, under two published points, then the gap.""" if guard: return "peak" if not plan.has_prices: return "no_prices" if sum(1 for p in _points(plan) if not p.provisional) < 2: return "not_firm" return "not_worth" def _point_end(plan, point): for p, end in _spans(_points(plan)): if p.start == point.start: return end return point.start + timedelta(hours=1) # Under this many kWh a block is not drawn: the cycle would not move it. MIN_KWH = 0.05 def trade_runs(c, plan, verdict, day0, day1, now, shadow): """A charge block and a give block on each battery, where the verdict put them. The site trades as one; each battery's share is capped by its own rating and by the room its level leaves (arbitrage's own floor/ceiling). Only what `arbitrage.decide()` would still do is drawn: no block on a battery with no room, no sale at a negative price, and no sale while the emptiest battery is at the floor (the cycle's `soc` is the emptiest). With a level unknown the cycle does not trade, so nothing is drawn.""" floor, ceiling = arbitrage.soc_bounds(c.o) if any(inv.soc is None for inv in c.inverters): return [], [], "no_level" level = [float(inv.soc) for inv in c.inverters] legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)), key=lambda kp: kp[1].start) runs, later = [], [] for kind, pt in legs: end = _point_end(plan, pt) if end <= now: continue if not (day0 <= pt.start < day1): later.append((kind, pt)) continue if kind == "give" and (pt.eur_per_kwh < 0 or min(level) <= floor): continue hrs = (end - pt.start).total_seconds() / 3600.0 for i, inv in enumerate(c.inverters): kwh = inv.max_w / 1000.0 * hrs cap = inv.capacity_kwh if cap: room = (ceiling - level[i] if kind == "grid" else level[i] - floor) kwh = min(kwh, max(0.0, room) / 100.0 * cap) # A later give may use what this charge put in: an upper # bound, like every amount here ("up to"). level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0 if kwh < MIN_KWH: continue eur = kwh * pt.eur_per_kwh if kind == "grid": what, why, amount = (run_grid_what(kwh, shadow), run_grid_why(pt.eur_per_kwh), run_grid_amount(eur)) else: what, why, amount = (run_give_what(kwh, shadow), run_give_why(pt.eur_per_kwh), run_give_amount(eur)) runs.append(_run(i, kind, pt.start, end, day0, day1, what, why, amount, kwh=kwh, eur=eur, price=pt.eur_per_kwh)) return runs, later, None def maintenance_runs(c, day0, day1, now, shadow): """The monthly battery cycle: under way now, or starting later today. The cycle starts in the hour `maintenance_start_hour` names, read as the control cycle reads it (UTC hour of `now`), shown in local time.""" runs, lines = [], [] waiting = [] for i, (inv, m) in enumerate(zip(c.inverters, c.maints)): cfg = m.cfg if not cfg.enabled: continue cap = inv.capacity_kwh if m.phase != M_IDLE: hours = _cycle_hours(m.phase, inv.soc, cap, cfg) end = now + timedelta(hours=hours or 1.0) runs.append(_run(i, "cycle", now, end, day0, day1, run_cycle_what(m.phase, shadow), run_cycle_why(shadow), run_cycle_amount(hours))) continue if not m.due(now) and (m.next_due(now) is None or m.next_due(now) >= day1): continue start = now.replace(hour=cfg.start_hour, minute=0, second=0, microsecond=0) if start + timedelta(hours=1) <= now: start += timedelta(days=1) due_at = m.next_due(now) if due_at is not None and due_at > start: continue if not (day0 <= start < day1): continue waiting.append((m.waited_days(now), i, start)) # One battery cycles at a time (maintenance.arbitrate); the one waiting # longest goes first, and none starts while another is under way. busy = any(m.phase != M_IDLE for m in c.maints) waiting.sort(key=lambda t: (-t[0], t[1])) for n, (_w, i, start) in enumerate(waiting): inv, m = c.inverters[i], c.maints[i] hours = _cycle_hours(M_DRAIN, inv.soc, inv.capacity_kwh, m.cfg) if n == 0 and not busy: end = start + timedelta(hours=hours or 1.0) runs.append(_run(i, "cycle", start, end, day0, day1, run_cycle_what(M_IDLE, shadow), run_cycle_why(shadow), run_cycle_amount(hours))) else: lines.append(line_cycle_waits(inv.name)) return runs, lines def _cycle_hours(phase, soc, cap_kwh, cfg): """Hours left in a cycle, or None when the capacity or level is unknown.""" if phase == M_HOLD: return cfg.hold_min / 60.0 if not cap_kwh or soc is None: return None hours = cfg.hold_min / 60.0 fill_from = soc if phase == M_CHARGE else cfg.soc_floor hours += (max(0.0, cfg.soc_target - fill_from) / 100.0 * cap_kwh / max(cfg.charge_w / 1000.0, 0.1)) if phase not in (M_CHARGE,): hours += (max(0.0, soc - cfg.soc_floor) / 100.0 * cap_kwh / max(cfg.discharge_w / 1000.0, 0.1)) return hours def peak_run(c, day0, day1, now, shadow, limit_w): """While the batteries hold the grid under the peak limit, one block for the current 15-minute period on every battery.""" pg = c.peak_guard if pg is None or not pg.engaged: return [] start = now - timedelta(minutes=now.minute % 15, seconds=now.second, microseconds=now.microsecond) end = start + timedelta(minutes=15) return [_run(i, "peak", start, end, day0, day1, run_peak_what(pg.ceiling_w or limit_w, shadow), run_peak_why(), None) for i in range(len(c.inverters))] def house_runs(n, day0, day1, now, shadow): """"Follows the house" for the rest of the day, drawn under the others.""" return [_run(i, "house", max(now, day0), day1, day0, day1, run_house_what(shadow), run_house_why(shadow), run_house_amount()) for i in range(n)] def region_of(opts): """The levies preset and its year, as the page model has it (UX-13 P0).""" try: ui = options_to_ui(opts) except Exception: # noqa: BLE001 return {"id": None, "year": None} rid = ui.get("ui.region") return {"id": rid, "year": ui.get("region_year") if rid in REGION_PRESETS else None} def build_plan(c, now, shadow, running, held=False, settled=True): day0, day1 = local_day(now) fresh = c.fresh_tariff_plan(now) plan = fresh if plan is None or plan.origin == "static": # A static profile is pure arithmetic: price the whole day from it. try: static = static_plan(c.o, day0, day1, now=now) if static.has_prices: plan = static except Exception: # noqa: BLE001 pass seen = _day_prices(c).see(_points(plan), day0, day1) bars = hour_bars(seen, 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) if running: runs += house_runs(len(c.inverters), day0, day1, now, shadow) if verdict is not None: trade, later, no_trade = trade_runs(c, fresh, verdict, day0, day1, now, shadow) runs += trade if no_trade: lines.append(line_no_trade(no_trade)) for kind, pt in later: lines.append(line_trade_tomorrow(kind, hhmm(pt.start), pt.eur_per_kwh, shadow)) else: lines.append(line_no_trade(trade_why)) cyc, waits = maintenance_runs(c, day0, day1, now, shadow) runs += cyc lines += waits runs += peak_run(c, day0, day1, now, shadow, limit_w) if c.p1_enabled: # The guard's ceiling: the cap, or this month's peak once above it. pg = c.peak_guard lines.append(line_peak_limit( (pg.ceiling_w if pg is not None else None) or limit_w, shadow)) lines = [x for x in lines if x] kinds = {r["kind"] for r in runs} if held: verdict_key = "held" elif not running: verdict_key = "stopped" elif "cycle" in kinds: verdict_key = "cycle" elif "grid" in kinds or "give" in kinds: verdict_key = "trade" else: verdict_key = "house" known = [b for b in bars if b["eur_kwh"] is not None] price_now = None try: if plan is not None: price_now = plan.price_at(now) except Absence: price_now = None feed = fresh is not None and fresh.from_feed tomorrow = any(not p.provisional and p.start >= day1 for p in _points(fresh)) region = region_of(c.o) return { "day": brussels(now).date().isoformat(), "now_h": round(_hours(now, day0), 3), "hours": int(round(_hours(day1, day0))), "verdict": verdict_words(verdict_key, settled), "verdict_key": verdict_key, "head": plan_head(verdict_key, shadow, settled), "prices": bars, "runs": runs, "lines": lines, "why": c.why_words, "price_line": line_prices(price_now, *day_range(seen)), "tomorrow_line": line_tomorrow(tomorrow) if feed else "", "region": region, "region_line": (line_region(region["id"], region["year"]) if feed else ""), "has_prices": bool(known), } # --------------------------------------------------------------------------- # # Batteries, attention, month peak, control # --------------------------------------------------------------------------- # def at_zero(inv) -> bool: """Has this inverter accepted a 0 W power request? A stop or an alarm asks for 0 W at once, but the inverter gets it with the next write, so until then the page says "Going to 0 W" (UX-13 P1 test, a). Before the first accepted write nothing is known to be held; the heartbeat writes every `heartbeat_s`, stopped or not, so this does not stick.""" return inv.last_write is not None and abs(inv.last_write) < 1.0 def battery_rows(c, shadow, running, held=False): shares = {} alloc = getattr(c, "allocation", None) if shadow and alloc is not None and getattr(alloc, "shares", None): shares = dict(alloc.shares) rows = [] for i, inv in enumerate(c.inverters): m = c.maints[i] if i < len(c.maints) else None phase = m.phase if m is not None else M_IDLE cap = inv.capacity_kwh rows.append({ "name": inv.name, "level_pct": None if inv.soc is None else float(inv.soc), "power_w": None if inv.batt is None else float(inv.batt), "capacity_kwh": cap, "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(), "maintenance": phase, "now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow, running=running, held=held, would_w=shares.get(inv.name), settled=at_zero(inv)), # Per-battery reasons arrive with battery modes (OPT-11). "why": None, }) return rows # Row id -> the DOCS.md heading under Notifications, where one exists. ATTN_DOCS = { "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, } def attention(checks): """Every row that is not plain green: red rows as alarms, amber rows as warnings, and green-but-amber rows (a state still settling) as info. Most urgent first; within a level, the page's own row order.""" rank = {ALARM: 0, WARNING: 1, INFO: 2} out = [] for row in checks: ok, warn = bool(row.get("ok")), bool(row.get("warn")) if ok and not warn: continue sev = INFO if ok else (WARNING if warn else ALARM) rid = row.get("id") out.append({"severity": sev, "id": rid, "title": attn_title(rid), "body": row.get("text", ""), "docs": ATTN_DOCS.get(rid)}) out.sort(key=lambda a: rank[a["severity"]]) return out 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": month_name(local), "limit_w": limit_w, "peak_w": None, "pct": None, "period_w": None, "state": "not_covered", "line": ""} if not c.p1_enabled: out["line"] = line_peak_not_covered() return out reading = c.peak.peak(now) if reading.is_unreadable: out["state"] = "unreadable" elif reading.is_observed: out["state"] = "observed" out["peak_w"] = float(reading.peak_w) out["pct"] = round(100.0 * float(reading.peak_w) / limit_w, 1) \ if limit_w > 0 else None else: out["state"] = "none" pg = c.peak_guard ceiling = limit_w if pg is not None: out["period_w"] = pg.projected_w ceiling = pg.ceiling_w or limit_w out["line"] = line_period(out["period_w"], ceiling, c.shadow) return out def state_of(c, level, shadow): """One word for the pill and the banner's colour: running, shadow, stopped, alarm or warning.""" if shadow: return "shadow" if level == "bad": return "alarm" if not c.auto: return "stopped" if level == "warn": return "warning" return "running" 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) # A red banner means every inverter is at 0 W (the status cascade), so # nothing is planned until it clears. Shadow mode still plans: it is # what the add-on would do. held = level == "bad" and not shadow running = (bool(c.auto) or shadow) and not held settled = all(at_zero(inv) for inv in c.inverters) ctl = next((r for r in checks if r.get("id") == "controller"), None) return { "state": {"kind": kind, "pill": pill(kind), "ran": ctl.get("text", "") if ctl else "", "sub": banner_sub(kind)}, "now": now_block(grid_w=c.grid, pv_w=c.pv_w, batt_w=c.site_batt_w, has_solar=bool(c.pv_roster), shadow=shadow, settled=settled), "batteries": battery_rows(c, shadow, running, held), "plan": build_plan(c, now, shadow, running, held=held, settled=settled), "attention": ([{"severity": INFO, "id": "shadow", "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, "text": control_text(bool(c.auto), shadow), "maintenance": c.maint.phase, "docs_url": _docs_url()}, } def _docs_url(): slug = messages.addon_slug() return None if slug is None else messages.docs_url(slug, messages._site["ha"]) # =========================================================================== # # 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. The sentences themselves are in # app/ui_text/.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. # =========================================================================== # _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 _t("attn_shadow_body") 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 _t("pill." + kind) def banner_sub(kind) -> str: return _t("banner_sub." + kind) def tile_solar(pv_w, has_solar) -> str: if not has_solar: return _t("tile.not_set_up") if pv_w is None: 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 _t("tile.needs_solar") if disagree: 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 _t("tile.no_reading") if grid_w > IDLE_W: return _t("tile.taking") if grid_w < -IDLE_W: return _t("tile.feeding") return _t("tile.balanced") def _zero(settled) -> str: 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 _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 _t("now.no_reading") zero = _zero(settled) if shadow: if would_w is None or abs(would_w) <= IDLE_W: 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 _t("now.held", zero=zero) if not running: return _t("now.stopped", zero=zero) if phase != M_IDLE: 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 _t("now.giving", kw=_kw(batt_w)) if st == "charging": return _t("now.charging", kw=_kw(batt_w)) return _t("now.resting") def plan_head(key, shadow, settled=True) -> str: if key == "held": return _t("head.held" if settled else "head.held_going") if key == "stopped": return _t("head.stopped") if not shadow: return _t("head." + key) return _t("head.shadow") + " " + _t("head." + key + "_would") def run_grid_what(kwh, shadow) -> str: 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 _t("run.grid_why", eur=_eur(price)) def run_grid_amount(eur) -> str: return _t("run.grid_amount", eur=_eur(eur)) def run_give_what(kwh, shadow) -> str: return _w(shadow, "run.give_what", kwh=_kwh(kwh)) def run_give_why(price) -> str: return _t("run.give_why", eur=_eur(price)) def run_give_amount(eur) -> str: return _t("run.give_amount", eur=_eur(eur)) def run_cycle_what(phase, shadow) -> str: return _w(shadow, "run.cycle_start" if phase == M_IDLE else "run.cycle_now") def run_cycle_why(shadow=False) -> str: return _w(shadow, "run.cycle_why") def run_cycle_amount(hours) -> str: if hours is None: 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, "run.peak_what", kw=_kw(limit_w)) def run_peak_why() -> str: return _t("run.peak_why") def run_house_what(shadow) -> str: return _w(shadow, "run.house_what") def run_house_why(shadow=False) -> str: return _w(shadow, "run.house_why") def run_house_amount() -> str: return _t("run.house_amount") def line_no_trade(key) -> str: return "" if key == "off" else _t("no_trade." + key) def line_trade_tomorrow(kind, at, price, shadow) -> str: return _w(shadow, "tomorrow_trade." + kind, at=at, eur=_eur(price)) def line_cycle_waits(name) -> str: return _t("cycle_waits", name=name) def line_peak_limit(limit_w, shadow) -> str: 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(_t("prices.now", eur=_eur(now_eur))) if lo is not None and hi is not None: 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 _t("tomorrow.known" if known else "tomorrow.unknown") def line_region(rid, year) -> str: if rid in REGION_PRESETS and year: 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 _t("peak_not_covered") def line_period(period_w, limit_w, shadow) -> str: step = _w(shadow, "step_in", kw=_kw(limit_w)) if period_w is None: return step return _t("period", kw=_kw(period_w)) + " " + step def control_text(running, shadow) -> str: if shadow: return _t("control.shadow") if running: return _t("control.running") return _t("control.stopped") 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))] for sh in (False, True): out += [("title", tile_solar(v, s)) for v, s in ((None, False), (None, True), (3900, True), (0, True))] out += [("title", tile_house(v, s)) for v, s in ((None, False), (None, True), (1100, True))] out.append(("title", tile_house(None, True, True))) out += [("title", tile_grid(v)) for v in (None, 1200, -800, 5)] out += [("title", tile_batt(v, sh)) for v in (None, 2500, -2500, 0)] out.append(("title", tile_batt(0, sh, settled=False))) for phase in (M_IDLE, M_DRAIN, M_CHARGE, M_HOLD): for w in (2500, -2500, 0): out.append(("plan", battery_now(62, w, phase=phase, shadow=sh, running=True, would_w=-2400))) out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=sh, running=False))) for settled in (True, False): out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=False, running=False, held=True, settled=settled))) out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=sh, running=False, would_w=-2400, settled=settled))) out.append(("plan", plan_head("held", sh, settled=False))) out.append(("plan", battery_now(None, None, phase=M_IDLE, shadow=sh, running=True))) for k in VERDICT: out.append(("plan", plan_head(k, sh))) out += [("plan", run_grid_what(12.34, sh)), ("plan", run_give_what(9.6, sh)), ("plan", run_cycle_what(M_IDLE, sh)), ("plan", run_cycle_what(M_DRAIN, sh)), ("plan", run_peak_what(3500, sh)), ("plan", run_house_what(sh)), ("plan", line_trade_tomorrow("grid", "02:00", 0.1234, sh)), ("plan", line_trade_tomorrow("give", "19:00", 0.3612, sh)), ("plan", line_peak_limit(3500, sh)), ("plan", line_period(1240, 3500, sh)), ("plan", line_period(None, 3500, sh)), ("plan", control_text(True, sh)), ("plan", control_text(False, sh))] out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)), ("plan", run_give_why(0.3612)), ("plan", run_give_amount(3.47)), ("plan", run_cycle_why()), ("plan", run_cycle_why(True)), ("plan", run_cycle_amount(None)), ("plan", run_cycle_amount(5.4)), ("plan", run_peak_why()), ("plan", run_house_why()), ("plan", run_house_why(True)), ("plan", run_house_amount()), ("plan", line_cycle_waits("Battery B")), ("plan", line_prices(0.16, {"eur_kwh": 0.14, "label": "13:00"}, {"eur_kwh": 0.36, "label": "19:00"})), ("plan", line_prices(None, None, None)), ("plan", line_tomorrow(True)), ("plan", line_tomorrow(False)), ("plan", line_region("flanders", 2026)), ("plan", line_region("own", None)), ("plan", line_peak_not_covered())] out += [("plan", line_no_trade(k)) for k in ("not_covered", "no_prices", "peak", "not_firm", "not_worth", "no_level")] return out