Files
goodwe-addon/goodwe_controller/test_translations.py
T
adminandClaude Opus 5 5d7e275df4
checks / unit (pull_request) Successful in 5m16s
SAFETY-39: slew_w is a ratio to the rail, and gain is what is still wrong
`slew_w: 1000` was derived on 2026-08-22 against a clamp of 1500 W, where
"capping how far the loop can jump ahead of the inverter" was true because the
rail itself bounded the excursion. The live install went to max_w 5000 and the
constant did not move, so the loop crosses its whole output range in 10 s
against a plant NOTES.md §5.10 measured at ~18 s. Measured on the owner's system
2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 W in five
2 s ticks, the battery to -5154 W past a 5000 VA rating, and the grid from
3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken; the rail moved.

REPRODUCED FIRST, and one half of it is not reproducible at all. On
plantsim.Plant and on an independent in-repo model: the setpoint stepping a full
1000 W per tick to the rail (48 of 51 export steps), and the meter crossing from
export into import. The -5154 W figure is NOT reproducible in either model,
because both clamp the plant at its rating - so the card's AC "battery no longer
exceeds max_w in simulation" is unfalsifiable and was rewritten rather than met.

THE DELIVERABLE IS THE RELATIONSHIP.
  SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 1/9
  slew_w = 0 now means FOLLOW THE RAIL - SLEW_RAIL_FRAC * max_w - exactly as
  integrator_max_w = 0 already means follow max_w. It is the shipped default:
  222 W at max_w 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site.
  validate.py rule 10 refuses an explicit value faster than that, graded against
  sum(unit_caps(opts)) - the roster sum, i.e. Controller.site_max_w - so a
  second inverter moves the bound with no operator action.

VERIFIED BY BREAKING IT: restoring slew_w 1000 at max_w 5000 is refused, and the
refusal names the rail, the crossing time, the measurement and what to do.
Equality, one watt of slack, the live interim 400 and slew_w 0 at every rating
all still start.

GAIN IS THE CONSTANT THAT IS STILL WRONG, and it is NOT changed here. Over 51
export steps the over-absorption is FLAT in slew (958 W at 1000, 1081 W at 250)
and monotone in gain (987 W at 0.6, 876 at 0.3, 435 at 0.2): slew_w bounds the
OUTPUT, while the integrator moves gain*error per cycle and reaches a 5000 W
rail in under three ticks whatever the slew is. Two control-law constants moving
in one release on a running house makes the next measurement uninterpretable.

⚠️ N=2: the site rail becomes sum(max_w) ~ 9600 W (inverter B FROM DATASHEET AND
SERIAL, not measured, and sim/plantsim.py's INVERTER_B is A copied), and the
enforced ratio produces 1067 W there automatically. But a growing rail LOOSENS
this bound, and the >= 900 W symptom goes stale at N=2 the same way the constant
did - both stated in the record rather than left to be discovered.

Nothing reached the inverter. The live install still runs the interim slew_w 400
applied by hand on 2026-09-05, which rule 10 accepts unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 01:10:15 +02:00

370 lines
20 KiB
Python

"""UX-01 - the Configuration tab's labels, and the check that stops them rotting.
RUN: python3 -B test_translations.py (no third-party imports; the add-on
CI image is Alpine + aiohttp + paho)
WHAT THIS FILE IS FOR. Before UX-01 there was no `translations/` directory at
all, so Home Assistant rendered the raw option KEYS - `unit_sign_disagree_w`,
`saturation_cycles`, `integrator_max_w` - for all seventy options, in
`schema:` order, with no description. The two files this grades are what turn
that into sentences, and the highest-value half of them is not the label: it is
the sentence that says WHETHER TO TOUCH THE VALUE, because the tab presents a
constant derived from 1890 closed-loop test runs and the operator's own inverter
rating as the same kind of field.
⚠️ WHAT THIS FILE CANNOT SEE, SAID FIRST. It grades COVERAGE and PROVENANCE
CITATIONS - that every option has a name and a description in both languages,
that no translation names an option that does not exist, and that each derived
constant's English description cites the record it was derived from. It does NOT
grade whether a sentence is true, whether the Dutch is good Dutch, or whether
the result looks right on screen. The last of those is measured, not asserted,
by `tools/supervisor_shape_probe.py` section F, which loads these files with
Supervisor 2026.08.0's OWN loader out of the release image, runs its OWN
`UiOptions`, replays the frontend's label resolution as the shipped bundle
writes it, and prints every rendered row. That transcript is the evidence; this
file is the gate that keeps it true.
⚠️ AND THE PARSER IS THE OTHER HALF OF THAT PAIR. There is no PyYAML in the CI
image and a check that skips on ImportError is not a check, so
`tools/translations_lint.py` parses these files by hand. A hand parser that
quietly disagrees with the real loader is exactly the defect this repository
keeps finding, so the probe imports THAT SAME MODULE and asserts, key by key and
string by string, that it returns what `supervisor.store.data.
_read_app_translations()` returns. Part 5 below re-states the arrangement so a
reader of this file is told where the licence for the hand parser comes from.
MUTATION.md is the method for the ledger in part 6; `tools/mutation_run.sh` is
the harness, and every row there was RUN rather than reasoned about.
"""
import io
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / "tools"))
import translations_lint as TL # noqa: E402
FAILED = 0
def check(what, ok):
global FAILED
print((" ok " if ok else " FAIL ") + what)
if not ok:
FAILED += 1
CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read()
ORDER, NESTED = TL.config_surface(CFG_TEXT)
LANGS = ("en", "nl")
TR = {lg: TL.parse_translation_file(HERE / "translations" / f"{lg}.yaml")
for lg in LANGS}
print("UX-01 - add-on option translations\n")
print("1. the surface being described")
print(f" config.yaml declares {len(ORDER)} top-level options under `schema:` "
f"and {sum(len(v) for v in NESTED.values())} per-entry key(s) under "
f"{sorted(NESTED)}")
# ⚠️ A FLOOR, NOT AN EQUALITY. This is the "did the reader read anything at all"
# line: `config_surface()` returning an empty list would make every coverage
# check below vacuously true, which is the third failure mode in MUTATION.md -
# the assertion runs, the sentence is true, and the data cannot reach the
# failure it names.
check(f"the option surface was actually read - {len(ORDER)} options, not zero",
len(ORDER) >= 60 and "inverters" in ORDER and "max_w" in ORDER)
check("...and the repeatable `inverters:` section's per-entry keys were read "
"too, which is the half a top-level-only reader would miss",
NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"]
and len(NESTED["inverters"]) == 7)
print("\n2. AC: every option and every per-entry key is translated, in BOTH "
"languages")
for lg in LANGS:
problems = TL.audit(CFG_TEXT, TR[lg], lg)
print(f" {lg}: {len(TR[lg]['configuration'])} options translated, "
f"{len(problems)} problem(s)")
for p in problems[:10]:
print(" - " + p)
check(f"`{lg}` covers config.yaml exactly: no option without a translation, "
f"no translation without an option, every one with a name AND a "
f"description. Problems: {problems[:3]}", not problems)
# ⚠️ THE `nl` HALF IS NOT OPTIONAL EVEN THOUGH THE FALLBACK IS. The frontend
# resolves `translations[lang]?.name || translations["en"]?.name || key`, PER
# KEY - measured off the shipped bundle, see the probe - so a MISSING nl key
# degrades to English and a WRONG one does not degrade at all. The card asks for
# both languages complete, and complete is a thing a check can hold.
check("...and the two files describe the SAME option set, so nothing on the "
"owner's Dutch screen quietly falls back to English",
set(TR["en"]["configuration"]) == set(TR["nl"]["configuration"]))
check("...including inside the repeatable section",
set(TR["en"]["configuration"]["inverters"]["fields"])
== set(TR["nl"]["configuration"]["inverters"]["fields"]))
check("...and no Dutch string was left as a copy of the English one, which is "
"what an untranslated key looks like when it is present rather than "
"absent",
not [k for k in ORDER
if TR["en"]["configuration"][k]["description"]
== TR["nl"]["configuration"][k]["description"]])
print("\n3. ⭐ the description says WHETHER TO TOUCH IT")
# ⚠️ THE HIGHEST-VALUE ITEM IN THE CARD, AND THE ONE A COVERAGE COUNT CANNOT
# REACH. Sixty-odd descriptions that all say "the threshold, in watts" would
# pass part 2 completely. What makes this surface safe is that a DERIVED
# constant names the campaign it came from and says the value is not a knob,
# while a number that really is the operator's says so too.
#
# ⚠️ AND THIS IS A TEXT CHECK, WHICH IS WEAK ON PURPOSE AND IS SAID TO BE. It
# cannot know whether a citation is TRUE; it can only know that the citation is
# there and names the right record, so that deleting the provenance - or
# renaming the card it points at - goes red instead of shipping. The table is
# written out rather than derived because there is nothing in config.yaml that
# distinguishes a derived constant from an operator's number; that distinction
# is the whole of what this card had to add.
DERIVED = {
"sign_disagree_w": ("SAFETY-02", "MEASURED"),
"sign_disagree_s": ("SAFETY-02", "MEASURED"),
"unit_sign_disagree_w": ("SAFETY-30", "1890"),
"unit_sign_disagree_s": ("SAFETY-30", "1890"),
"site_sign_disagree_w": ("SAFETY-30", "CARRIED"),
"site_sign_disagree_s": ("SAFETY-30", "1890"),
"meter_freeze_s": ("SAFETY-18", "MEASURED"),
"circulation_w": ("SAFETY-31", "924"),
"circulation_hold_s": ("SAFETY-31", "DERIVED"),
"peak_soc_floor": ("SAFETY-24", "DERIVED"),
"bms_soc_floor": ("0x0560", "MEASURED"),
"integrator_max_w": ("MEASURED", "Do not raise it"),
"gain": ("MEASURED ON", "do not raise it"),
# SAFETY-39. `slew_w` joins the table because it stopped being an
# operator's convenience and became a DERIVED value the moment it was
# tied to the rating: 0 resolves to about a ninth of `max_w`, from an
# ~18 s measured step response. It is the one entry here whose value is
# a RATIO, so the description has to say what it follows as well as
# where the ratio came from - a citation alone would leave an operator
# reading "0" on the tab with no idea it is not "off".
"slew_w": ("SAFETY-39", "DERIVED", "0 MEANS FOLLOW"),
}
_uncited = []
for key, needles in sorted(DERIVED.items()):
desc = TR["en"]["configuration"][key]["description"]
for needle in needles:
if needle not in desc:
_uncited.append(f"{key} does not cite {needle!r}")
print(f" {len(DERIVED)} derived constants must cite their campaign; "
f"{len(_uncited)} do not")
check(f"every derived constant's English description names WHERE THE NUMBER "
f"CAME FROM, so an operator reading the tab can tell it apart from their "
f"own inverter rating. Uncited: {_uncited}", not _uncited)
# The other direction, and it matters as much: a number that IS the operator's
# must not be dressed up as derived, or the labels stop meaning anything.
_theirs = ["max_w", "peak_cap_w"]
check("...and the numbers that really are the OPERATOR'S say so instead of "
"carrying a citation - `max_w` and `peak_cap_w` are the two the card "
"names, and both read 'THIS IS YOUR NUMBER'",
all("THIS IS YOUR NUMBER" in TR["en"]["configuration"][k]["description"]
for k in _theirs))
check("...and the one threshold that is NOT derived from a measurement admits "
"it rather than borrowing the others' authority - "
"`cycle_cost_threshold_eur` is an open product question (OPT-08)",
"NOT DERIVED FROM A MEASUREMENT"
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"]
and "OPT-08"
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"])
print("\n4. task ordering, and the labels that carry it")
# ⚠️ ADD-ON OPTIONS ARE A FLAT LIST - no section headers, no per-option advanced
# flag, no units on the input. Grouping is approximated by ORDER plus label text
# and by nothing else, so both halves are graded: the label prefix, and that the
# prefixes appear in the file as CONTIGUOUS RUNS in the task order. A group
# whose members are scattered renders as a jumble however good each label is.
GROUPS = ["Hardware", "Meter", "Limits", "Tariff", "Maintenance", "Safety",
"Advanced"]
NL_GROUPS = {"Hardware": "Hardware", "Meter": "Meter", "Limits": "Grenzen",
"Tariff": "Tarief", "Maintenance": "Onderhoud",
"Safety": "Veiligheid", "Advanced": "Geavanceerd"}
_prefixes = []
for key in ORDER:
name = TR["en"]["configuration"][key]["name"]
_prefixes.append(name.split(" \u00b7 ")[0] if " \u00b7 " in name else f"<none:{key}>")
_runs = [p for i, p in enumerate(_prefixes) if i == 0 or p != _prefixes[i - 1]]
print(f" the rendered order groups into: {_runs}")
check(f"every label carries a group prefix, so the flat list reads as groups. "
f"Missing: {[p for p in _prefixes if p.startswith('<none:')]}",
not [p for p in _prefixes if p.startswith("<none:")])
check(f"...and each group is ONE CONTIGUOUS RUN in the rendered order, in the "
f"task order the card asks for - hardware, meter, ratings and limits, "
f"tariff, maintenance, safety, advanced. Got: {_runs}", _runs == GROUPS)
_nl_bad = [k for k in ORDER
if NL_GROUPS.get(TR["en"]["configuration"][k]["name"].split(" \u00b7 ")[0])
!= TR["nl"]["configuration"][k]["name"].split(" \u00b7 ")[0]]
check(f"...and the Dutch labels carry the SAME grouping under Dutch names, so "
f"the two languages do not read as two different layouts. Off: {_nl_bad}",
not _nl_bad)
# ⚠️ THE ORDER IS THE `schema:` BLOCK'S, NOT THE `options:` BLOCK'S, AND THAT IS
# A MEASUREMENT. `App.schema_ui` is `UiOptions(coresys)(data["schema"])`, so the
# tab renders the schema half in its own order. Asserted here because the whole
# of part 4 is about an ordering that would otherwise be graded on the wrong
# half of the file - and because the two halves of config.yaml were already in
# different orders before this card.
# ⚠️ ANCHORED ON THE INDENTATION AND SCOPED TO THE `options:` BLOCK. The file
# starts with the add-on manifest, whose `services:` list carries ` - mqtt:want`
# at two spaces with a colon in it - a looser reader picks that up as an option
# called `- mqtt` and the comparison below then fails for a reason that has
# nothing to do with what it is watching. Measured, on the first run.
import re as _re # noqa: E402
_opt_half = CFG_TEXT[CFG_TEXT.index("\noptions:\n"):CFG_TEXT.index("\nschema:")]
_opt_order = _re.findall(r"^ ([a-z_0-9]+):", _opt_half, _re.M)
check("config.yaml's two halves now declare the same options in the same "
"order, so the file reads the way the tab renders even though only the "
"schema half decides it", _opt_order == ORDER)
print("\n5. the parser is strict, so a file it half-read cannot pass")
# ⚠️ THE FAILURE THIS PART EXISTS FOR. A permissive parser answers "no problems"
# for a file it silently skipped half of - the same green-and-not-watching
# defect one layer down. So every shape outside the subset UX-01 writes must
# RAISE, and that is graded with real malformed input rather than asserted.
BAD = [
("a tab-indented line", "configuration:\n\tmeter_entity:\n"),
("three-space indent", "configuration:\n meter_entity:\n"),
("a bare unquoted scalar", 'configuration:\n a:\n name: hello\n'),
("a single-quoted scalar", "configuration:\n a:\n name: 'hello'\n"),
("a list item", 'configuration:\n a:\n - "x"\n'),
("a duplicate key", 'configuration:\n a:\n name: "x"\n name: "y"\n'),
("an over-indented child", 'configuration:\n a:\n'),
("a line that is not a key at all", "configuration:\n just some prose\n"),
("an unsupported escape", 'configuration:\n a:\n name: "x\\ty"\n'),
]
_accepted = []
for what, text in BAD:
try:
TL.parse_translation_text(text, what)
_accepted.append(what)
except TL.TranslationSyntaxError:
pass
check(f"every shape outside the accepted subset RAISES rather than being "
f"skipped. Silently accepted: {_accepted}", not _accepted)
check("...and the accepted subset really does round-trip the shipped files - "
"70 options and 7 per-entry keys came back out of each, which is what "
"says the strictness above is not just refusing everything",
all(len(TR[lg]["configuration"]) == len(ORDER)
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7
for lg in LANGS))
print(" ⚠️ the licence for this hand parser is NOT this file. "
"tools/supervisor_shape_probe.py section F loads the same two files with "
"`supervisor.store.data._read_app_translations()` out of the 2026.08.0 "
"release image and asserts, key by key and string by string, that this "
"module returns the same thing. Re-run it when either changes.")
print("\n6. MUTATION: the check is run against broken inputs, both directions")
# ⚠️ THIS IS THE PART THE CARD ASKS FOR IN SO MANY WORDS, AND IT IS RUN RATHER
# THAN DESCRIBED. A coverage check is worth exactly what it catches, so each
# mutation below is APPLIED to a copy of the real parsed translations and the
# audit is required to NAME the key it broke - not merely to be non-empty. A
# check that goes red for the wrong reason is a check that will go green for the
# wrong reason later.
import copy # noqa: E402
def _mutate(what, fn, must_name):
tr = copy.deepcopy(TR["en"])
fn(tr)
problems = TL.audit(CFG_TEXT, tr, "en")
named = [p for p in problems if must_name in p]
print(f" {what}\n -> {len(problems)} problem(s); "
f"{(named[0][:120] + '...') if named else 'NONE NAMED ' + must_name}")
check(f"MUTATION: {what} -> the audit REFUSES and names `{must_name}`",
bool(named))
_mutate("delete one option's whole translation (`saturation_cycles`)",
lambda tr: tr["configuration"].pop("saturation_cycles"),
"saturation_cycles")
_mutate("delete one option's description but keep its name (`meter_phases`)",
lambda tr: tr["configuration"]["meter_phases"].pop("description"),
"meter_phases")
_mutate("blank one option's description (`gain`) - present but empty is not "
"covered either",
lambda tr: tr["configuration"]["gain"].update(description=" "),
"gain")
_mutate("translate an option that does not exist (`saturation_cyles`, one "
"letter out - the rename this check exists for)",
lambda tr: tr["configuration"].update(
saturation_cyles={"name": "x", "description": "y"}),
"saturation_cyles")
_mutate("delete one PER-ENTRY key of the repeatable section (`bms_soc_floor`)",
lambda tr: tr["configuration"]["inverters"]["fields"].pop("bms_soc_floor"),
"bms_soc_floor")
_mutate("invent a per-entry key the schema does not declare (`max_kw`)",
lambda tr: tr["configuration"]["inverters"]["fields"].update(
max_kw={"name": "x", "description": "y"}),
"max_kw")
_mutate("drop the whole `fields:` block from the repeatable section",
lambda tr: tr["configuration"]["inverters"].pop("fields"),
"inverters")
_mutate("hang a `fields:` block off an option that is not a repeatable section",
lambda tr: tr["configuration"]["max_w"].update(
fields={"x": {"name": "y"}}),
"max_w")
# ⚠️ AND THE TWO THAT COST THE WHOLE FILE RATHER THAN ONE LABEL. Supervisor
# validates each translation file through SCHEMA_APP_TRANSLATIONS and, on
# refusal, LOGS A WARNING AND DROPS ALL OF IT - so a shape it rejects does not
# lose one description, it silently returns the entire tab to raw keys, and the
# only trace is one line in a log nobody is reading. Neither of these is
# reachable through the `configuration:` coverage above, which is exactly why
# they are here.
_mutate("a top-level block Supervisor does not read (`netwrok:`) - the file is "
"DROPPED WHOLE, not partly",
lambda tr: tr.update(netwrok={"x": "y"}), "netwrok")
_mutate("a `network:` block whose value is a mapping rather than a string - "
"same consequence, every label in the file gone",
lambda tr: tr.update(network={"8099/tcp": {"x": "y"}}), "network")
# ⚠️ AND THE MUTATION THAT MATTERS MOST: A NEW OPTION ARRIVES. This is the rot
# the acceptance criterion is written about - somebody adds a key to config.yaml
# in six months and the tab renders it raw. The mutation is applied to the
# CONFIG side rather than the translation side, because that is the direction it
# happens in.
_grown = CFG_TEXT.replace("\n log_level: list(", "\n new_option_s: int(1,9)\n log_level: list(")
check("the config-side mutation really did land - a new option in the schema "
"half that no translation mentions", _grown != CFG_TEXT
and "new_option_s" in TL.config_surface(_grown)[0])
_grow_problems = TL.audit(_grown, TR["en"], "en")
print(f" a new option added to config.yaml -> {_grow_problems[:1]}")
check("MUTATION: adding an option to config.yaml and forgetting the "
"translation goes RED, naming the key - which is the whole of the "
"acceptance criterion about this rotting",
any("new_option_s" in p for p in _grow_problems))
# ⚠️ THE NEGATIVE CONTROL, WITHOUT WHICH THE EIGHT ROWS ABOVE PROVE NOTHING. An
# audit that returned a problem for every input would have "passed" all of them.
check("...and the UNMUTATED input is still clean, so the rows above are the "
"mutations speaking and not a check that always complains",
not TL.audit(CFG_TEXT, TR["en"], "en")
and not TL.audit(CFG_TEXT, TR["nl"], "nl"))
print("\n7. no behaviour and no value moved")
# ⚠️ UX-01 IS A PRESENTATION CARD. It reorders config.yaml and adds two files;
# it must not move a single default. The whole options half is compared against
# the schema half key by key here, and every other suite in this directory pins
# the actual values by reading this same file - test_maintenance.py's eight,
# test_watchdog.py's six, test_price.py's seven, test_freeze.py's, and
# test_peakguard.py's. This line is the cheap one that says the reorder did not
# lose or duplicate a key on the way.
check("no option was lost, gained or duplicated by the reordering: the two "
"halves of config.yaml hold the same 70 keys, once each",
sorted(_opt_order) == sorted(ORDER) and len(set(ORDER)) == len(ORDER))
check("...and `inverters: []` is still shipped in the options half, which is "
"what Supervisor 2026.08.0 refuses every pre-0.6.0 options blob without "
"(measured; see DECISIONS/CFG-04-inverter-roster.md B)",
"\n inverters: []\n" in _opt_half)
check("...and the schema half still declares the NESTED per-entry `max_w:` "
"BEFORE the flat one, so test_inverter.py part 12's first-match trap "
"stays baited rather than sorted out by this card's reordering",
CFG_TEXT[CFG_TEXT.index("\nschema:"):].index(" max_w: int(")
< CFG_TEXT[CFG_TEXT.index("\nschema:"):].index("\n max_w: int("))
print()
if FAILED:
print(f"{FAILED} FAILED")
sys.exit(1)
print("all checks passed")