UX-08 2c: short refusals, error texts and option texts
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s

Validator refusals name the setting by its label and key, say what is
wrong and what to change, and keep the rejected value and limit. Option
names and descriptions are short in en and nl; citations moved to DOCS
"For installers" and the test_translations citation check now pins the
instruction on the tab and the record in DOCS. No rule, key, schema,
default or exit code changed. Allowlist 167 -> 43 (log lines only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
glenn schrooyen
2026-09-27 05:55:45 +02:00
co-authored by Claude Opus 5.5
parent e1d0970b23
commit 2c516a032c
26 changed files with 1026 additions and 1236 deletions
+63 -62
View File
@@ -77,7 +77,7 @@ p = validate_startup_config(base(max_w=8000), -5000, 5000)
check("max_w above the device maximum is refused", len(p) == 1)
check("...and the message names the configured value", why(p, "8000"))
check("...and the device's value", why(p, "5000"))
check("...and says which one wins", why(p, "THE DEVICE WINS"))
check("...and says what to lower it to", why(p, "Lower the rating to 5000 W"))
check("max_w exactly at the device maximum is fine",
validate_startup_config(base(max_w=5000), -5000, 5000) == [])
check("max_w under it is fine",
@@ -85,7 +85,7 @@ check("max_w under it is fine",
# The charge side is a separate rail and an asymmetric device is legal.
p = validate_startup_config(base(max_w=4000), -2500, 5000)
check("a max_w the device cannot take on the CHARGE side is refused too",
len(p) == 1 and why(p, "charge side"))
len(p) == 1 and why(p, "when charging"))
check("...naming the device's floor", why(p, "-2500"))
# ⚠️ Not guessed at: the entity may simply not have booted yet. run_control()
# re-runs this the moment the real rating arrives.
@@ -122,7 +122,7 @@ check("...and rule 2 REFUSES that whole configuration at startup, which is why "
len(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV)) == 1)
check("...on the rating side specifically, not incidentally through some other "
"rule", why(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV),
"THE DEVICE WINS"))
"Lower the rating to"))
check("...and the case the refusal CANNOT reach - the rating unread because "
"ESPHome was not up - still starts, which is precisely why SAFETY-20's "
"answer had to be a runtime status row and not a startup gate",
@@ -136,9 +136,9 @@ print("2. an actuator to write to")
for blank in ("", " ", None):
p = validate_startup_config(base(setpoint_entity=blank), None, None)
check(f"setpoint_entity {blank!r} is refused", len(p) == 1)
check("...and the message says why it is dangerous rather than untidy",
check("...and the message says what it costs: nothing is controlled",
why(validate_startup_config(base(setpoint_entity=""), None, None),
"heartbeat"))
"cannot control the inverter"))
# --------------------------------------------------------------------------- #
# specs §1.5: TWO WRITERS ON ONE ACTUATOR, AND WHAT ACTUALLY GRADES IT
# --------------------------------------------------------------------------- #
@@ -181,7 +181,7 @@ check("...and the replacement SEES it, because it counts writers per ACTUATOR "
and len(_census["number.goodwe_setpoint"]) == 3)
_hazard_problems = validate_startup_config(_PER_INVERTER_HAZARD, -5000, 5000)
check("AC 3: and the config is REFUSED, where today it starts",
why(_hazard_problems, "one actuator"))
why(_hazard_problems, "point at the same inverter entity"))
check("...and the refusal names the actuator and every writer aimed at it, so "
"an operator can see WHICH declarations to remove rather than being told "
"only that something is wrong",
@@ -243,7 +243,8 @@ check("...and entity ids are folded to lower case, so a second writer cannot "
"be smuggled past the census on Number.X versus number.x",
why(validate_startup_config(base(inverters=[{"setpoint_entity":
"NUMBER.GOODWE_SETPOINT"}]),
-5000, 5000), "one actuator"))
-5000, 5000),
"point at the same inverter entity"))
# ⚠️ THIS CHECK CHANGED SHAPE IN CFG-04, AND THE REASON IS A DECISION RATHER
# THAN AN ADJUSTMENT. It used to assert that a blank per-inverter entity left
# the whole config ACCEPTED - "blank is how an operator leaves a slot
@@ -265,7 +266,8 @@ check("...but the ENTRY is refused all the same, by rule 9 and not by the "
why(validate_startup_config(base(inverters=[{"name": "inverter-b",
"max_w": 2000,
"setpoint_entity": " "}]),
-5000, 5000), "has no setpoint_entity"))
-5000, 5000),
"has no “Inverter setpoint entity” (setpoint_entity)"))
# --- OPT-09: `capacity_kwh`, which is OPTIONAL but not PARTIAL ---------------
# ⚠️ RULE 9 GAINED TWO CLAUSES AND BOTH REFUSE A CONFIG THAT WOULD OTHERWISE
@@ -303,9 +305,9 @@ check("⚠️ AND A PARTIAL ROSTER IS REFUSED - stated on one entry and left emp
"weighing a dimensionless 1.0 is not a comparison, so the key would "
"silently change nothing at all",
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
"not PARTIAL")
"Set it on every inverter, or on none")
and why(validate_startup_config(_with_cap(None, 20.48), -5000, 5000),
"not PARTIAL"))
"Set it on every inverter, or on none"))
check("...and the refusal names WHICH entry is which, because an operator with "
"three inverters told 'one of them is wrong' is sent to the wrong "
"cupboard",
@@ -339,7 +341,7 @@ check("a phase list disagreeing with meter_phases is refused",
why(validate_startup_config(base(meter_source="ha_signed",
p1_net_entity="sensor.n", meter_phases=3,
p1_phase_net_entities=["a", "b"]),
-5000, 5000), "phase-count"))
-5000, 5000), "Make them match"))
check("mqtt_p1 with no topic is refused",
why(validate_startup_config(base(meter_source="mqtt_p1"), -5000, 5000),
"meter_mqtt_topic"))
@@ -349,7 +351,7 @@ check("ha_dsmr with only half its entities is refused",
"p1_export_entity"))
check("a misspelt meter_source is refused rather than silently ignored",
why(validate_startup_config(base(meter_source="ha_signd"), -5000, 5000),
"is not one of"))
"Choose one of"))
# --------------------------------------------------------------------------- #
print("4. meter_max_age_s against the firmware's compile-time veto")
@@ -361,7 +363,8 @@ p = validate_startup_config(base(**hw, meter_max_age_s=120), -5000, 5000)
check("a meter_max_age_s above the firmware veto is refused", len(p) == 1)
check("...naming the configured value", why(p, "120"))
check("...naming the firmware's value", why(p, "30"))
check("...and saying which one wins", why(p, "THE FIRMWARE WINS"))
check("...and saying that the control board stops the inverter first",
why(p, "control board stops the inverter after 30 s"))
check("the tested pair 30/30 is accepted",
validate_startup_config(base(**hw, meter_max_age_s=30), -5000, 5000) == [])
check("one second above it is refused",
@@ -391,7 +394,7 @@ p = validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
check("maintenance_charge_w above max_w is refused", len(p) == 1)
check("...and the message names the charge rate", why(p, "2500"))
check("...and the ceiling", why(p, "2000"))
check("...and says which one wins", why(p, "THE CEILING WINS"))
check("...and says what it blocks", why(p, "monthly recharge cannot run"))
# ⚠️ Equality is REACHABLE, not a near miss: lo=-2000, hi=-2000 is the single
# point -2000 W. Refusing it would be exactly the trivia that gets a validator
# deleted, and it is the boundary the rule is written `>` for.
@@ -407,7 +410,8 @@ check("below it starts",
check("...and it is refused with maintenance_enabled off, as shipped",
why(validate_startup_config(
base(max_w=2000, maintenance_charge_w=2500,
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
maintenance_enabled=False), -5000, 5000),
"monthly recharge cannot run"))
# ⚠️ MAINT-05: AND AT TWO PACKS IT IS THE WEAKEST CAP, NOT THE FIRST ENTRY'S.
# The comment this replaces in validate.py said, in as many words, that it took
@@ -448,11 +452,11 @@ check("⚠️ A CHARGE RATE THE SMALLEST PACK CANNOT TAKE IS REFUSED, even thoug
"recalibrated, so its cap could not contradict anything; it is now, and "
"its cheap-window charge would ask for hi=-3000 against lo=-2000 - the "
"`lo > hi` contradiction, 0 W, on the pack nobody was watching",
len(_p_two) == 1 and why(_p_two, "max_w (inverter-b) is 2000 W"))
len(_p_two) == 1 and why(_p_two, "(max_w) for inverter-b is 2000 W"))
check("...and the message names the pack the number came off, not the first "
"entry - an operator told to lower a number on the wrong device is being "
"sent to the wrong device by the message that exists to stop that",
not why(_p_two, "max_w (inverter-a)"))
not why(_p_two, "(max_w) for inverter-a"))
check("...while the same rate against two packs that can both take it starts, "
"so this is a comparison and not a blanket refusal of rosters",
[p for p in validate_startup_config(
@@ -463,7 +467,7 @@ check("...while the same rate against two packs that can both take it starts, "
check("...and at a ROSTER OF ONE `min()` over one cap is that cap: the same "
"message, the same boundary, the same refusal the flat config always got",
why(validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
-5000, 5000), "THE CEILING WINS")
-5000, 5000), "monthly recharge cannot run")
and validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
-5000, 5000) == [])
@@ -539,17 +543,15 @@ check("⭐ RESTORING THE 2026-09-05 PAIR - slew_w 1000 at max_w 5000 - IS REFUSE
"It crosses the whole output range in 10 s against a plant measured at "
"18 s, which is not a fast loop, it is a loop that commands its entire "
"range before it can observe its answer to the first step",
len(_broken) == 1 and why(_broken, "slew_w is 1000 W"))
check("...and the refusal says WHICH RAIL it graded against and HOW LONG the "
"crossing takes, because 'slew_w is too high' sends an operator to lower "
"a number when raising max_w or setting 0 are both correct answers too",
why(_broken, "site rail of 5000 W")
and why(_broken, "crosses its whole output range in 5.0 cycles"))
check("...and it names the plant measurement it is graded against, so the "
"number is traceable to a step response rather than to a preference",
why(_broken, "goodwe/NOTES.md §5.10"))
len(_broken) == 1 and why(_broken, "(slew_w) is 1000 W"))
check("...and the refusal says WHICH RAIL it graded against, because 'slew_w "
"is too high' sends an operator to lower a number when raising max_w or "
"setting 0 are both correct answers too",
why(_broken, "for a 5000 W site"))
# UX-08 2c: the plant measurement the rule is graded against (the ~18 s step
# response) is in DOCS.md "For installers", beside slew_w, not in the refusal.
check("...and it says what to DO, including the setting that cannot go stale",
why(_broken, "Set slew_w to 0") and why(_broken, "556 W"))
why(_broken, "Set it to 0") and why(_broken, "556 W"))
# THE BOUNDARY. Equality is the DERIVED VALUE, not a violation - at exactly
# `SLEW_RAIL_FRAC * rail` the loop takes precisely the plant's settling time to
@@ -600,7 +602,7 @@ check("slew_w 0 - FOLLOW THE RAIL - is never refused at any rating, which it "
check("a negative slew_w is refused as nonsense rather than treated as 0 - it "
"is a magnitude, and 0 already has a meaning here",
why(validate_startup_config(dict(_R5000, slew_w=-400), -5000, 5000),
"must not be negative"))
"cannot be negative"))
# ⭐ THE ROSTER, WHICH IS THE WHOLE REASON THIS GRADES A SUM. `slew_w` is not a
# per-unit quantity: main.py builds `Tuning(max_w=self.site_max_w)` and
@@ -649,7 +651,7 @@ check("...so the same explicit slew_w that is REFUSED at a 5000 W rail is "
check("...and a slew that outruns even the doubled rail is still refused there, "
"so the roster path is not simply un-graded",
why(validate_startup_config(_roster(slew_w=2000)),
"site rail of 9600 W"))
"for a 9600 W site"))
# --------------------------------------------------------------------------- #
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
@@ -777,9 +779,10 @@ print("max_w must be a positive magnitude")
# sim/inject.py's entry point and can be handed any dict at all.
for bad in (0, -1, -2000):
p = validate_startup_config(base(max_w=bad), -5000, 5000)
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "must be positive"))
check("...and the message explains it would read as a trip, not a mistake",
why(validate_startup_config(base(max_w=0), -5000, 5000), "contradiction"))
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "Set it above 0 W"))
check("...and the message names the setting by its label and its key",
why(validate_startup_config(base(max_w=0), -5000, 5000),
"“Inverter rating” (max_w) is 0 W"))
# ⚠️ SAFETY-02 changed what this case has to say, and the change is real rather
# than bookkeeping: with a 1 W ceiling the default 300 W sign-disagreement
# threshold is unreachable, so rule 5 refuses - correctly, because the watchdog
@@ -792,7 +795,7 @@ check("the smallest sane max_w still starts",
unit_sign_disagree_w=0.5,
maintenance_charge_w=1), -5000, 5000) == [])
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
why(validate_startup_config(base(max_w=1), -5000, 5000), "never fire"))
why(validate_startup_config(base(max_w=1), -5000, 5000), "can never trip"))
# ⚠️ SAFETY-30 ADDED A SECOND WAY TO DISARM A SIGN WATCHDOG BY CONFIGURATION,
# and the fixture above now has to name BOTH thresholds - which is the check
# doing its job rather than a bookkeeping edit. `unit_sign_disagree_w` bounds a
@@ -1188,9 +1191,9 @@ def _roster_n(caps, **over):
# The three rules that reduce over `unit_caps_indexed()`, each as
# (option name, the token its refusal is recognised by).
_CAP_RULES = (
("sign_disagree_w", "SAFETY-02 sign-disagreement"),
("unit_sign_disagree_w", "SAFETY-30 per-inverter sign-disagreement"),
("maintenance_charge_w", "maintenance charge in a cheap window"),
("sign_disagree_w", "(sign_disagree_w) is"),
("unit_sign_disagree_w", "(unit_sign_disagree_w) is"),
("maintenance_charge_w", "monthly recharge cannot run"),
)
_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0,
"maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0}
@@ -1220,7 +1223,7 @@ def _verdict(opts, token):
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
if not ps:
return (False, None, None)
m = re.search(r"max_w \(([^)]+)\) is (\d+) W", ps[0])
m = re.search(r"\(max_w\) for (\S+) is (\d+) W", ps[0])
return (True, m.group(1) if m else None, float(m.group(2)) if m else None)
@@ -1330,7 +1333,7 @@ check("...and at a ROSTER OF ONE every candidate that still has a member to "
and {n for n, v in _at_one.items() if v is None}
== {"min(caps[1:])", "min(caps[:-1])"}
and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)),
"SAFETY-02 sign-disagreement")[0])
"(sign_disagree_w) is")[0])
# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w --------------
# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL
@@ -1342,7 +1345,7 @@ check("...and at a ROSTER OF ONE every candidate that still has a member to "
_leftover = _roster_n([_SMALL, _SMALL],
**dict(_QUIET, unit_sign_disagree_w=_RATE))
_leftover["max_w"] = _BIG # what a single-inverter install leaves behind
_lv = _verdict(_leftover, "SAFETY-30 per-inverter sign-disagreement")
_lv = _verdict(_leftover, "(unit_sign_disagree_w) is")
print(f" leftover flat max_w {_BIG:.0f} beside a roster of two "
f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}")
check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT "
@@ -1358,7 +1361,7 @@ check("...and the over-refusal in the OTHER direction is gone too: a roster of "
"reach. It starts",
_verdict(_roster_n([_BIG, _BIG],
**dict(_QUIET, unit_sign_disagree_w=_RATE)),
"SAFETY-30 per-inverter sign-disagreement")
"(unit_sign_disagree_w) is")
== (False, None, None))
check("...and the flat single-inverter config it always refused is refused "
"IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is "
@@ -1411,7 +1414,7 @@ print(f" {(_T17_P_GAP or [''])[0][:120]}")
check("a table that leaves 00:00-07:00 unpriced is REFUSED, by the same tiling "
"rule static_profile has - and the refusal names the KEY and the HOURS, "
"not merely that something was wrong",
why(_T17_P_GAP, "price_taxes_profile cannot be used:")
why(_T17_P_GAP, "“Day/night levies and VAT” cannot be used.")
and why(_T17_P_GAP, "leaves mon 00:00-07:00 with no period"))
check("...and it is refused for THAT reason and not as a side effect of "
"something else this fixture happens to break: exactly one problem",
@@ -1427,9 +1430,9 @@ print(f" {(_T17_P_BOTH or [''])[0][:120]}")
check("the table AND a non-default price_taxes_eur_kwh is refused, and the "
"refusal says WHICH ONE WINS and what to do - not merely that the two "
"are incompatible",
why(_T17_P_BOTH, "price_taxes_profile is set AND price_taxes_eur_kwh is "
"0.1198")
and why(_T17_P_BOTH, "THE TABLE WINS"))
why(_T17_P_BOTH, "(price_taxes_profile) is set, so")
and why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used, but it reads "
"0.1198"))
check("...and the SHIPPED scalar beside the table is NOT refused, or every "
"Walloon site would have to clear a key it never touched. The refusal is "
"about a value the operator chose, and the shipped default is the one "
@@ -1479,19 +1482,17 @@ check("...while a value that is NEITHER the shipped default NOR a superseded "
"one is still REFUSED beside the table, naming it - the set of "
"'untouched' figures widened by exactly the old defaults and by nothing "
"else",
why(_T17_P_BOTH, "price_taxes_profile is set AND price_taxes_eur_kwh is "
why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used, but it reads "
"0.1198")
and 0.1198 not in _T18_SUPERSEDED
and abs(0.1198 - _T18_SHIPPED_TAXES) > 1e-9)
# ⚠️ AND THE REFUSAL SAYS SO, so an operator who HAS typed a number is told
# both which figure to return to and that the old default is not the problem.
check("...and the refusal names the SHIPPED default to return to AND says the "
"superseded default is accepted, so the sentence is actionable on both "
"sides of the upgrade",
# UX-08 2c: that a superseded default is accepted is said in DOCS.md "For
# installers" now. The refusal keeps the one figure the operator acts on.
check("...and the refusal names the SHIPPED default to return to",
bool(_T18_SUPERSEDED)
and why(_T17_P_BOTH, f"back to {_T18_SHIPPED_TAXES}")
and why(_T17_P_BOTH, "SUPERSEDED")
and all(why(_T17_P_BOTH, str(u)) for u in _T18_SUPERSEDED))
and why(_T17_P_BOTH, f"back to {_T18_SHIPPED_TAXES}"))
check("...and the same non-default scalar WITHOUT the table still starts, "
"which is what makes the line above about the PAIR rather than about "
"0.1198 being disliked",
@@ -1531,18 +1532,18 @@ print(f" ORES : {(_T17_P_ORES or [''])[0][:110]}")
print(f" Sibelga : {(_T17_P_SIB or [''])[0][:110]}")
check("ORES's card typed in CENTS is refused by the PARSER's bound, and the "
"sentence is the parser's - `beyond +-10.0`, naming the row",
why(_T17_P_ORES, "price_taxes_profile cannot be used:")
why(_T17_P_ORES, "“Day/night levies and VAT” cannot be used.")
and why(_T17_P_ORES, "beyond +-10.0"))
check("...and SIBELGA's whole card - 9,39 and 7,10, BOTH under that bound - is "
"refused by THIS rung instead, naming both bands and the factor. Without "
"it a Brussels site would price a 9.39 EUR/kWh levy and start",
why(_T17_P_SIB, "price_taxes_profile carries [7.1, 9.39] EUR/kWh")
and why(_T17_P_SIB, "factor of 100"))
why(_T17_P_SIB, "(price_taxes_profile) has [7.1, 9.39] EUR/kWh")
and why(_T17_P_SIB, "Divide the figures by 100"))
check("...and the two refusals are DIFFERENT sentences, so the rungs cannot "
"mask each other: deleting the units rung leaves the Sibelga line red "
"while the ORES line goes on passing for the parser's reason",
_T17_P_ORES != _T17_P_SIB
and not why(_T17_P_ORES, "factor of 100")
and not why(_T17_P_ORES, "Divide the figures by 100")
and not why(_T17_P_SIB, "beyond +-10.0"))
check("...and BOTH cards divided by 100 start, which is habit 7: the refusals "
"above are about the units and not about Brussels or Wallonia",
@@ -1639,13 +1640,13 @@ check("PRECONDITION: each of those two blobs differs from the accepted "
== _T16_ENECO)
check("a factor 100x out is REFUSED at startup, by the pass-through rung, "
"naming the option the operator typed",
why(_T16_P_PT, "price_export_formula cannot be used:")
why(_T16_P_PT, "“Your own injection formula” cannot be used.")
and why(_T16_P_PT, "passes through")
and not why(_T16_P_PT, "|offset| above"))
and not why(_T16_P_PT, "larger than"))
check("...and an offset 100x out is refused by the OFFSET rung, so the two "
"rungs are graded on their reasons and cannot mask each other",
why(_T16_P_OFF, "price_export_formula cannot be used:")
and why(_T16_P_OFF, "|offset| above")
why(_T16_P_OFF, "“Your own injection formula” cannot be used.")
and why(_T16_P_OFF, "larger than")
and not why(_T16_P_OFF, "passes through"))
check("...and a malformed blob is refused as a malformed blob, naming the "
"field rather than 'invalid configuration'",
@@ -1688,7 +1689,7 @@ check("...and a card ON THIS LEG that the coefficients do not reproduce is "
"REFUSED at startup, so the optional block is optional and not inert",
why(_t16_start(price_export_formula=_T16_CONS.replace(
'"expect_incl_vat":15.55', '"expect_incl_vat":15.05'))[0],
"price_export_formula does not reproduce its own card"))
"price_export_formula does not match its own card"))
# ⚠️ THE SENTENCE DOCS.md RESTS ON, AND IT IS THE ONE MOST LIKELY TO ROT.
_T16_INFO = [m for lvl, m in _T16_OK_LOG if lvl == "INFO"
@@ -1736,7 +1737,7 @@ for _v in _S10_BAD:
_p = validate_startup_config(base(shadow_mode=_v), -5000, 5000)
check(f"shadow_mode={_v!r} is REFUSED, and the refusal SPELLS the key so "
"configui.problems_by_field() can file it under the right control",
why(_p, "shadow_mode is") and why(_p, "'auto'")
why(_p, "(shadow_mode) is") and why(_p, "'auto'")
and why(_p, "'on'") and why(_p, "'off'"))
for _v in ("auto", "on", "off", "AUTO", " on ", "Auto "):
check(f"shadow_mode={_v!r} starts - the three values and the tolerant "