UX-13 P3: Simple view and Batteries screen
checks / unit (pull_request) Successful in 11m9s
infra-pin / fresh (pull_request) Successful in 2s

- Batteries: one card per battery with On/Off, the mode list (cheap hours
  and selling greyed out until OPT-11 P3), level, and Now / Today / Why
  lines built in homeview.py. Replaces the OPT-11 P1 minimal cards and uses
  the existing pack_use / pack_mode actions and store keys.
- An Off battery the peak limit still needs says so with its figure; no line
  shows 0 W beside moving power (OPT-11 P1 test F2). Today says "Not planned
  yet" until OPT-11 P3/P4.
- Home's plan leaves Off batteries out, except for the peak limit.
- Settings opens on Simple: the layout's simple fields with an applies chip
  and a note, plus the battery switches and modes. Installer is one tap.
- English and Dutch words in app/ui_text; test_batteries_screen.py; browser
  check tools/ux13_browser_check.py (CHECK=1 ux13_screenshots.sh); new and
  changed screenshots only.

No control, allocator, arbiter or command module changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
This commit is contained in:
Claude
2026-09-28 22:39:43 +02:00
parent ed739d4cdb
commit 2d2b6afb07
26 changed files with 1119 additions and 89 deletions
+151 -16
View File
@@ -20,6 +20,7 @@ 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 .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
from .price import Absence, Uncertainty, brussels
from .static_tariff import static_plan
from .tariff import BILLING_TZ
@@ -284,6 +285,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
continue
hrs = (end - pt.start).total_seconds() / 3600.0
for i, inv in enumerate(c.inverters):
if _off(c, inv):
continue # OPT-11: an Off battery does not trade
kwh = inv.max_w / 1000.0 * hrs
cap = inv.capacity_kwh
if cap:
@@ -317,8 +320,8 @@ def maintenance_runs(c, day0, day1, now, shadow):
waiting = []
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
cfg = m.cfg
if not cfg.enabled:
continue
if not cfg.enabled or _off(c, inv):
continue # OPT-11: no monthly cycle while it is off
cap = inv.capacity_kwh
if m.phase != M_IDLE:
hours = _cycle_hours(m.phase, inv.soc, cap, cfg)
@@ -390,12 +393,13 @@ def peak_run(c, day0, day1, now, shadow, limit_w):
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."""
def house_runs(n, day0, day1, now, shadow, skip=()):
""""Follows the house" for the rest of the day, drawn under the others.
`skip`: the batteries that are switched off (OPT-11)."""
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)]
for i in range(n) if i not in skip]
def region_of(opts):
@@ -429,7 +433,8 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
# 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)
off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)}
runs += house_runs(len(c.inverters), day0, day1, now, shadow, off)
if verdict is not None:
trade, later, no_trade = trade_runs(c, fresh, verdict, day0, day1,
now, shadow)
@@ -507,16 +512,56 @@ def at_zero(inv) -> bool:
return inv.last_write is not None and abs(inv.last_write) < 1.0
def _off(c, inv) -> bool:
"""OPT-11: is this battery switched off? Only with battery modes on."""
return bool(getattr(c, "modes_on", False)) and not c.pack_mode(inv).use
def refusing(c, inv) -> int:
"""SAFETY-52/73: the direction this battery refuses (+1 give, -1 charge,
0 none). Per inverter at two or more, the site's own at one."""
if len(c.inverters) > 1:
return int(getattr(inv, "refused", 0) or 0)
return int(getattr(c, "batt_refused", 0) or 0)
def peak_engaged(c) -> bool:
"""The peak guard is asking the batteries for power this cycle."""
pg = getattr(c, "peak_guard", None)
return bool(pg is not None and pg.engaged)
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)
modes_on = bool(getattr(c, "modes_on", False))
pms = {inv.name: c.pack_mode(inv) for inv in c.inverters} \
if modes_on else {}
peak = peak_engaged(c)
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
pm = pms.get(inv.name)
off = pm is not None and not pm.use
# UX-13 P3: does a Solar first battery that is on come before this one?
sf_ahead = any(o.use and o.mode == SOLAR_FIRST
for n, o in pms.items() if n != inv.name)
# ...and does another battery refuse to give power, so this one
# carries its share (SAFETY-73)?
other = next((o.name for o in c.inverters if o is not inv
and refusing(c, o) > 0 and not _off(c, o)), None)
why = battery_why(
modes_on=modes_on, mode=None if pm is None else pm.mode, off=off,
reading=inv.soc is not None and inv.batt is not None,
phase=phase, shadow=shadow, running=running, held=held,
refused=refusing(c, inv), peak=peak,
moving=flow(inv.batt) in ("giving", "charging"),
giving=flow(inv.batt) == "giving", other_refuses=other,
sf_ahead=sf_ahead)
rows.append({
"name": inv.name,
"level_pct": None if inv.soc is None else float(inv.soc),
@@ -530,21 +575,79 @@ def battery_rows(c, shadow, running, held=False):
"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 the plan (OPT-11 P3/P4).
"why": None,
settled=at_zero(inv), off=off, peak=peak),
# UX-13 P3: why it does what "now" says, from what the add-on
# knows this cycle. Price and forecast reasons are OPT-11 P3/P4.
"why_key": why,
"why": why_words(why, name=other),
})
if getattr(c, "modes_on", False):
if modes_on:
# OPT-11 P1: the battery's switch and mode, for the Batteries
# screen. Only with battery modes on, so the page is unchanged
# without them. UX-13 P3 builds the real screen on these keys.
pm = c.pack_mode(inv)
# without them.
rows[-1].update({"use": pm.use, "mode_key": pm.key,
"mode_choice": pm.mode,
"mode": mode_word(pm.key)})
return rows
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
held, refused, peak, moving, sf_ahead, giving=False,
other_refuses=None) -> str:
"""The key of one battery's "why" line (`home_words.why`), most urgent
first. Pure, so every branch is tested without a controller."""
if not reading:
return "no_reading"
if held:
return "held"
if not running:
return "stopped"
if shadow:
return "shadow"
if phase != M_IDLE:
return "cycle_mode" if modes_on else "cycle"
if off:
# Off gives way to the peak limit only (OPT-11 design section 2).
return "off_peak" if peak and moving else "off"
if refused:
return "refuses_give" if refused > 0 else "refuses_charge"
if peak:
return "peak"
if giving and other_refuses:
return "other_refuses"
if not modes_on:
return "modes_off"
if giving:
return "covers_house" # both modes cover the house until P3
if mode == LEFTOVER_SOLAR:
return "leftover" if sf_ahead else "leftover_alone"
return "solar_first"
def attach_today(rows, runs, *, modes_on, running=True, held=False,
shadow=False) -> None:
"""UX-13 P3: each battery's runs today from the plan (the same blocks as
Home's chart), or one line saying there are none. With battery modes on
and no run, the mode's own plan is OPT-11 P3/P4, so it says so."""
for i, row in enumerate(rows):
mine = [r for r in runs if r["battery"] == i and r["kind"] != "house"]
row["today"] = [{"from": r["from"], "to": r["to"], "kind": r["kind"],
"what": r["what"]} for r in mine]
if mine:
row["today_line"] = ""
elif held:
row["today_line"] = today_words("held")
elif not running:
row["today_line"] = today_words("stopped")
elif row.get("use") is False:
row["today_line"] = today_words("off")
elif modes_on:
row["today_line"] = today_words("not_planned")
else:
row["today_line"] = today_words("house_would" if shadow
else "house")
# Row id -> the DOCS.md heading under Notifications, where one exists.
ATTN_DOCS = {
"meter_frozen": messages.T_METER_FROZEN,
@@ -640,6 +743,11 @@ def _build(c, *, level, checks, now):
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)
rows = battery_rows(c, shadow, running, held)
plan = build_plan(c, now, shadow, running, held=held, settled=settled)
attach_today(rows, plan["runs"],
modes_on=bool(getattr(c, "modes_on", False)),
running=running, held=held, shadow=shadow)
return {
"state": {"kind": kind, "pill": pill(kind),
"ran": ctl.get("text", "") if ctl else "",
@@ -647,9 +755,9 @@ def _build(c, *, level, checks, now):
"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),
"batteries": rows,
"plan": plan,
"modes_on": bool(getattr(c, "modes_on", False)),
"attention": ([{"severity": INFO, "id": "shadow",
"title": attn_shadow(), "body": attn_shadow_body(),
"docs": None}] if shadow else []) + attention(checks),
@@ -811,7 +919,7 @@ def tile_batt(batt_w, shadow, settled=True) -> str:
def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
would_w=None, settled=True) -> str:
would_w=None, settled=True, off=False, peak=False) -> str:
if soc is None or batt_w is None:
return _t("now.no_reading")
zero = _zero(settled)
@@ -828,6 +936,14 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
return _t("now." + {M_DRAIN: "cycle_drain", M_CHARGE: "cycle_charge",
M_HOLD: "cycle_hold"}.get(phase, "cycle"))
st = flow(batt_w)
if off:
# OPT-11: an Off battery is asked for 0 W, but the peak limit may
# still need it. Say which, and never "0 W" beside a power figure.
if st == "resting":
return _t("now.off_resting")
if st == "giving" and peak:
return _t("now.off_peak", kw=_kw(batt_w))
return _t("now.off_going", kw=_kw(batt_w))
if st == "giving":
return _t("now.giving", kw=_kw(batt_w))
if st == "charging":
@@ -835,6 +951,17 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
return _t("now.resting")
def why_words(key, name=None) -> str:
"""One battery's "why" line (UX-13 P3), from `battery_why()`'s key.
`name`: the battery that refuses, for `other_refuses`."""
return _t("why." + key, name=name or "")
def today_words(key) -> str:
"""One battery's "today" line when the plan has no run for it."""
return _t("today." + key)
def plan_head(key, shadow, settled=True) -> str:
if key == "held":
return _t("head.held" if settled else "head.held_going")
@@ -1039,6 +1166,14 @@ def _samples() -> list:
("plan", line_region("flanders", 2026)),
("plan", line_region("own", None)),
("plan", line_peak_not_covered())]
for off in (False, True):
for peak in (False, True):
for w in (2500, -2500, 0, 500):
out.append(("plan", battery_now(62, w, phase=M_IDLE,
shadow=False, running=True,
off=off, peak=peak)))
out += [("plan", why_words(k, name="Battery A")) for k in words["why"]]
out += [("plan", today_words(k)) for k in words["today"]]
out += [("plan", line_no_trade(k)) for k in
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
"no_level")]