SAFETY-32 round 5: stop closing sites, build an instrument that finds its own scope
checks / unit (pull_request) Successful in 4m59s
checks / unit (pull_request) Successful in 4m59s
Rounds 2, 3 and 4 each closed exactly the survivor they were handed and each time
the reviewer found the same shape one step over: the reduction, the call site, the
sibling argument, and now the sibling MODULE. Round 4's enumeration stopped at the
`Controller` class boundary; round 5's four blockers were reductions in validate.py,
over a list DERIVED from the roster three calls away from it. Closing four more sites
by hand would have produced a fifth rejection, so this round widens the instrument
instead and lets it enumerate.
⚠️ THE BLOCKERS ARE NOT EQUIVALENT MUTANTS - a safety refusal disappears, verified by
calling the shipped validator both ways on the real tree:
roster max_w 5000 (inverter-a) beside 2000 (inverter-b), sign_disagree_w 3000
clean -> 1 problem, "...max_w (inverter-b) is 2000 W, so the
SAFETY-02 sign-disagreement watchdog can never fire"
min(caps[:1]) +4 B -> 0 problems. The site STARTS.
caps_ix[:1] +4 B -> 0 problems.
the SAME roster with the small cap FIRST (2000, 5000)
clean -> 1 problem, naming inverter-a
min(caps[-1:]) +5 B -> 0 problems.
⚠️ AND THE ONE-ROSTER FIXTURE COULD NOT HAVE SEEN THAT. On [5000, 2000] the `[-1:]`
mutant still refuses, because last IS min there. A fixture of N members cannot separate
two reductions that agree on N members - which is why the behavioural half below is a
SET and not a roster.
THE INSTRUMENT (test_peakguard.py part 12j, widened past `Controller`)
Every `.py` under app/ is read off disk and parsed - NO MODULE IS NAMED IN THE CHECK -
and the roster is found by transitive closure from ONE seed, the `inverters` option key:
through returns, through lists ACCUMULATED in a loop over a roster (which is exactly
what `caps`, `roster` and `problems` are, and what round 4's rule could not express),
through attributes and properties, and THROUGH CALL ARGUMENTS, which is how it walks
into peakguard, arbiter, allocator, circulation, maintenance and store without being
told they exist. A slice of a roster stays a roster so a mutation cannot cut the taint;
an index is one unit and does not.
183 roster reductions across 9 modules, out of 23 files parsed:
main 96, allocator 24, validate 19, arbiter 14, circulation 10, maintenance 10,
peakguard 4, inverter 3, store 3. 14 modules reduce over no roster at all.
⚠️ THE HAND-WRITTEN MODULE LIST WOULD HAVE BEEN WRONG IN BOTH DIRECTIONS. It named
watchdog.py, which reduces over no roster at all (SAFETY-30's sign watchdogs are
strictly per-unit), and omitted maintenance.py and store.py, which both do. A vacuous
claim about one module and no claim at all about two others - which is the argument for
computing the scope, made with data.
The pin rediscovers ALL FOUR blockers without being told where to look, and finds a
FIFTH beside them (validate.py:783, rule 5's own message) that no review round named.
Its counterfactual runs at two depths: every enumerated site's expression mutated with
each of the four cuts this card has been defeated by (183 x 4 = 732 spellings, all
reported), and `[:1]` written into app/validate.py at each blocker position with the
WHOLE analysis re-run over the mutated source.
THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE
All 183 sites mutated to `[:1]`, +4 B each, against the full 19-file suite in
goodwe-addon-ci (:ro, -B, fresh rsync per mutant, 0 .pyc before and after - a read-only
mount cannot write one): 183/183 RED. ⚠️ That is TAUTOLOGICAL on its own, because the
pin makes every slice red. So the 51 whose first red was test_peakguard.py were re-run
with the pin NEUTRALISED:
145 of 183 a behavioural fixture separates the site from a slice of itself
38 of 183 ONLY the structural claim. Behaviourally UNGRADED.
All 38 are listed by site with what a `[:1]` there would cost, in the DECISIONS record
and in part 12j itself: 15 are rows and counts on the operator's page, 3 are
`len(targets)` arithmetic beside a write path that IS graded, 6 are allocator internals
ALLOC-01 reaches but not with enough units to separate. NONE of the 38 is closed
behaviourally by this card and none is counted as covered.
THE BEHAVIOURAL HALF, because a shape pin grades an identity and never a meaning
test_validate.py grows a ROSTER SET for the three rules that reduce over
`unit_caps_indexed()`: widths 2, 3, 4, 5 with the one small cap at EVERY position,
14 rosters x 3 rules = 42 runs of the real validator, each asserting the refusal fires
AND names the unit the number came off. Beside it a COMPUTED family table of twelve
candidates - max, first, last, the mean, [:1], [-1:], [:2], [:3], [1:], [:-1], [::2] -
each compared against what the shipped validator actually answered on the same roster.
Only `min` agrees everywhere; a member no roster kills is printed as a NAMED SURVIVOR.
⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
Found by reading the three branches side by side, NOT by the pin - and said that way
because it is the shape of what the pin cannot see. `unit_sign_disagree_w` was graded
against `_number(opts, "max_w", 2000.0)`, three lines under a branch that had already
stopped doing that. Both directions were measured before the fix:
two 2000 W units + a leftover `max_w: 5000` + unit_sign_disagree_w 3000 -> 0 problems.
The site started, the status page said `armed`, and the per-inverter watchdog could
not trip on EITHER unit. Identical sentence to the blocker, one branch over.
two 5000 W units with NO flat key -> REFUSED against a 2000 W default the operator
never wrote, for a threshold both units can reach.
It takes the same `weakest` the branch above takes and names the same unit. At a roster
of one `unit_caps()` is `[max_w]`, so the shipped single-inverter refusal is the same
comparison, the same boundary and the same sentence.
MUTATION PROOF (each: bytecode purged and counted 0 before and 0 after, the edit
re-read FROM DISK with the new text present and the old absent, byte delta printed)
B1 validate min(caps) -> min(caps[:1]) +4 B RED test_validate.py
B2 validate min(caps) -> min(caps[-1:]) +5 B RED test_validate.py
B3 validate caps_ix -> caps_ix[:1] +4 B RED test_validate.py
B4 validate rule 6 min -> min(caps_ix[-1:]) +5 B RED test_validate.py
B5 validate rule 5 msg -> min(caps_ix[:1]) +4 B RED test_validate.py
B6 validate rule 5b msg -> min(caps_ix[-1:]) +5 B RED test_validate.py
B7 CONTROL: weakest -> max(0.0, weakest) +10 B GREEN, as intended
R3a peakguard max(floors) -> min(floors) 0 B RED test_peakguard.py
R3b peakguard max(floors) -> max(floors[-2:]) +5 B RED test_peakguard.py
R3c main call site packs -> self.packs[:2] +4 B RED test_peakguard.py
R4a main pack_floor_soc -> vals[:2] +4 B RED test_peakguard.py
R4b main pack_floor_soc -> vals[-2:] +5 B RED test_peakguard.py
R4c main pack_floor_soc -> vals[:3] +4 B RED test_peakguard.py
R4d main cycle() any(...) -> self.inverters[:1] +4 B RED test_watchdog.py
B1-B6 are caught by the BEHAVIOURAL set in test_validate.py, before the pin is reached.
B7 is a deliberate no-op control: `max(0.0, weakest)` is `weakest` for weakest > 0, and
it stays green, so the battery is not merely red for everything.
R3a is a ZERO-byte delta, so the shipped function was called both ways as well:
packs floors [15.0, 30.0, 20.0], peak_soc_floor 15
binding_soc_floor(...) = 30.0 the 0-byte mutant would answer 15.0 (+15.0 pp)
-> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %.
⚠️ PRODUCTION IS DEGENERATE ON THE FLOOR AXIS. SPIKE-04: pack A and pack B BOTH floor
at 10 %, so a differing floor exists ONLY in the fixtures. That is the argument for
keeping them, not for simplifying them - collapsing them deletes the only data in this
repository that can see the reduction at all. Restated in config.yaml's example, in the
probe and in the fixtures.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every second-unit figure here -
4600 W FROM ITS DATASHEET, 15 % illustrative, the whole roster SET - is SIMULATED and
labelled. Only inverter A's 10 % is read off hardware.
REBASED onto 67e649a. Two conflicts with SPIKE-04a, both in the blocks this card had
rewritten (config.yaml's roster example and the probe's C/D sections). Resolved to carry
BOTH claims, and the probe was RE-RUN rather than hand-merged: `all cases passed` on
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, transcript updated with the
date. MUTATION.md habit 8 - a committed transcript stitched together out of two runs is
not a measurement.
Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and verified 0
before and after. CI-06: test_peakguard.py run four times in isolation, green each time
(288 ok lines); test_validate.py three times, green each time.
MUTATION.md gains habits 12 and 13: build the instrument once the same shape has been
found twice AND make it discover its own scope, and say what the instrument cannot see
with sizes in the same breath.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -924,6 +924,248 @@ for _name, _got in sorted(_prov.items()):
|
||||
f"against, and the 4324 W the DC reading would make it {_got}",
|
||||
all(_got.values()))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("rules 5, 5b and 6 on a ROSTER SET - the smallest cap at EVERY position")
|
||||
# ⚠️ SAFETY-32 ROUND 5, AND THE REASON THIS SECTION EXISTS AT ALL. PR #48 was
|
||||
# rejected on FOUR MUTATIONS THAT SURVIVED A 19/19 GREEN SUITE, all of them in
|
||||
# the three lines above:
|
||||
#
|
||||
# validate.py:779 min(caps) -> min(caps[:1]) +4 B GREEN
|
||||
# validate.py:779 min(caps) -> min(caps[-1:]) +5 B GREEN
|
||||
# validate.py:778 caps = [cap for _, cap in caps_ix[:1]] +4 B GREEN
|
||||
# validate.py:893 min(caps_ix[-1:], key=lambda p: p[1]) +5 B GREEN
|
||||
#
|
||||
# NONE of them is an equivalent mutant: each one deletes a safety refusal.
|
||||
# `max_w: 5000` on inverter-a beside `2000` on inverter-b with
|
||||
# `sign_disagree_w: 3000` is refused by the shipped validator with "the
|
||||
# SAFETY-02 sign-disagreement watchdog can never fire" and ACCEPTED by every one
|
||||
# of the four - the site starts, with a watchdog that reports `armed` on the
|
||||
# status page and cannot trip on pack B. That is rule 5's own failure mode
|
||||
# reached THROUGH rule 5.
|
||||
#
|
||||
# WHY THE OLD FIXTURE COULD NOT SEE IT, which is the whole lesson and is now
|
||||
# MUTATION.md habit 10: the fixture was ONE roster, `[5000, 2000]`. On that
|
||||
# roster `min` and `last` and `[-1:]` and `[:2]` all answer 2000. A fixture of N
|
||||
# members cannot separate two reductions that agree on N members, and adding a
|
||||
# member moves the hole instead of closing it.
|
||||
#
|
||||
# SO THE FIXTURE IS A SET, NOT A ROSTER: the smallest cap at EVERY POSITION of
|
||||
# rosters of width 2, 3, 4 and 5. `[:1]` needs width 2 to be caught, `[:2]`
|
||||
# needs 3, `[:3]` needs 4 - the sweep is what terminates the regress that a
|
||||
# single fixture cannot. And the FAMILY TABLE below is COMPUTED AND PRINTED, so
|
||||
# a member no fixture kills shows up as a NAMED SURVIVOR rather than as an
|
||||
# absence from a list nobody re-derived.
|
||||
#
|
||||
# ⚠️ THE DIFFERING CAPS ARE A FIXTURE PROPERTY AND MUST STAY ONE. In production
|
||||
# both packs are expected to floor at 10 % and SPIKE-04a puts inverter B at
|
||||
# 4600 W against A's 5000 W - a 400 W gap, narrower than slew_w. A fixture built
|
||||
# on the real pair would be degenerate on exactly the axis these checks measure.
|
||||
# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST (SPIKE-04, ~2026-09-12). Nothing
|
||||
# in this section is measured off a second unit; the rosters are simulated and
|
||||
# are labelled so.
|
||||
_BIG, _SMALL = 5000.0, 2000.0
|
||||
_RATE = 3000.0 # strictly between them, which is the point
|
||||
|
||||
|
||||
def _roster_n(caps, **over):
|
||||
"""A roster of len(caps) units, unit-0..unit-N, each with its own cap."""
|
||||
o = base(**over)
|
||||
for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"):
|
||||
o.pop(k, None)
|
||||
o["inverters"] = [
|
||||
dict(name=f"unit-{i}", setpoint_entity=f"number.sp{i}",
|
||||
soc_entity=f"sensor.soc{i}", batt_entity=f"sensor.batt{i}",
|
||||
max_w=c, bms_soc_floor=10)
|
||||
for i, c in enumerate(caps)]
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
# 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"),
|
||||
)
|
||||
_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0,
|
||||
"maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0}
|
||||
|
||||
# THE SET: width x position of the one small cap.
|
||||
_SET = []
|
||||
for _n in (2, 3, 4, 5):
|
||||
for _pos in range(_n):
|
||||
_caps = [_BIG] * _n
|
||||
_caps[_pos] = _SMALL
|
||||
_SET.append((_n, _pos, tuple(_caps)))
|
||||
|
||||
# habit 1: the PRECONDITION as its own check. If a later edit makes the caps
|
||||
# equal, this goes red rather than the claims below going quietly vacuous.
|
||||
check("PRECONDITION: every fixture in the set has a UNIQUE smallest cap, that "
|
||||
"cap is at the stated position, and the rate under test sits STRICTLY "
|
||||
"between it and the others - two equal caps could not tell `min` from "
|
||||
"`first` from `last` and the whole set would be measuring nothing",
|
||||
len(_SET) == 14
|
||||
and all(c[p] == _SMALL and c.count(_SMALL) == 1 and _SMALL < _RATE < _BIG
|
||||
for _n2, p, c in _SET)
|
||||
and {p for _n2, p, _c in _SET} == {0, 1, 2, 3, 4})
|
||||
|
||||
|
||||
def _verdict(opts, token):
|
||||
"""(refused?, the unit the message NAMES, the cap it QUOTES)."""
|
||||
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])
|
||||
return (True, m.group(1) if m else None, float(m.group(2)) if m else None)
|
||||
|
||||
|
||||
# --- the shipped answer, on every member of the set -------------------------
|
||||
_rows = []
|
||||
for _opt, _token in _CAP_RULES:
|
||||
for _n, _pos, _caps in _SET:
|
||||
_o = _roster_n(list(_caps), **dict(_QUIET, **{_opt: _RATE}))
|
||||
_rows.append((_opt, _n, _pos, _caps, _verdict(_o, _token)))
|
||||
print(f" {len(_CAP_RULES)} rules x {len(_SET)} rosters = {len(_rows)} runs "
|
||||
f"of the REAL validator (rate {_RATE:.0f} W, caps {_SMALL:.0f}/"
|
||||
f"{_BIG:.0f} W, simulated rosters - inverter B does not exist):")
|
||||
for _opt in sorted({r[0] for r in _rows}):
|
||||
_got = [(r[1], r[2], r[4]) for r in _rows if r[0] == _opt]
|
||||
print(f" {_opt:<22} " + " ".join(
|
||||
f"n{n}p{p}:{'REFUSE ' + str(u) if ok else 'starts'}"
|
||||
for n, p, (ok, u, _c) in _got))
|
||||
|
||||
check("⚠️ EVERY ONE OF THE THREE CAP RULES REFUSES ON EVERY MEMBER OF THE SET, "
|
||||
"and NAMES THE UNIT THE NUMBER CAME OFF - at width 2, 3, 4 and 5, with "
|
||||
"the small cap at every position in turn. A reduction that answered "
|
||||
"`first`, `last`, `[:1]`, `[-1:]`, `[:2]` or `[:-1]` instead of `min` "
|
||||
"misses at least one member of this set, which is what four hand-run "
|
||||
"mutations against a 19/19 green suite proved the single `[5000, 2000]` "
|
||||
"fixture could not do",
|
||||
all(ok and unit == f"unit-{p}" and cap == _SMALL
|
||||
for _o2, _n2, p, _c2, (ok, unit, cap) in _rows))
|
||||
|
||||
check("...and it is a COMPARISON, not a blanket refusal of rosters: the same "
|
||||
"three rates against a roster whose caps can ALL take them start "
|
||||
"cleanly, at every width. Without this the line above is satisfied by a "
|
||||
"validator that refuses everything (habit 2 in the negative direction)",
|
||||
all(_verdict(_roster_n([_BIG] * n, **dict(_QUIET, **{opt: _RATE})),
|
||||
tok) == (False, None, None)
|
||||
for opt, tok in _CAP_RULES for n in (1, 2, 3, 4, 5)))
|
||||
|
||||
# --- the POSITIONAL-SLICE FAMILY TABLE, computed and printed ----------------
|
||||
# ⚠️ COMPUTED, NOT LISTED. Each candidate is applied to the SAME caps the
|
||||
# validator saw, and its (unit, cap) answer is compared against what the shipped
|
||||
# validator actually said on that roster. A candidate that agrees everywhere is
|
||||
# a reduction this fixture set CANNOT distinguish from the shipped one, and it
|
||||
# is printed as a SURVIVOR rather than left out of a table nobody re-derived.
|
||||
def _pick(caps, fn):
|
||||
ix = list(enumerate(caps))
|
||||
try:
|
||||
i, c = fn(ix)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
return (f"unit-{i}", c)
|
||||
|
||||
|
||||
_FAMILY = {
|
||||
"min (SHIPPED)": lambda ix: min(ix, key=lambda p: p[1]),
|
||||
"max": lambda ix: max(ix, key=lambda p: p[1]),
|
||||
"first": lambda ix: ix[0],
|
||||
"last": lambda ix: ix[-1],
|
||||
"min(caps[:1])": lambda ix: min(ix[:1], key=lambda p: p[1]),
|
||||
"min(caps[-1:])": lambda ix: min(ix[-1:], key=lambda p: p[1]),
|
||||
"min(caps[:2])": lambda ix: min(ix[:2], key=lambda p: p[1]),
|
||||
"min(caps[:3])": lambda ix: min(ix[:3], key=lambda p: p[1]),
|
||||
"min(caps[1:])": lambda ix: min(ix[1:], key=lambda p: p[1]),
|
||||
"min(caps[:-1])": lambda ix: min(ix[:-1], key=lambda p: p[1]),
|
||||
"min(caps[::2])": lambda ix: min(ix[::2], key=lambda p: p[1]),
|
||||
"mean": lambda ix: (0, sum(c for _i, c in ix) / len(ix)),
|
||||
}
|
||||
_survivors = []
|
||||
print(" the family table - each candidate against what the SHIPPED "
|
||||
"validator answered on the same roster:")
|
||||
for _name, _fn in _FAMILY.items():
|
||||
_misses = []
|
||||
for _opt, _n, _pos, _caps, (_ok, _unit, _cap) in _rows:
|
||||
_pred = _pick(list(_caps), _fn)
|
||||
_pred_refuses = _pred is not None and _pred[1] > 0 and _RATE >= _pred[1]
|
||||
if _opt == "maintenance_charge_w":
|
||||
_pred_refuses = (_pred is not None and _pred[1] > 0
|
||||
and _RATE > _pred[1])
|
||||
_said = ((True, _pred[0], _pred[1]) if _pred_refuses
|
||||
else (False, None, None))
|
||||
if _said != (_ok, _unit, _cap):
|
||||
_misses.append(f"{_opt[:4]}/n{_n}p{_pos}")
|
||||
if not _misses and _name != "min (SHIPPED)":
|
||||
_survivors.append(_name)
|
||||
print(f" {_name:<16} {'AGREES EVERYWHERE (survivor)' if not _misses else 'differs on ' + str(len(_misses)) + ' run(s), first: ' + _misses[0]}")
|
||||
check("⚠️ AND THE FAMILY TABLE HAS NO SURVIVOR BUT THE SHIPPED REDUCTION. "
|
||||
"Eleven candidates - max, first, last, the mean, and every positional "
|
||||
"slice this card has actually been defeated by - are each computed "
|
||||
"against what the real validator answered on the same roster, and every "
|
||||
"one of them differs somewhere in the set. This is the check that would "
|
||||
"have taken PR #48 red four rounds ago: a member no fixture kills is "
|
||||
"PRINTED AS A NAMED SURVIVOR here instead of being absent from a list "
|
||||
f"nobody re-derived. survivors: {_survivors}",
|
||||
_survivors == [])
|
||||
|
||||
# --- the ROSTER OF ONE, where every one of them is the same thing -----------
|
||||
_at_one = {n: _pick([_SMALL], f) for n, f in _FAMILY.items()}
|
||||
print(f" at a ROSTER OF ONE the twelve candidates answer: "
|
||||
f"{sorted({str(v) for v in _at_one.values()})}")
|
||||
check("...and at a ROSTER OF ONE every candidate that still has a member to "
|
||||
"reduce answers THE SAME THING - which is exactly why no "
|
||||
"single-inverter fixture in this repository could ever have caught any "
|
||||
"of this, and why the shipped single-inverter refusals are what they "
|
||||
"always were. The only two that do NOT coincide are `caps[1:]` and "
|
||||
"`caps[:-1]`, which EMPTY the list and would raise: at N=1 they are "
|
||||
"crashes rather than silent wrong answers, so they are not survivors "
|
||||
"there either",
|
||||
{v for v in _at_one.values() if v is not None} == {("unit-0", _SMALL)}
|
||||
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])
|
||||
|
||||
# --- 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
|
||||
# PIN - and said that way on purpose, because it is the shape of what the pin
|
||||
# cannot see. `unit_sign_disagree_w` is SAFETY-30's, not this card's. It was
|
||||
# graded against `_number(opts, "max_w", 2000.0)`, the FLAT option, three lines
|
||||
# under a branch that had already stopped doing that. Both directions were
|
||||
# measured before the fix and both are asserted here.
|
||||
_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")
|
||||
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 "
|
||||
"WATCHDOG SILENTLY. Two 2000 W units beside a `max_w: 5000` left over "
|
||||
"from a single-inverter install and `unit_sign_disagree_w: 3000` used to "
|
||||
"produce ZERO problems - the site started, the status page said `armed`, "
|
||||
"and the watchdog could not trip on EITHER unit. It is refused now, "
|
||||
"against the weakest unit's own cap, and the message names that unit",
|
||||
_lv == (True, "unit-0", _SMALL))
|
||||
check("...and the over-refusal in the OTHER direction is gone too: a roster of "
|
||||
"two 5000 W units with NO flat `max_w` was refused at 3000 W against a "
|
||||
"2000 W DEFAULT the operator never wrote, for a threshold both units can "
|
||||
"reach. It starts",
|
||||
_verdict(_roster_n([_BIG, _BIG],
|
||||
**dict(_QUIET, unit_sign_disagree_w=_RATE)),
|
||||
"SAFETY-30 per-inverter sign-disagreement")
|
||||
== (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 "
|
||||
"the same comparison, the same boundary and the same sentence",
|
||||
why(validate_startup_config(base(max_w=2000, unit_sign_disagree_w=2000),
|
||||
-5000, 5000), "unit_sign_disagree_w")
|
||||
and validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999),
|
||||
-5000, 5000) == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
Reference in New Issue
Block a user