diff --git a/docs/img/ux13/batteries-off-peak.png b/docs/img/ux13/batteries-off-peak.png index 19b0082..354d392 100644 Binary files a/docs/img/ux13/batteries-off-peak.png and b/docs/img/ux13/batteries-off-peak.png differ diff --git a/goodwe_controller/DECISIONS/UX-13-one-layout-table.md b/goodwe_controller/DECISIONS/UX-13-one-layout-table.md index 34cb1ac..72118a4 100644 --- a/goodwe_controller/DECISIONS/UX-13-one-layout-table.md +++ b/goodwe_controller/DECISIONS/UX-13-one-layout-table.md @@ -93,8 +93,8 @@ What UX-02 made authoritative stays authoritative: existing `/action` values, not options, so the table places no key for them. The Batteries screen's Now, Today and Why lines are built in `homeview.py` from what the add-on knows this cycle; the plan per mode is - OPT-11 P3/P4, so until then Today says "Not planned yet" - (`test_batteries_screen.py`). + OPT-11 P3/P4, so until then Today says "Not planned yet" when no block + is planned for the battery (`test_batteries_screen.py`). ## 4. What this costs diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 34318e9..7bc4033 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -3263,9 +3263,10 @@ battery. An Off battery that the peak limit still needs says so, with the power it gives: "Off, but gives 0.5 kW for the peak limit." -**Settings** opens on the **Simple** view: the peak limit, the batteries' -switches and modes, and shadow mode, each marked "Applies right away" or -"Applies after a restart". **Installer** shows every setting. +**Settings** opens on the **Simple** view, or on the view you last picked: +the peak limit, the batteries' switches and modes, and shadow mode, each +marked "Applies right away" or "Applies after a restart". **Installer** shows +every setting. ### Setup, for a new install diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 90d1943..efee29d 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -380,9 +380,10 @@ def _cycle_hours(phase, soc, cap_kwh, cfg): 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.""" + the current 15-minute period on every battery. An Off battery gets it + only while it gives power (OPT-11: Off covers only the shortfall).""" pg = c.peak_guard - if pg is None or not pg.engaged: + if not peak_engaged(c): return [] start = now - timedelta(minutes=now.minute % 15, seconds=now.second, microseconds=now.microsecond) @@ -390,7 +391,8 @@ def peak_run(c, day0, day1, now, shadow, limit_w): 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))] + for i, inv in enumerate(c.inverters) + if not _off(c, inv) or flow(inv.batt) == "giving"] def house_runs(n, day0, day1, now, shadow, skip=()): @@ -526,9 +528,11 @@ def refusing(c, inv) -> int: def peak_engaged(c) -> bool: - """The peak guard is asking the batteries for power this cycle.""" + """The peak guard asks the batteries for power this cycle. Not when it is + blind or floor-limited: those are engaged with a 0 W floor (no charging + only), so no battery gives power for it.""" pg = getattr(c, "peak_guard", None) - return bool(pg is not None and pg.engaged) + return bool(pg is not None and pg.engaged and (pg.floor_w or 0.0) > 0.0) def battery_rows(c, shadow, running, held=False): @@ -559,7 +563,6 @@ def battery_rows(c, shadow, running, held=False): 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({ @@ -592,7 +595,7 @@ def battery_rows(c, shadow, running, held=False): def battery_why(*, modes_on, mode, off, reading, phase, shadow, running, - held, refused, peak, moving, sf_ahead, giving=False, + held, refused, peak, 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.""" @@ -608,7 +611,7 @@ def battery_why(*, modes_on, mode, off, reading, phase, shadow, running, 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" + return "off_peak" if peak and giving else "off" if refused: return "refuses_give" if refused > 0 else "refuses_charge" if peak: diff --git a/goodwe_controller/app/static/settings.js b/goodwe_controller/app/static/settings.js index 3503133..b80dc7c 100644 --- a/goodwe_controller/app/static/settings.js +++ b/goodwe_controller/app/static/settings.js @@ -473,9 +473,11 @@ window.UX13.batteries(batts); fillNotes(); } - function setMode(v, redraw) { + // `remember`: only the person's own pick of a view is kept for next time; + // a jump to Installer (a link, a setting to fix) is not. + function setMode(v, redraw, remember) { mode = v; - try { localStorage.setItem("ux13.settings.view", v); } catch (e) { /* no storage */ } + if (remember) try { localStorage.setItem("ux13.settings.view", v); } catch (e) { /* no storage */ } document.querySelectorAll("#set-view [role=radio]").forEach((b) => { const on = b.dataset.v === v; b.setAttribute("aria-checked", String(on)); b.tabIndex = on ? 0 : -1; @@ -486,13 +488,13 @@ if (redraw && M && view === "settings") render(); } document.querySelectorAll("#set-view [role=radio]").forEach((b, i, all) => { - b.addEventListener("click", () => setMode(b.dataset.v, true)); + b.addEventListener("click", () => setMode(b.dataset.v, true, true)); b.addEventListener("keydown", (e) => { const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key]; if (!d) return; e.preventDefault(); const j = all[(i + d + all.length) % all.length]; - setMode(j.dataset.v, true); j.focus(); + setMode(j.dataset.v, true, true); j.focus(); }); }); setMode(mode, false); diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index 61b9fbf..13777c9 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -320,7 +320,7 @@ "off_peak": "The peak limit comes before Off. It rests again after.", "refuses_give": "The battery refuses to give power. The add-on tries again every 15 minutes.", "refuses_charge": "The battery refuses to charge. The add-on tries again every 15 minutes.", - "peak": "The peak limit needs power, so every battery gives.", + "peak": "The peak limit needs power from the batteries.", "modes_off": "Battery modes are off, so it follows the house.", "solar_first": "Solar first: it gets spare solar before the others.", "leftover": "It gets the solar that Solar first batteries cannot take.", diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index 0a0c2d1..956cff9 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -259,10 +259,10 @@ "cycle": "De maandelijkse cyclus houdt de meting van het laadniveau juist.", "cycle_mode": "De maandelijkse cyclus gaat voor. Daarna volgt ze weer haar modus.", "off": "U hebt haar uitgezet.", - "off_peak": "De piekgrens gaat voor Uit. Daarna rust ze weer.", + "off_peak": "De piekgrens gaat voor op Uit. Daarna rust ze weer.", "refuses_give": "De batterij weigert te leveren. De add-on probeert het elke 15 minuten opnieuw.", "refuses_charge": "De batterij weigert te laden. De add-on probeert het elke 15 minuten opnieuw.", - "peak": "De piekgrens vraagt vermogen, dus elke batterij levert.", + "peak": "De piekgrens vraagt vermogen van de batterijen.", "modes_off": "Batterijmodi staan uit, dus ze volgt het huis.", "solar_first": "Eerst zonne-energie: ze krijgt het overschot vóór de andere.", "leftover": "Ze krijgt de zonne-energie die de batterijen met Eerst zonne-energie niet opnemen.", diff --git a/goodwe_controller/test_batteries_screen.py b/goodwe_controller/test_batteries_screen.py index a2f2347..4ddc917 100644 --- a/goodwe_controller/test_batteries_screen.py +++ b/goodwe_controller/test_batteries_screen.py @@ -27,6 +27,7 @@ import os import re import socket import sys +from datetime import datetime, timedelta, timezone HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "tools")) @@ -54,13 +55,35 @@ def words(lang): EN, NL = words("en"), words("nl") +B_NAME = D.OPTS["inverters"][1]["name"] LANGS = {"en": EN, "nl": NL} -def build(state, lang="en", c=None): +# The fixture's prices are for the day it is built, so the plan depends on +# the hour. Pin the clock (review of #150: unpinned, this file failed every +# morning): the fixture is built at 00:15 on 2026-09-28, Brussels time. Part 1 +# looks at 23:30, when the day's trades and cycle start are past, and part 3 +# at 00:30, with all of them ahead. +_BUILT = datetime(2026, 9, 27, 22, 15, tzinfo=timezone.utc) + + +class _Clock(datetime): + @classmethod + def now(cls, tz=None): + return _BUILT.astimezone(tz) if tz else _BUILT.replace(tzinfo=None) + + +D.datetime = _Clock +_DAY0 = homeview.local_day(_BUILT)[0] +EVENING = _DAY0 + timedelta(hours=23, minutes=30) +MORNING = _DAY0 + timedelta(minutes=30) + + +def build(state, lang="en", c=None, now=None): c = c or D.controller(state) s = c.status_payload() - return homeview.build(c, level=s["level"], checks=s["checks"], lang=lang) + return homeview.build(c, level=s["level"], checks=s["checks"], lang=lang, + now=now or EVENING) # The six states and what each battery's Why line must be. @@ -188,16 +211,60 @@ _kinds = {i: {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == i} print(f" {_kinds}") check("Off: no 'follows the house', no trade, no monthly cycle for B", not _kinds[1] and "house" in _kinds[0]) +_p = build("off", now=MORNING) +_m = build("modes", now=MORNING) +_kinds = {i: {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == i} + for i in (0, 1)} +print(f" morning: {_kinds}, lines {_p['plan']['lines']}") +check("in the morning, with trades and a cycle ahead: A has them, Off B has " + "no block and waits for no cycle (with B on, it does)", + {"house", "grid", "give", "cycle"} <= _kinds[0] and not _kinds[1] + and not any(B_NAME in x for x in _p["plan"]["lines"]) + and {"grid", "give"} <= {r["kind"] for r in _m["plan"]["runs"] + if r["battery"] == 1} + and any(B_NAME in x for x in _m["plan"]["lines"])) _p = build("off_peak") check("...but the peak block stays on it while the peak limit needs it", {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == 1} == {"peak"}) +# Review: an Off battery that A covers alone rests, so it gets no peak block, +# and a card never says "every battery gives" beside it. +_c = D.controller("off_peak") +_c.inverters[1].batt = 0.0 +_p = build("off_peak", c=_c) +_a, _b = _p["batteries"] +check("peak limit covered by A alone: Off B rests, has no peak block, and " + "A's Why does not claim every battery gives", + _b["why_key"] == "off" and _b["now"] == EN["home_words"]["now"] + ["off_resting"] and not _b["today"] + and {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == 0} + >= {"peak"} and _a["why_key"] == "peak" + and "every" not in _a["why"] and "elke" not in + build("off_peak", "nl", c=_c)["batteries"][0]["why"]) +# Review: a blind or floor-limited guard is engaged with a 0 W floor. It +# forbids charging only, so no battery gives power for it. +_bad = [] +for _kw in (dict(blind="meter gap"), dict(floor_limited=True)): + _c = D.controller("off_peak") + _c.peak_guard = homeview.peakguard.PeakDecision( + engaged=True, floor_w=0.0, ceiling_w=3500.0, ceiling_from="cap", + projected_w=None, **_kw) + _c.inverters[1].batt = 300.0 # B still going to 0 W + _p = build("off_peak", c=_c) + _a, _b = _p["batteries"] + if (_a["why_key"] == "peak" or _b["why_key"] == "off_peak" + or "peak limit" in _b["now"] + or any(r["kind"] == "peak" for r in _p["plan"]["runs"])): + _bad.append((_kw, _a["why_key"], _b["why_key"], _b["now"])) +print(f" {_bad}") +check("a blind or floor-limited peak guard (floor 0 W) draws no peak block " + "and no line says the peak limit takes power", not _bad) # ============================================================================ # print("\n4. battery_why(): pure, most urgent first") BASE = dict(modes_on=True, mode=packmode.SOLAR_FIRST, off=False, reading=True, phase=IDLE, shadow=False, running=True, held=False, refused=0, - peak=False, moving=False, sf_ahead=False) + peak=False, giving=False, sf_ahead=False) def why(**kw): @@ -211,9 +278,9 @@ _cases = [ (dict(shadow=True, phase=DRAIN), "shadow"), (dict(phase=DRAIN, off=True), "cycle_mode"), (dict(phase=DRAIN, modes_on=False), "cycle"), - (dict(off=True, peak=True, moving=True), "off_peak"), - (dict(off=True, peak=True, moving=False), "off"), - (dict(off=True, moving=True), "off"), + (dict(off=True, peak=True, giving=True), "off_peak"), + (dict(off=True, peak=True, giving=False), "off"), # charging or resting + (dict(off=True, giving=True), "off"), (dict(refused=1, peak=True), "refuses_give"), (dict(refused=-1), "refuses_charge"), (dict(peak=True, giving=True, other_refuses="B"), "peak"), diff --git a/goodwe_controller/tools/ux13_browser_check.py b/goodwe_controller/tools/ux13_browser_check.py index 56d80ef..ad5df4a 100644 --- a/goodwe_controller/tools/ux13_browser_check.py +++ b/goodwe_controller/tools/ux13_browser_check.py @@ -135,6 +135,54 @@ def actions(browser): ctx.close() +def switching(browser): + # Review: an unsaved change survives the view switch both ways, the + # Installer's locked values still need "Change", and only the person's + # own pick of a view is remembered. + ctx, page, errors = open_page(browser, "modes", "#settings", + size="desktop", scheme="light", lang="en-GB") + chips = [c.locator(".chip").first.inner_text() + for c in page.locator("#simple > .card").all()] + check(f"Simple's badges: the peak limit and shadow mode need a restart, " + f"the batteries apply at once ({chips})", + chips == ["Applies after a restart", "Applies right away", + "Applies after a restart"]) + num = page.locator("#simple .card input.num").first + num.fill("4.2") + num.dispatch_event("change") + page.wait_for_timeout(600) + n1 = page.locator("#sb-text").inner_text() + page.locator("#set-view [data-v=installer]").click() + page.wait_for_timeout(600) + val = page.locator("#groups [data-key=peak_cap_w] input.num").input_value() + n2 = page.locator("#sb-text").inner_text() + locked = (page.locator("#groups [data-key=peak_soc_floor] input").count(), + page.locator("#groups [data-key=peak_soc_floor] button.link") + .count()) + page.locator("#set-view [data-v=simple]").click() + page.wait_for_timeout(600) + back = page.locator("#simple .card input.num").first.input_value() + n3 = page.locator("#sb-text").inner_text() + check("an unsaved peak limit survives Simple -> Installer -> Simple, and " + "the save bar counts it once", n1.startswith("1 ") and n2 == n1 + and n3 == n1 and val == "4.2" and back == "4.2" + and page.locator("#savebar").is_visible()) + check("...the Installer's locked value has no input until 'Change'", + locked == (0, 1)) + ctx.close() + ctx, page, errors = open_page(browser, "running", "#batteries", + size="desktop", scheme="light", lang="en-GB") + page.locator("#batt-off a").click() + page.wait_for_timeout(800) + page.reload() + page.wait_for_load_state("networkidle") + page.wait_for_timeout(1200) + check("a jump to Installer from a link is not remembered: Settings " + "opens on Simple again", page.locator("#simple").is_visible() + and page.locator("#groups").is_hidden() and not errors) + ctx.close() + + def modes_off(browser): ctx, page, errors = open_page(browser, "running", "#batteries", size="phone", scheme="light", lang="en-GB") @@ -172,6 +220,7 @@ def main(): sweep(b) actions(b) month_peak(b) + switching(b) modes_off(b) b.close() if FAILS: