A driver now says whether its release() hands the inverter back (Driver.hands_back; GoodWe is False, so release() stays write(0) and the Controller never calls it). command_site_release() sits next to command_site_zero(): the Stop button and shutdown() (so SIGTERM, restart, control task death and a late config refusal) hand a hand-back unit back, once, and zero every other unit as before. Safety stops and refusals still hold 0 W for every unit. Shadow mode releases a hand-back unit. After a release nothing is written to the unit (heartbeat and cycle included) until control starts again. release() is bounded by RELEASE_BUDGET_S (= the write budget), runs concurrently per unit, and a raising or hanging release on one unit does not stop the others. The "set to 0 W" sentences for Stop, restart, shadow mode and the stopped banner are built from the roster (app/handback.py, EN and NL, words from the driver); a GoodWe-only roster keeps every old literal. A handed-back unit shows "Back under <its control>" on Home. Existing test edited: test_messages.py (two classification entries for the CRITICAL line that moved into a roster builder, and the new release bound). Co-Authored-By: Claude <noreply@anthropic.com>
387 lines
14 KiB
Python
387 lines
14 KiB
Python
"""SMA-10: the two idle states, in words.
|
|
|
|
A unit is either HELD at 0 W (GoodWe: the add-on goes on writing 0 W) or
|
|
HANDED BACK (its driver has `hands_back = True`: `release()` gives it back to
|
|
whatever steers it by itself, and the add-on then writes nothing to it). So every
|
|
sentence that said "every inverter is set to 0 W" for the add-on going away or
|
|
for shadow mode has to be built from the roster. DECISIONS/SMA-10-two-idle-
|
|
states.md.
|
|
|
|
THE RULE FOR EVERY BUILDER HERE: a roster with no hand-back unit gets None, and
|
|
the caller then uses the sentence it always used, untouched. So a GoodWe-only
|
|
site cannot see a changed byte; test_sma10_two_idle_states.py renders each one
|
|
and compares it with the literal from before this card.
|
|
|
|
The words for a hand-back unit come from its driver (`Driver.words`), not from
|
|
here: `back` is "SMA control" in "goes back to SMA control", `back_nl` is the
|
|
Dutch noun phrase after "terug naar". A driver without them gets a plain,
|
|
brand-free phrase.
|
|
|
|
Safety stops and refusals are not in this file on purpose: they hold 0 W for
|
|
every unit, and their sentences say so.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
_BACK_EN = "its own control"
|
|
_BACK_NL = "eigen sturing"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Unit:
|
|
name: str
|
|
hands_back: bool = False
|
|
back_en: str = _BACK_EN
|
|
back_nl: str = _BACK_NL
|
|
|
|
|
|
def unit_of(inv) -> Unit:
|
|
"""One roster entry's view: its name, and (for a hand-back driver) the
|
|
words of whose control it returns to."""
|
|
drv = inv.driver
|
|
back = bool(getattr(drv, "hands_back", False))
|
|
words = getattr(drv, "words", None) or {}
|
|
return Unit(str(inv.name), back,
|
|
str(words.get("back") or _BACK_EN),
|
|
str(words.get("back_nl") or _BACK_NL))
|
|
|
|
|
|
def units_of(inverters) -> tuple:
|
|
return tuple(unit_of(i) for i in inverters)
|
|
|
|
|
|
# The roster of the Controller that is running, for the callers that have no
|
|
# Controller in hand (the page, the banner). One process runs one Controller.
|
|
_CURRENT: list = [()]
|
|
|
|
|
|
def set_roster(units) -> None:
|
|
_CURRENT[0] = tuple(units)
|
|
|
|
|
|
def current() -> tuple:
|
|
return _CURRENT[0]
|
|
|
|
|
|
def active(units) -> bool:
|
|
"""Is any unit handed back? False is every GoodWe-only roster."""
|
|
return any(u.hands_back for u in units)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The grammar. English and Dutch, a few verb forms each.
|
|
# --------------------------------------------------------------------------- #
|
|
def _join(names, lang) -> str:
|
|
names = list(names)
|
|
word = " en " if lang == "nl" else " and "
|
|
if len(names) <= 1:
|
|
return "".join(names)
|
|
return ", ".join(names[:-1]) + word + names[-1]
|
|
|
|
|
|
def _forms(lang):
|
|
"""kind -> (zero template, back template); `{n}` the names, `{b}` the words.
|
|
`one`/`many` pick the verb by how many units the clause is about."""
|
|
if lang == "nl":
|
|
return {
|
|
"set": (("{n} gaat naar 0 W", "{n} gaan naar 0 W"),
|
|
("{n} gaat terug naar {b}", "{n} gaan terug naar {b}")),
|
|
"at": (("{n} op 0 W",) * 2, ("{n} onder {b}",) * 2),
|
|
"is": (("{n} staat op 0 W", "{n} staan op 0 W"),
|
|
("{n} staat onder {b}", "{n} staan onder {b}")),
|
|
"stays": (("{n} blijft op 0 W", "{n} blijven op 0 W"),
|
|
("{n} blijft onder {b}", "{n} blijven onder {b}")),
|
|
}
|
|
return {
|
|
"set": (("{n} is set to 0 W", "{n} are set to 0 W"),
|
|
("{n} goes back to {b}", "{n} go back to {b}")),
|
|
"at": (("{n} at 0 W",) * 2, ("{n} under {b}",) * 2),
|
|
"is": (("{n} is at 0 W", "{n} are at 0 W"),
|
|
("{n} is under {b}", "{n} are under {b}")),
|
|
"stays": (("{n} stays at 0 W", "{n} stay at 0 W"),
|
|
("{n} stays under {b}", "{n} stay under {b}")),
|
|
}
|
|
|
|
|
|
def _groups(units, lang):
|
|
"""`(zero names, [(names, back words)])`: the held units, and the handed-back
|
|
ones grouped by whose control they return to, in roster order."""
|
|
zeros = [u.name for u in units if not u.hands_back]
|
|
groups: dict = {}
|
|
for u in units:
|
|
if u.hands_back:
|
|
groups.setdefault(u.back_nl if lang == "nl" else u.back_en,
|
|
[]).append(u.name)
|
|
return zeros, [(names, back) for back, names in groups.items()]
|
|
|
|
|
|
# A clause may not eat the sentence it sits in: STYLE.md allows 25 words, and
|
|
# the longest tail after a clause is about 11.
|
|
CLAUSE_MAX_WORDS = 14
|
|
|
|
|
|
def _count_txt(n, lang) -> str:
|
|
if lang == "nl":
|
|
return f"{n} omvormer" if n == 1 else f"{n} omvormers"
|
|
return f"{n} inverter" if n == 1 else f"{n} inverters"
|
|
|
|
|
|
def _clause(units, lang, kind, sep, generic, counted) -> str:
|
|
forms = _forms(lang)[kind]
|
|
zeros, groups = _groups(units, lang)
|
|
if generic: # one group, and no make named
|
|
names = [n for g, _b in groups for n in g]
|
|
own = ("de eigen sturing" if lang == "nl" else
|
|
"their own control" if len(names) > 1 else "its own control")
|
|
groups = [(names, own)] if names else []
|
|
subject = ("de omvormers", "de omvormer") if lang == "nl" else (
|
|
"the inverters", "the inverter")
|
|
parts = []
|
|
if zeros:
|
|
parts.append(forms[0][1 if len(zeros) > 1 else 0].format(
|
|
n=(str(len(zeros)) if groups else _count_txt(len(zeros), lang))
|
|
if counted else _join(zeros, lang)))
|
|
for names, back in groups:
|
|
if not zeros and len(groups) == 1:
|
|
names_txt = subject[0 if len(names) > 1 else 1]
|
|
elif counted:
|
|
# the bare number after a counted first clause: "1 inverter is set
|
|
# to 0 W and 2 go back to their own control"
|
|
names_txt = (str(len(names)) if zeros
|
|
else _count_txt(len(names), lang))
|
|
else:
|
|
names_txt = _join(names, lang)
|
|
parts.append(forms[1][1 if len(names) > 1 else 0].format(
|
|
n=names_txt, b=back))
|
|
if sep:
|
|
return sep.join(parts)
|
|
word = " en " if lang == "nl" else " and "
|
|
return (", ".join(parts[:-1]) + word + parts[-1]) if len(parts) > 1 \
|
|
else "".join(parts)
|
|
|
|
|
|
def clause(units, lang, kind, sep=None) -> str:
|
|
"""One clause about the whole roster, `kind` one of set/at/is/stays.
|
|
|
|
Held units first, then the handed-back ones, joined by "and" (or `sep`).
|
|
When EVERY unit is handed back to the same control the subject is "the
|
|
inverters", not their names, as the card's table has it.
|
|
|
|
A long roster would break the 25-word rule, so a clause over
|
|
CLAUSE_MAX_WORDS is said again without naming the make (one "own control"
|
|
for all), and then by counting units instead of naming them."""
|
|
text = _clause(units, lang, kind, sep, False, False)
|
|
if sep or _words(text) <= CLAUSE_MAX_WORDS:
|
|
return text
|
|
text = _clause(units, lang, kind, sep, True, False)
|
|
if _words(text) <= CLAUSE_MAX_WORDS:
|
|
return text
|
|
return _clause(units, lang, kind, sep, True, True)
|
|
|
|
|
|
def _cap(text) -> str:
|
|
return text[:1].upper() + text[1:]
|
|
|
|
|
|
def _bi(en, nl):
|
|
from .messages import Bi
|
|
return Bi(en, nl)
|
|
|
|
|
|
def _words(text) -> int:
|
|
return len([w for w in re.findall(r"\S+", text)
|
|
if re.search(r"[A-Za-z0-9]", w)])
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The sentences. Each returns None for a roster with no hand-back unit.
|
|
# --------------------------------------------------------------------------- #
|
|
def stop_dialog(units, lang):
|
|
"""Stop control? (was "Every inverter is set to 0 W until you start
|
|
control again.")"""
|
|
if not active(units):
|
|
return None
|
|
c = clause(units, lang, "set")
|
|
return (f"{_cap(c)} tot u de regeling opnieuw start." if lang == "nl"
|
|
else f"{_cap(c)} until you start control again.")
|
|
|
|
|
|
def restart_dialog(units, lang):
|
|
"""Restart now? / Save and restart? (live)"""
|
|
if not active(units):
|
|
return None
|
|
c = clause(units, lang, "set")
|
|
return (f"{_cap(c)} terwijl de add-on herstart, daarna gelden de nieuwe "
|
|
f"instellingen." if lang == "nl" else
|
|
f"{_cap(c)} while the add-on restarts, and then the new settings "
|
|
f"apply.")
|
|
|
|
|
|
def restart_dialog_shadow(units, lang):
|
|
"""Save and restart? when shadow mode keeps the units idle afterwards."""
|
|
if not active(units):
|
|
return None
|
|
c = clause(units, lang, "set")
|
|
return (f"{_cap(c)} terwijl de add-on herstart, en de schaduwmodus houdt "
|
|
f"het zo." if lang == "nl" else
|
|
f"{_cap(c)} while the add-on restarts, and shadow mode keeps it "
|
|
f"that way.")
|
|
|
|
|
|
def setup_dialog_shadow(units, lang):
|
|
"""Save and restart? in setup, shadow mode on."""
|
|
if not active(units):
|
|
return None
|
|
c = clause(units, lang, "at")
|
|
return (f"De add-on herstart met deze instellingen, en de schaduwmodus "
|
|
f"houdt {c}." if lang == "nl" else
|
|
f"The add-on restarts with these settings, and shadow mode keeps "
|
|
f"{c}.")
|
|
|
|
|
|
def control_stopped(units, lang):
|
|
"""The page's control line when stopped."""
|
|
if not active(units):
|
|
return None
|
|
c = clause(units, lang, "is")
|
|
return (f"De regeling is gestopt. {_cap(c)}." if lang == "nl"
|
|
else f"Control is stopped. {_cap(c)}.")
|
|
|
|
|
|
def control_shadow(units, lang):
|
|
"""The page's control line in shadow mode."""
|
|
if not active(units):
|
|
return None
|
|
c = clause(units, lang, "stays")
|
|
return (f"Schaduwmodus: {c}." if lang == "nl" else f"Shadow mode: {c}.")
|
|
|
|
|
|
def attn_shadow_body(units, lang):
|
|
"""The attention card's body in shadow mode."""
|
|
if not active(units):
|
|
return None
|
|
c = clause(units, lang, "stays")
|
|
return (f"De add-on beslist alles, maar {c}." if lang == "nl" else
|
|
f"The add-on decides everything, but {c}.")
|
|
|
|
|
|
def shadow_state(units, lang, many=True):
|
|
"""The tail of a notification: "Shadow mode is on, so <this>." For the
|
|
three output-clamp sentences that said every inverter stays at 0 W."""
|
|
if not active(units):
|
|
return None
|
|
return clause(units, lang, "stays")
|
|
|
|
|
|
def banner_stopped(units):
|
|
"""Stopped. 12 words at most (STYLE.md)."""
|
|
if not active(units):
|
|
return None
|
|
out = {}
|
|
for lang, stopped in (("en", "Stopped"), ("nl", "Gestopt")):
|
|
text = f"{stopped} — {clause(units, lang, 'at', sep=', ')}"
|
|
if _words(text) > 12:
|
|
text = _banner_counts(units, lang, stopped)
|
|
out[lang] = text
|
|
return _bi(out["en"], out["nl"])
|
|
|
|
|
|
def _banner_counts(units, lang, stopped) -> str:
|
|
"""The long-roster form: how many, not which."""
|
|
zeros, groups = _groups(units, lang)
|
|
if len(groups) == 1:
|
|
back = groups[0][1]
|
|
n_back = len(groups[0][0])
|
|
if lang == "nl":
|
|
held = f"{len(zeros)} op 0 W, " if zeros else ""
|
|
return f"{stopped} — {held}{n_back} onder {back}"
|
|
held = f"{len(zeros)} at 0 W, " if zeros else ""
|
|
return f"{stopped} — {held}{n_back} under {back}"
|
|
return (f"{stopped} — omvormers op 0 W of teruggegeven" if lang == "nl"
|
|
else f"{stopped} — inverters at 0 W or handed back")
|
|
|
|
|
|
def banner_shadow(units):
|
|
"""Shadow mode, nothing standing. The GoodWe sentence adds "Inverters stay
|
|
at 0 W." which is false for a unit that hands back, and a banner is 12
|
|
words, so the unit-by-unit state is left to the control line."""
|
|
if not active(units):
|
|
return None
|
|
return _bi("Shadow mode: the battery is not controlled.",
|
|
"Schaduwmodus: de batterij wordt niet geregeld.")
|
|
|
|
|
|
def hint_shadow_on(units):
|
|
""""Shadow mode on, inverters stay at 0 W" for the Last event line."""
|
|
if not active(units):
|
|
return None
|
|
return "Shadow mode on, " + clause(units, "en", "at", sep=", ")
|
|
|
|
|
|
def unit_state(unit, lang):
|
|
"""One handed-back unit's own state, in place of "Held at 0 W"."""
|
|
if lang == "nl":
|
|
return f"Terug onder {unit.back_nl}"
|
|
return f"Back under {unit.back_en}"
|
|
|
|
|
|
def log_shutdown(units):
|
|
"""The INFO line at shutdown (was "shutting down - commanding 0 W on every
|
|
inverter")."""
|
|
if not active(units):
|
|
return None
|
|
zeros, groups = _groups(units, "en")
|
|
held = f"commanding 0 W on {_join(zeros, 'en')}" if zeros else ""
|
|
back = " and ".join(f"handing back {_join(n, 'en')} to {b}"
|
|
for n, b in groups)
|
|
return "shutting down - " + " and ".join(x for x in (held, back) if x)
|
|
|
|
|
|
def task_died_state(units) -> str:
|
|
"""The part of the CRITICAL line after "so" when the control task dies.
|
|
For a roster with no hand-back unit it is the sentence it always was."""
|
|
if not active(units):
|
|
return "every inverter is set to 0 W"
|
|
zeros, groups = _groups(units, "en")
|
|
backs = [n for names, _b in groups for n in names]
|
|
parts = []
|
|
if zeros:
|
|
parts.append(f"{_join(zeros, 'en')} "
|
|
f"{'are' if len(zeros) > 1 else 'is'} set to 0 W")
|
|
parts.append(f"{_join(backs, 'en')} "
|
|
f"{'are' if len(backs) > 1 else 'is'} handed back")
|
|
return " and ".join(parts)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The page words: the ui_text sentences that move with the roster, by language.
|
|
# --------------------------------------------------------------------------- #
|
|
def overlay(units) -> dict:
|
|
"""`{lang: nested dict}` of ui_text paths to replace; {} when no unit is
|
|
handed back, so the page is the file as it always was."""
|
|
if not active(units):
|
|
return {}
|
|
out = {}
|
|
for lang in ("en", "nl"):
|
|
out[lang] = {
|
|
"home": {"dialog": {"stop": {
|
|
"description": stop_dialog(units, lang)}}},
|
|
"settings": {
|
|
"dialog": {"description": restart_dialog(units, lang)},
|
|
"setup": {
|
|
"dialog": {"description":
|
|
restart_dialog_shadow(units, lang)},
|
|
"dialog_live": {"description":
|
|
restart_dialog(units, lang)},
|
|
"dialog_setup": {"description":
|
|
setup_dialog_shadow(units, lang)}}},
|
|
"home_words": {
|
|
"attn_shadow_body": attn_shadow_body(units, lang),
|
|
"control": {"shadow": control_shadow(units, lang),
|
|
"stopped": control_stopped(units, lang)}},
|
|
}
|
|
return out
|