Compare commits
3
Commits
f0f7b30098
..
UX-02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcee2356e0 | ||
|
|
2bb07ccdde | ||
|
|
fdaf3fb54f |
@@ -512,7 +512,7 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
soc_entity=opts.get("soc_entity", ""),
|
||||
batt_entity=opts.get("batt_entity", ""),
|
||||
batt_invert=bool(opts.get("batt_invert")),
|
||||
max_w=float(opts.get("max_w", 2000)),
|
||||
max_w=_flat_max_w(opts),
|
||||
# SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape
|
||||
# - the one pack the flat options describe is the one pack whose
|
||||
# cut-out `bms_soc_floor` states. Defaulted from CFG-03's single
|
||||
@@ -588,6 +588,36 @@ def _entry_bms_floor(entry: dict):
|
||||
return None
|
||||
|
||||
|
||||
def _flat_max_w(opts: dict) -> float:
|
||||
"""The single-inverter shape's cap, tolerating what rule 9 tolerates.
|
||||
|
||||
⚠️ THIS USED TO BE A BARE `float(opts.get("max_w", 2000))` AND IT
|
||||
DISAGREED WITH THE VALIDATOR. `validate.py`'s `_number()` reads `""` as
|
||||
"use the default", so rule 9 looks at an empty `max_w` box, sees 2000 and
|
||||
is content - while the bare `float("")` here raised ValueError from inside
|
||||
`Controller.__init__`. Through UX-02's panel that surfaced as HTTP 500 on a
|
||||
non-JSON body, which the page reported as "cannot reach the add-on": untrue,
|
||||
and for the most prominent limit field's most likely transient state.
|
||||
|
||||
That inverted this module's own docstring rule - "keep the two in step: a
|
||||
shape this tolerates that rule 9 does not name is a shape that runs" - in
|
||||
the other direction: a shape rule 9 TOLERATED could not be composed at all.
|
||||
The roster path never had the bug, because `_entry_max_w` below already
|
||||
guards; only the flat path did.
|
||||
|
||||
Empty or absent is the default, exactly as `_number()` reads it.
|
||||
Unparseable is 0.0, exactly as `_entry_max_w` reads it - and rule 9 refuses
|
||||
it by name at line 790, with a sentence, before a control loop exists.
|
||||
"""
|
||||
raw = opts.get("max_w", 2000)
|
||||
if raw is None or raw == "":
|
||||
return 2000.0
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _entry_max_w(entry: dict) -> float:
|
||||
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
|
||||
|
||||
|
||||
@@ -304,7 +304,36 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
one. That was the first shape of this and it disagreed with itself.
|
||||
"""
|
||||
flat = str(opts.get("setpoint_entity", "") or "").strip()
|
||||
units = build_roster(opts, None)
|
||||
|
||||
# ⚠️ THE ROSTER IS BUILT BEFORE THE VALIDATOR RUNS, AND IT PARSES NUMBERS.
|
||||
# `build_roster` reaches `inverter.py`'s `float(opts.get("max_w", 2000))`,
|
||||
# which raises on a value the validator has a SENTENCE for. Cleared the
|
||||
# `max_w` box and the panel answered HTTP 500 - not JSON, so the page said
|
||||
# "cannot reach the add-on", which was untrue - while `validate.py`'s
|
||||
# "max_w ... is not a number" was unreachable through the panel and reachable
|
||||
# at startup, where `main.py` validates before it composes. That made this
|
||||
# surface WEAKER than the startup gate for one field, which is the opposite
|
||||
# of what this module claims below.
|
||||
#
|
||||
# A malformed scalar therefore falls back to validating WITHOUT device
|
||||
# bounds. Those bounds come from the roster, and there is no roster; the
|
||||
# validator's own complaint about the unparseable value is the answer the
|
||||
# operator needs, and it is strictly more informative than a 500.
|
||||
try:
|
||||
units = build_roster(opts, None)
|
||||
except (TypeError, ValueError) as exc:
|
||||
# ⚠️ AND IT MUST NOT RETURN AN EMPTY LIST. An empty list means "no
|
||||
# problems", and the page enables Save on it. If the roster could not be
|
||||
# composed at all and the validator has nothing to say about why, that
|
||||
# disagreement is itself the finding - so it is reported rather than
|
||||
# swallowed. Returning `[]` here would turn a crash into a silent
|
||||
# acceptance, which is worse than the 500 this replaced.
|
||||
found = list(validate_startup_config(opts))
|
||||
if not found:
|
||||
found = [f"the configuration could not be composed ({type(exc).__name__}: "
|
||||
f"{exc}), and the validator did not name a reason. This is a "
|
||||
f"disagreement between the two and should be reported."]
|
||||
return found, []
|
||||
if hass is not None:
|
||||
async def _limits(unit):
|
||||
if unit.setpoint_entity:
|
||||
|
||||
@@ -247,6 +247,103 @@ check("...and the caller gets the new token back, so the next save can "
|
||||
res["fingerprint"] == selfconfig.fingerprint(_good))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2b. ⚠️ THE TWO HALVES OF THE max_w FIX, EACH PINNED SO IT CANNOT BE UNDONE")
|
||||
# Review found that BOTH halves reverted with the whole 24-file suite still
|
||||
# green - including the half that turns a crash into a SILENT ACCEPTANCE, which
|
||||
# is the more dangerous of the two and was a wrong first attempt at this very
|
||||
# fix. A fix nothing grades is a fix that comes back. MUTATION.md habit 4.
|
||||
from app.inverter import _flat_max_w, build_roster # noqa: E402
|
||||
from app.validate import _number # noqa: E402
|
||||
|
||||
# ⚠️ THIS CHECK EXISTS BECAUSE THE OBVIOUS ONE HAS A HOLE. "problems_and_ranges
|
||||
# does not raise" is ALSO satisfied by the backstop swallowing the exception and
|
||||
# returning [] - which is the silent-acceptance bug half two exists to prevent.
|
||||
# Measured: with BOTH halves reverted, every max_w check below reported ok and
|
||||
# only the forced-boom pins went red. So half one is pinned HERE, on the
|
||||
# composition itself, where the backstop cannot answer for it.
|
||||
_raised = None
|
||||
try:
|
||||
build_roster(base(max_w=""), None)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_raised = exc
|
||||
check("⭐ THE COMPOSITION ITSELF TOLERATES A CLEARED max_w - not merely the "
|
||||
"backstop above it. `validate.py`'s `_number()` reads '' as the default, "
|
||||
"so rule 9 sees 2000 and is content; a bare `float('')` in the flat path "
|
||||
"raised instead, inverting inverter.py's own rule that a shape rule 9 "
|
||||
"tolerates must be composable",
|
||||
_raised is None)
|
||||
|
||||
# HALF ONE: composition tolerates exactly what rule 9 tolerates.
|
||||
for raw in ("", None, 0, False, True, -1, "2000", " 2000 ", "2e3", 1e18):
|
||||
o = base(max_w=raw)
|
||||
expect = _number(o, "max_w", 2000.0)
|
||||
if expect is not None:
|
||||
check(f"_flat_max_w agrees with _number() for max_w={raw!r}",
|
||||
_flat_max_w(o) == expect)
|
||||
|
||||
_raised = None
|
||||
try:
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(base(max_w=""), None))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_raised, _probs = exc, None
|
||||
check("⚠️ A CLEARED max_w BOX ANSWERS WITH A VERDICT, NOT AN EXCEPTION. It "
|
||||
"used to raise ValueError from inside Controller.__init__, which the "
|
||||
"panel served as HTTP 500 on a non-JSON body and the page rendered as "
|
||||
"'cannot reach the add-on' - untrue, for the most prominent limit "
|
||||
"field's most likely transient state",
|
||||
_raised is None)
|
||||
check("...and it agrees with the validator, which reads '' as the default",
|
||||
_probs == list(validate_startup_config(base(max_w=""), None, None)))
|
||||
|
||||
_raised = None
|
||||
try:
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(base(max_w="abc"), None))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_raised, _probs = exc, None
|
||||
check("an UNPARSEABLE max_w answers with the validator's own sentence",
|
||||
_raised is None and any("is not a number" in p for p in (_probs or [])))
|
||||
|
||||
# HALF ONE, THE PART THAT MAKES IT SAFE RATHER THAN MERELY NOT-CRASHING:
|
||||
# clearing the box on the LIVE shape must FAIL CLOSED, not silently halve the
|
||||
# rail. `unit_caps_indexed` reads the flat rail through the same `_number`, so
|
||||
# the rail collapses to 2000 in lockstep and rule 10 catches the collapse.
|
||||
_live = base(max_w=5000, slew_w=400)
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(dict(_live), None))
|
||||
check("the live shape (5000 W rail, slew_w 400) starts", _probs == [])
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(dict(_live, max_w=""), None))
|
||||
check("⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the "
|
||||
"rail collapsing to the 2000 default under an explicit 400 W slew, so "
|
||||
"the box cannot be emptied into a quietly halved rail",
|
||||
any("site rail of 2000 W" in p for p in _probs))
|
||||
|
||||
# HALF TWO: the backstop cannot report "no problems" for a config it could not
|
||||
# compose. Returning [] there enables Save - a crash became a green.
|
||||
_orig_roster = selfconfig.build_roster
|
||||
|
||||
|
||||
def _boom(*_a, **_kw):
|
||||
raise ValueError("forced boom")
|
||||
|
||||
|
||||
selfconfig.build_roster = _boom
|
||||
try:
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(base(), None))
|
||||
finally:
|
||||
selfconfig.build_roster = _orig_roster
|
||||
check("⚠️ THE BACKSTOP NEVER RETURNS AN EMPTY LIST. A roster that cannot be "
|
||||
"composed while the validator names no reason is itself the finding - "
|
||||
"returning [] would turn a crash into a silent acceptance and enable "
|
||||
"Save, which is worse than the 500 it replaced",
|
||||
_probs != [] and len(_probs) == 1)
|
||||
check("...and it says so in words, naming the exception",
|
||||
any("could not be composed" in p and "forced boom" in p
|
||||
for p in _probs))
|
||||
check("...and the patch was undone, so nothing below inherits it",
|
||||
selfconfig.build_roster is _orig_roster)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. THE OTHER SURFACE WRITES WHILE THIS PAGE IS OPEN")
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# Without a `translations/` directory the Configuration tab renders the raw
|
||||
# option KEYS - `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w`.
|
||||
# The frontend keeps a field when `e.name in options || e.required`, and all 71
|
||||
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY are
|
||||
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY-ONE are
|
||||
# rendered, always, in `schema:` order, with no description. This file is what
|
||||
# turns that into sentences.
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user