When release() fails (False, raises or hangs past RELEASE_BUDGET_S) the unit gets one bounded write(0) through the new Driver.release_fallback() hook (default write(0)), then nothing more, so the inverter's own fallback timer is not reset. The release is retried once per heartbeat, not on every idle write. One error line and one action-log event per failure streak, one line on recovery. Dutch default "terug naar de eigen sturing". DECISIONS says what a unit is left at. test_messages.py: the RELEASE_BUDGET_S data entry is gone again (the line that needed it is gone). 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 = "de 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
|