UX-13 P3: Simple view and Batteries screen
- 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:
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user