"""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 ." 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