SAFETY-32: bms_soc_floor per pack, rule 8 per unit
checks / unit (pull_request) Successful in 4m38s

`bms_soc_floor` describes A BATTERY, not a site. At two packs of different
models the cut-outs differ, and one number for both graded the shallower pack
against the deeper one's BMS - so a site with cut-outs of 10 % and 30 % under
`peak_soc_floor: 20` STARTED, and the guard would command a forced discharge
that pack B's BMS, not the operator's floor, is the thing that stops. That is
rule 8's own failure mode, reached through rule 8.

Scoped to the card's third bullet, per its PM PROOFREAD: the aggregate
anti-windup (DEV-02) and per-instance `achievable_w` (CFG-04) were verified as
already shipped and are untouched.

* config.yaml: `bms_soc_floor: int(0,50)` REQUIRED per `inverters:` entry.
  Nothing is inherited; the flat key stays the single-inverter shape's own
  cut-out. A per-entry DEFAULT was declined - a default is one number for every
  pack, which is this card's defect - and the decision is recorded.
* inverter.py: `Inverter.bms_soc_floor`; an entry that states none gets None,
  not a borrowed number and not 0.0 (the most permissive value the key has).
* peakguard.py: `Pack`, `pack_floor_pct()`, `pack_permissions()`,
  `binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the
  one place the question is answered.
* validate.py: `unit_bms_floors_indexed()` carries the ROSTER index; rule 8
  grades EVERY unit and NAMES the one it refuses. Graded with the offender
  SECOND and with the same two numbers in the other order.
* main.py: `Controller.packs`; `decide(soc_floor=...)` is the per-pack
  reduction of the same option; the floor-limited notification names the pack
  that stopped the site. `pack_floor_soc` - the emptiest pack, and its
  None-on-missing behaviour - is untouched.
* tools/supervisor_shape_probe.py: RE-RUN and repaired. The required key broke
  §C and left §D printing PASS on a refusal about a different key. §D is now an
  isolated counterfactual over an accepted merged baseline, one key varied,
  asserting the key name. `all cases passed` on 2026.08.0.

Stated rather than hidden: under any config rule 8 accepts the per-pack FLOOR
reduces to `peak_soc_floor` for every pack. The reduction is written for
`unit_caps_indexed()`'s reason and graded on both sides - 1775 rule-8-valid
pairs answer the option, the pair rule 8 refuses answers the cut-out.
`peak_soc_floor` is still one number for the site, so the "deeper pack
needlessly withheld" half is filed as an open question, not closed.

Full 19-file suite green. Twelve source mutations through
tools/mutation_run.sh: eleven red, one argued survivor. The battery also found
`_flat_bms_floor()` defaulting in two places, each covering for the other.

No measurement of inverter B appears anywhere: it does not exist yet. Every
second-pack figure is simulated and labelled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-04 14:11:11 +02:00
co-authored by Claude Opus 5
parent 2d025eebf7
commit d9388c81b0
12 changed files with 1185 additions and 60 deletions
+450 -3
View File
@@ -127,7 +127,7 @@ from app.arbiter import (
from app.control import Tuning, compute, peak_at_risk
from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w
from app import main as app_main
from app.inverter import Inverter
from app.inverter import Inverter, build_roster
from app.main import Controller
from app.p1 import (
PLAUSIBLE_MAX_W, P1Ingest, QUARTER_S, QuarterAverager, make_sample,
@@ -136,8 +136,9 @@ from app.p1 import (
from app.peakguard import (
BLIND_COVERAGE, BLIND_FROZEN, BLIND_NO_BLOCK, CEILING_CAP,
CEILING_MONTH_PEAK, FLOOR_AT_SOC_FLOOR, FLOOR_SOC_UNREADABLE,
PEAK_OPTION_DEFAULTS, PeakDecision, blindness, decide, discharge_permitted,
excess_w, quarter_ceiling_w, uncovered_s,
PEAK_OPTION_DEFAULTS, Pack, PeakDecision, binding_soc_floor, blindness,
decide, discharge_permitted, excess_w, pack_floor_pct, pack_permissions,
quarter_ceiling_w, uncovered_s,
)
from app.validate import validate_startup_config
from app.tariff import (
@@ -3150,6 +3151,374 @@ check("...and the roster it was restored to is the shipped one, with a healthy "
c_src.units == (app_main.UNIT_A,)
and c_src.live_units() == (app_main.UNIT_A,))
# ==============================================================================
print()
print("12. SAFETY-32 - one bms_soc_floor for two packs grades one against the")
print(" other's BMS. Per-entry cut-out, per-pack permission, rule 8 per unit")
# ⚠️ THE TWO FLOORS DIFFER, AND THAT IS THE FIRST CHECK RATHER THAN AN
# ASSUMPTION. This card's acceptance criteria say it in words because the repo
# has now shipped SIX checks whose fixture could not reach the failure it named,
# two of them - DEV-02's `_b2.max_w` and CFG-04's blank `soc_entity` - being
# EXACTLY this shape: a per-unit claim compared against a number the fixture
# happened to have twice. MUTATION.md habit 1: write the precondition as its own
# check, so a later edit that makes them equal goes RED instead of making every
# claim below quietly vacuous.
#
# ⚠️ AND ONLY ONE OF THESE NUMBERS IS MEASURED. `A_PACK_FLOOR` is config.yaml's
# shipped `bms_soc_floor` - register 0x0560 on the reference GW5000S-BP. The two
# B values are SIMULATED: there is no second pack on this site (~8 days out) and
# nothing here is a measurement of one. They are chosen for what they have to
# straddle, and that is stated on each.
A_PACK_FLOOR = BMS_FLOOR
# BELOW peak_soc_floor, so a roster carrying it is one rule 8 ACCEPTS.
B_PACK_FLOOR_SIM = SOC_FLOOR - 2.0
# ABOVE peak_soc_floor: the shallower pack, whose own BMS would win. This is the
# configuration the flat key used to accept and this card refuses.
B_PACK_FLOOR_DEEP_SIM = SOC_FLOOR + 15.0
# A state of charge ABOVE the site floor and BELOW the shallow pack's cut-out -
# the ONE band in which the two packs disagree. Outside it they answer the same
# and the fixture would observe nothing.
SOC_BETWEEN = SOC_FLOOR + 5.0
print(f" floors: A {A_PACK_FLOOR:.0f} % (measured), B {B_PACK_FLOOR_SIM:.0f} % "
f"/ {B_PACK_FLOOR_DEEP_SIM:.0f} % (SIMULATED - inverter B does not exist), "
f"peak_soc_floor {SOC_FLOOR:.0f} %, probe SoC {SOC_BETWEEN:.0f} %")
check("PRECONDITION (MUTATION.md habit 1): the two per-pack floors are "
"DIFFERENT numbers and they straddle peak_soc_floor, and the probe SoC "
"sits strictly between them. Two equal floors cannot show a pack graded "
"against the wrong one, which is the defect this card is about and the "
"defect six checks in this repository have already shipped",
A_PACK_FLOOR != B_PACK_FLOOR_DEEP_SIM
and A_PACK_FLOOR < SOC_FLOOR < B_PACK_FLOOR_DEEP_SIM
and A_PACK_FLOOR < SOC_BETWEEN < B_PACK_FLOOR_DEEP_SIM
and B_PACK_FLOOR_SIM != A_PACK_FLOOR and B_PACK_FLOOR_SIM < SOC_FLOOR)
# --- 12a. AC 1: the permission is answered PER PACK --------------------------
# ⚠️ BOTH PACKS ARE AT THE SAME STATE OF CHARGE. That is the whole design of
# this fixture: SoC is held EQUAL so it cannot be what separates the two
# answers, and the only thing left that can is each pack's own floor. The
# shipped `min()` over SoC - which this card does NOT change - reduces these two
# readings to one number before anything is graded, so after it the two packs
# are literally indistinguishable.
_pk_a = Pack("inverter-a", SOC_BETWEEN, A_PACK_FLOOR)
_pk_b = Pack("inverter-b", SOC_BETWEEN, B_PACK_FLOOR_DEEP_SIM)
_perm = pack_permissions([_pk_a, _pk_b], SOC_FLOOR)
print(f" per-pack at the SAME {SOC_BETWEEN:.0f} %: "
+ "; ".join(f"{n}: {'PERMITTED' if w is None else w}" for n, w in _perm))
check("⚠️ AC 1. Two packs AT THE SAME STATE OF CHARGE, graded against their OWN "
"cut-outs, get OPPOSITE answers: the deeper pack is still permitted and "
"the shallower one is WITHHELD. The SoC is equal on purpose - it is the "
"floors that differ, so this line can only be green if each pack was "
"graded against its own number",
_perm == [("inverter-a", None),
("inverter-b", FLOOR_AT_SOC_FLOOR)])
check("...and the refusal is asserted by its CONTENT, not by the fact that one "
"happened (MUTATION.md habit 6): `not None` is satisfied by an unreadable "
"SoC, by an unconfigured floor and by the at-floor case alike, and those "
"are three different operator actions",
_perm[1][1] == FLOOR_AT_SOC_FLOOR
and _perm[1][1] != FLOOR_SOC_UNREADABLE)
# ⚠️ THE COUNTERFACTUAL, AND IT VARIES EXACTLY ONE THING (habit 7). Same two
# packs, same SoC, same site floor - only B's cut-out moves to A's. Both are
# then permitted. Without this line, 12a would also pass for an implementation
# that refused the second entry always, or the named one, or every roster of two.
_perm_same = pack_permissions(
[_pk_a, Pack("inverter-b", SOC_BETWEEN, A_PACK_FLOOR)], SOC_FLOOR)
print(f" ...and with B's cut-out set to A's: "
+ "; ".join(f"{n}: {'PERMITTED' if w is None else 'withheld'}"
for n, w in _perm_same))
check("...and the counterfactual: give B the SAME cut-out as A and BOTH are "
"permitted. One thing varied, from an accepted baseline - so 12a's "
"difference came from the floor and not from the position, the name, or "
"the roster being two long",
[w for _, w in _perm_same] == [None, None])
# ⚠️ AND THE OTHER AXIS, because a per-pack grader could still read pack[0]'s
# SoC for every pack. Same floors, DIFFERENT SoC: the emptier pack is withheld.
_perm_soc = pack_permissions(
[Pack("inverter-a", SOC_FLOOR + 10.0, A_PACK_FLOOR),
Pack("inverter-b", SOC_FLOOR - 1.0, A_PACK_FLOOR)], SOC_FLOOR)
check("...and with the floors EQUAL and the two SoC readings different, it is "
"the emptier pack that is withheld - so the grader reads each pack's own "
"SoC as well as its own floor. Two mutations, two axes, and a fixture "
"that is degenerate on one of them cannot see the other",
[w for _, w in _perm_soc] == [None, FLOOR_AT_SOC_FLOOR])
# --- 12b. an unstated cut-out is WITHHELD, not defaulted ---------------------
# ⚠️ AT 100 % STATE OF CHARGE, which is the strongest form of the claim: a full
# battery is the one value that disarms every other rung in this file, so if the
# missing floor is what refuses THIS pack, nothing else can be doing it.
_perm_none = pack_permissions([Pack("inverter-b", 100.0, None)], SOC_FLOOR)
print(f" a pack with NO stated cut-out, at 100 %: {_perm_none[0][1]}")
check("a roster entry that states no `bms_soc_floor` is WITHHELD even at a full "
"battery - not defaulted to the flat key, not defaulted to 0 %, and not "
"quietly permitted. An invented cut-out IS this card's defect, and 0 % is "
"the most permissive value the key has",
_perm_none[0][1] is not None
and pack_floor_pct(Pack("b", 100.0, None), SOC_FLOOR) is None
and binding_soc_floor([_pk_a, Pack("b", 100.0, None)], SOC_FLOOR) is None)
# --- 12c. AC 5: at a roster of ONE, the floor is the option, unchanged -------
# ⚠️ SWEPT OVER THE WHOLE SCHEMA, NOT ASSERTED ON THE SHIPPED PAIR. "N=1 is
# byte-identical" is a claim about every configuration the schema accepts, and
# the shipped pair is one point in it. Every rule-8-VALID (peak, bms) pair is
# tried; the reduction must answer the option itself in all of them, or some
# install's floor moved under it.
_SW_LO, _SW_HI = int(read_schema_min("peak_soc_floor")), int(read_schema_max("peak_soc_floor"))
_BMS_HI = int(read_schema_max("bms_soc_floor"))
_n1_pairs = [(pk, bm) for pk in range(_SW_LO, _SW_HI + 1)
for bm in range(0, _BMS_HI + 1) if bm < pk]
_n1_bad = [(pk, bm) for pk, bm in _n1_pairs
if binding_soc_floor([Pack("inverter-a", 50.0, float(bm))],
float(pk)) != float(pk)]
print(f" N=1 sweep: {len(_n1_pairs)} rule-8-valid (peak_soc_floor, "
f"bms_soc_floor) pairs over the shipped schema, {len(_n1_bad)} disagree")
check("⚠️ AC 5, SWEPT. At a roster of ONE the floor the guard grades against IS "
"`peak_soc_floor`, for every pair of values the schema accepts that rule "
"8 would also accept. The reduction is new code on the path of every "
"existing install, so 'nothing changes at N=1' is measured over the whole "
"option space rather than at the shipped point",
len(_n1_pairs) > 500 and _n1_bad == [])
# ...and the case rule 8 refuses is the case the reduction is FOR, so it must
# NOT answer the option there - otherwise the max() above is dead in both
# directions and the mutation that deletes it can never bite.
_n1_deep = binding_soc_floor(
[Pack("inverter-a", 50.0, B_PACK_FLOOR_DEEP_SIM)], SOC_FLOOR)
check("...and on the pair rule 8 REFUSES - a cut-out above the EMS floor - the "
"reduction answers the CUT-OUT and not the option, which is the only "
"direction in which it does anything. Without this line the sweep above "
"would be satisfied by `soc_floor` typed straight through",
_n1_deep == B_PACK_FLOOR_DEEP_SIM and _n1_deep != SOC_FLOOR)
# --- 12d. AC 4: pack_floor_soc's None-on-missing is untouched ---------------
_c32, _h32 = built(TMP / "s32.db")
_b32, _restore32 = second_inverter(_c32)
_c32.inverters[0].soc, _c32.inverters[1].soc = SOC_BETWEEN, SOC_FLOOR + 30.0
check("AC 4: `pack_floor_soc` is still the MINIMUM measured SoC across the "
"roster - this card changed the FLOOR, not the reduction, and DEV-02's "
"comment is the record of why the emptiest pack is right",
_c32.pack_floor_soc == SOC_BETWEEN)
_c32.inverters[1].soc = None
check("...and it is still None - never a number - when ANY pack did not answer, "
"so a silent pack is floor-limited rather than defaulted to full. The "
"per-pack grading added beside it must not have turned an absence into a "
"reading",
_c32.pack_floor_soc is None
and pack_permissions(_c32.packs, SOC_FLOOR)[1][1] == FLOOR_SOC_UNREADABLE)
_c32.inverters[1].soc = SOC_FLOOR + 30.0
# ⚠️ ASSERTED AGAINST LITERALS THAT DIFFER ON ALL THREE AXES (habit 2). Compared
# against `[(inv.name, inv.soc, inv.bms_soc_floor) for inv in ...]` this check
# could not tell "each unit's own three numbers" from "the first unit's three,
# twice"; the tuples below are written out, and no two of them share a value.
_c32.inverters[0].bms_soc_floor = A_PACK_FLOOR
_c32.inverters[1].bms_soc_floor = B_PACK_FLOOR_SIM
print(f" Controller.packs -> {[(p.name, p.soc, p.bms_floor) for p in _c32.packs]}")
check("...and `Controller.packs` carries EACH unit's OWN name, SoC and cut-out "
"- not the first unit's repeated, which is DEV-02's `self.inverters[0]` "
"defect and the one that left all 19 files green",
[(pk.name, pk.soc, pk.bms_floor) for pk in _c32.packs]
== [("inverter-a", SOC_BETWEEN, A_PACK_FLOOR),
("inverter-b", SOC_FLOOR + 30.0, B_PACK_FLOOR_SIM)]
and A_PACK_FLOOR != B_PACK_FLOOR_SIM and SOC_BETWEEN != SOC_FLOOR + 30.0)
_restore32()
# --- 12e. rule 8 grades EVERY unit and NAMES it ------------------------------
def _roster32(a_floor, b_floor, **kw):
"""A two-entry roster, otherwise-startable, differing only in the floors."""
entries = []
for nm, sp, fl in (("inverter-a", "number.sp_a", a_floor),
("inverter-b", "number.sp_b", b_floor)):
e = {"name": nm, "setpoint_entity": sp, "max_w": 2000}
if fl is not None:
e["bms_soc_floor"] = fl
entries.append(e)
return opts24(setpoint_entity="", inverters=entries, **kw)
check("the two-entry fixture with BOTH cut-outs under the EMS floor is "
"ACCEPTED, so every refusal below has nowhere else to come from "
"(MUTATION.md habit 7)",
validate_startup_config(_roster32(A_PACK_FLOOR, B_PACK_FLOOR_SIM)) == [])
# ⚠️ THE OFFENDER IS THE SECOND ENTRY. CFG-04 has just fixed a rule 5/6 bug of
# exactly this shape - a caps-list index fed to a function that indexes the
# ROSTER, so the refusal named the WRONG INVERTER - and an index hardwired to 0,
# or a loop that grades only the first entry, passes a fixture whose offender is
# first. So it is second here, and first in the mirror below.
_r_second = rule8(validate_startup_config(
_roster32(A_PACK_FLOOR, B_PACK_FLOOR_DEEP_SIM)))
# ⚠️ INDEXED THROUGH `_msg()`, NOT `[0]`. The mutation this pair exists to catch
# - rule 8 grading only the first unit - makes this list EMPTY, and a bare `[0]`
# turns the red into an IndexError traceback that kills the file before the
# remaining checks report. A mutation battery wants NAMED reds, not a stack.
def _msg(problems, i=0):
return problems[i] if len(problems) > i else ""
print(f" offender SECOND -> {len(_r_second)} refusal(s): "
f"{_msg(_r_second)[:150] or '(none)'}...")
check("⚠️ AC 3. Rule 8 grades EVERY unit: a roster whose SECOND pack has a "
"cut-out above `peak_soc_floor` is refused, ONCE, and the refusal NAMES "
"inverter-b. Under the flat key this config STARTED, and the guard would "
"then have commanded a forced discharge that pack B's BMS - not the "
"operator's floor - is the thing that stops",
len(_r_second) == 1 and "(inverter-b)" in _msg(_r_second)
and "(inverter-a)" not in _msg(_r_second))
check("...and it names BOTH numbers and the shipped default, so the operator "
"can act on it without reading the source",
f"{B_PACK_FLOOR_DEEP_SIM:.0f} %" in _msg(_r_second)
and f"{SOC_FLOOR:.0f} %" in _msg(_r_second)
and f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %" in _msg(_r_second))
# The mirror. A grader hardwired to index 1 - or one that reports the LAST
# offender - passes the check above and fails this one.
_r_first = rule8(validate_startup_config(
_roster32(B_PACK_FLOOR_DEEP_SIM, A_PACK_FLOOR)))
check("...and with the SAME two numbers in the OTHER ORDER it names "
"inverter-a instead. The pair of checks is what makes this an index "
"check: one of them goes red for any hardwired position",
len(_r_first) == 1 and "(inverter-a)" in _msg(_r_first)
and "(inverter-b)" not in _msg(_r_first))
# And both at once, because a loop that `break`s on the first offender reports
# one refusal for a config with two.
_r_both = rule8(validate_startup_config(
_roster32(B_PACK_FLOOR_DEEP_SIM, B_PACK_FLOOR_DEEP_SIM + 1.0)))
check("...and a roster where BOTH packs offend is refused TWICE, once per unit "
"and each naming its own - so an operator with two wrong packs is not "
"told about one of them and left to discover the other on the next boot",
len(_r_both) == 2 and "(inverter-a)" in _msg(_r_both, 0)
and "(inverter-b)" in _msg(_r_both, 1))
# A missing per-entry cut-out is refused BY NAME rather than defaulted.
_r_missing = rule8(validate_startup_config(_roster32(A_PACK_FLOOR, None)))
print(f" entry with no bms_soc_floor -> {_msg(_r_missing)[:120] or '(none)'}...")
check("...and an entry that states NO `bms_soc_floor` is refused by name rather "
"than defaulted. THE DECISION THIS CARD RECORDS: a per-entry default is a "
"cut-out this add-on invented for a battery it has not been told about, "
"which is the same wrong number the flat key produced. See "
"DECISIONS/SAFETY-32-per-pack-bms-floor.md",
len(_r_missing) == 1 and "(inverter-b)" in _msg(_r_missing)
and "must both be numbers" in _msg(_r_missing))
# --- 12f. nothing is inherited, with the flat key SET AND DIFFERENT ----------
# ⚠️ THE FLAT KEY IS A REAL, DIFFERENT NUMBER HERE, AND THAT IS THE CHECK.
# CFG-04 wrote this exact claim against a fixture whose flat `soc_entity` was
# BLANK, so inherited and not-inherited were the same empty string and the
# mutation that restores the fallback SURVIVED. 30 % is nothing else in this
# file, so an entry that came back holding it can only have borrowed it.
_FLAT_ODD = 30.0
_opts_inh = {"bms_soc_floor": _FLAT_ODD, "setpoint_entity": "",
"inverters": [{"name": "inverter-a", "setpoint_entity": "number.sp_a",
"max_w": 2000, "bms_soc_floor": A_PACK_FLOOR},
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
"max_w": 2000}]}
_r_inh = build_roster(_opts_inh, _Store())
print(f" flat bms_soc_floor {_opts_inh['bms_soc_floor']:.0f} %, roster carries "
f"{[i.bms_soc_floor for i in _r_inh]}")
check("an entry that omits `bms_soc_floor` has NO cut-out and does NOT borrow "
"the flat one - which is set to a real, DIFFERENT number right here, so "
"inherited and not-inherited are visibly different answers. A pack graded "
"against a floor that belongs to another battery is this whole card",
_r_inh[1].bms_soc_floor is None
and _r_inh[0].bms_soc_floor == A_PACK_FLOOR
and _FLAT_ODD not in [i.bms_soc_floor for i in _r_inh])
# ...and at the FLAT shape the same key IS this one pack's cut-out.
_r_flat = build_roster({"setpoint_entity": "number.sp",
"bms_soc_floor": _FLAT_ODD}, _Store())
check("...while at the single-inverter shape the flat key IS that one pack's "
"cut-out, because there the flat keys describe the one inverter. The two "
"readings of the same key are the two shapes, not a fallback",
len(_r_flat) == 1 and _r_flat[0].bms_soc_floor == _FLAT_ODD)
_r_dflt = build_roster({"setpoint_entity": "number.sp"}, _Store())
check("...and with the key absent entirely the one pack takes the SHIPPED "
"default out of the one mirror - so an install that never set it reads "
"the same number the validator grades it against, and CFG-03 gains no "
"fourth defaults site",
_r_dflt[0].bms_soc_floor == BMS_FLOOR
and BMS_FLOOR == PEAK_OPTION_DEFAULTS["bms_soc_floor"])
# --- 12g. the call site: main.py hands decide() the per-pack reduction -------
_floor_call = ast.unparse(next(k.value for k in _call.keywords
if k.arg == "soc_floor"))
print(f" main.py's decide(soc_floor=...) is `{_floor_call}`")
check("main.py hands `decide()` the PER-PACK reduction of `peak_soc_floor`, "
"not the bare option: `binding_soc_floor(self.packs, ...)`, so the floor "
"the site is graded against can never sit below the deepest cut-out on "
"the roster. The key and the one defaults mirror are still in that "
"expression - graded three parts above - because this card moved the "
"REDUCTION and not the source",
"binding_soc_floor" in _floor_call and "self.packs" in _floor_call
and '"peak_soc_floor"' in _floor_call.replace("'", '"'))
# --- 12h. the operator-facing half, driven through the shipped method -------
# ⚠️ RUN, NOT GREPPED. CFG-04's review found a substring test over main.py that
# the defeating mutation walked straight through, so this drives the shipped
# `check_peak_floor_limited()` on a real two-pack Controller and reads what the
# operator's notification actually says.
async def _notify32(packs_socs, floors):
ctl, hs = built(TMP / f"s32n{len(packs_socs)}{floors[-1]:.0f}.db")
inv_b, _ = second_inverter(ctl)
for inv, so, fl in zip(ctl.inverters, packs_socs, floors):
inv.soc, inv.bms_soc_floor = so, fl
ctl.peak_guard = PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=CAP_W, ceiling_from=CEILING_CAP,
projected_w=CAP_W + 500.0, excess_w=500.0, wanted_w=500.0,
floor_limited=True, soc=min(packs_socs),
soc_floor=binding_soc_floor(ctl.packs, SOC_FLOOR),
soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x")
ctl._peak_floor_limited = False
await ctl.check_peak_floor_limited()
return hs.last_message("goodwe_peak_soc_floor")
_body32 = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_notify32([SOC_FLOOR + 30.0, SOC_FLOOR - 1.0], [A_PACK_FLOOR, A_PACK_FLOOR]))
print(f" two-pack notification tail: ...{_body32[-200:]}")
check("the notification an operator reads at 19:00 NAMES THE PACK that stopped "
"the site, and names only that one. 'the battery is at its floor' on a "
"site with two batteries is an instruction to go and look at both, and "
"the wrong one is the one that is fine",
"SAFETY-32" in _body32 and "inverter-b" in _body32
and "inverter-a" not in _body32.split("WHICH PACK")[-1])
# The mirror again, on the SoC axis: swap which pack is empty and the name
# swaps. A hardwired `self.inverters[1].name` passes the line above.
_body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_notify32([SOC_FLOOR - 1.0, SOC_FLOOR + 30.0], [A_PACK_FLOOR, A_PACK_FLOOR]))
check("...and with the SAME two readings swapped between the packs it names "
"inverter-a instead - so the sentence follows the measurement rather "
"than a position in the roster",
"inverter-a" in _body32b
and "inverter-b" not in _body32b.split("WHICH PACK")[-1])
# ⚠️ AC 5, THE OTHER HALF: THE SAME METHOD ON A ROSTER OF ONE MUST PRINT WHAT IT
# ALWAYS PRINTED. This card adds a sentence to an alarm every existing install
# already receives, so "byte-identical at N=1" is asserted against the shipped
# text rather than assumed - and against the text of the very body the two-pack
# run above produced, minus its added sentence.
async def _notify32_one():
ctl, hs = built(TMP / "s32n1.db")
ctl.inverters[0].soc = SOC_FLOOR - 1.0
ctl.peak_guard = PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=CAP_W, ceiling_from=CEILING_CAP,
projected_w=CAP_W + 500.0, excess_w=500.0, wanted_w=500.0,
floor_limited=True, soc=SOC_FLOOR - 1.0,
soc_floor=binding_soc_floor(ctl.packs, SOC_FLOOR),
soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x")
ctl._peak_floor_limited = False
await ctl.check_peak_floor_limited()
return hs.last_message("goodwe_peak_soc_floor")
_body32one = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_notify32_one())
check("⚠️ AC 5. At a roster of ONE the same notification carries NO per-pack "
"sentence at all - byte for byte the message SAFETY-25 shipped. There is "
"one battery, naming it is noise, and this alarm reaches every existing "
"install",
"SAFETY-32" not in _body32one and "WHICH PACK" not in _body32one
and _body32one == _body32.split("\n\nSAFETY-32")[0]
and "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body32one)
# ==============================================================================
print()
print("11. mutation: what defeats these guards, and which check bites")
@@ -3288,6 +3657,78 @@ MUT.append(("the grid-following loop discharging past this floor",
f"pins the claim-source set, so building the floor takes this "
f"part RED instead of leaving this row quietly stale"))
# ---- SAFETY-32's own defeats -------------------------------------------------
# ⚠️ RE-IMPLEMENTED IN PROCESS, exactly as SAFETY-07's B1/B2/B3 are: no source
# file is edited, so no `.pyc` is consulted and MUTATION.md's stale-bytecode
# defect cannot reach these rows. The SOURCE-EDIT battery is on the PR, run
# through `tools/mutation_run.sh`.
# 14. `build_roster()` letting an entry inherit the flat `bms_soc_floor` - this
# card's own defect, rebuilt in three lines, and CFG-04's exact mutation.
MUT.append(("`build_roster()` inheriting the flat `bms_soc_floor` for an entry "
"that omits it",
_r_inh[1].bms_soc_floor is None and _FLAT_ODD != A_PACK_FLOOR,
"part 12f: 'an entry that omits bms_soc_floor has NO cut-out'. ⚠️ "
"The flat key is set to 30 %, a number nothing else in this file "
"has, precisely so inherited and not-inherited are different "
"answers - CFG-04 shipped this claim against a BLANK flat key and "
"watched the mutation survive"))
# 15. the permission graded against the site's ONE floor, i.e. the shipped
# behaviour this card removes. Re-derived on 12a's own two packs.
_mut15 = [discharge_permitted(p.soc, SOC_FLOOR) for p in (_pk_a, _pk_b)]
MUT.append(("the permission graded against the ONE `peak_soc_floor` instead of "
"each pack's own cut-out - the shipped behaviour",
_mut15 != [w for _, w in _perm],
"part 12a: 'two packs AT THE SAME STATE OF CHARGE get OPPOSITE "
"answers'"))
# 16. every pack graded against packs[0]'s floor - DEV-02's `self.inverters[0]`
# defect and CFG-04's index borrow, in this card's costume.
_mut16 = [discharge_permitted(p.soc, pack_floor_pct(_pk_a, SOC_FLOOR))
for p in (_pk_a, _pk_b)]
MUT.append(("every pack graded against the FIRST pack's floor",
_mut16 != [w for _, w in _perm],
"part 12a, and it is the fixture that makes it visible: the two "
"floors differ, so borrowing pack A's answers a different question"))
# 17. `binding_soc_floor()` returning the bare option - the `max()` dropped.
MUT.append(("`binding_soc_floor()` returning `soc_floor` unreduced",
_n1_deep != SOC_FLOOR and _n1_deep == B_PACK_FLOOR_DEEP_SIM,
"part 12c: 'on the pair rule 8 REFUSES the reduction answers the "
"CUT-OUT and not the option'. ⚠️ THE N=1 SWEEP ALONE CANNOT SEE "
"THIS - it is 1775 pairs on which the two agree, which is the "
"point of pairing it with a case on which they must not"))
# 18. an unstated per-entry cut-out defaulted to 0 % rather than withheld - the
# most permissive value the key has, invented from an absence.
MUT.append(("an unstated per-entry cut-out defaulted to 0 %",
pack_floor_pct(Pack("b", 100.0, None), SOC_FLOOR) is None
and discharge_permitted(100.0, 0.0) is None,
"part 12b: 'a roster entry that states no bms_soc_floor is "
"WITHHELD even at a full battery'"))
# 19. rule 8 grading only the first unit, i.e. the flat rule left in place.
MUT.append(("rule 8 grading only the FIRST unit",
_r_second != [] and "(inverter-b)" in _msg(_r_second),
"part 12e: 'a roster whose SECOND pack has a cut-out above "
"peak_soc_floor is refused'. ⚠️ The offender is the SECOND entry "
"for exactly this row"))
# 20. rule 8 naming a hardwired unit - CFG-04's rule 5/6 bug, which is why
# `unit_bms_floors_indexed()` carries the roster index.
MUT.append(("rule 8's refusal naming a hardwired inverter",
"(inverter-b)" in _msg(_r_second) and "(inverter-a)" in _msg(_r_first),
"part 12e: the two orders. Either hardwired index passes one of "
"them and fails the other"))
# 21. the notification naming a fixed roster position rather than the pack that
# is actually at its floor.
MUT.append(("the per-pack notification naming a fixed roster position",
"inverter-a" in _body32b
and "inverter-b" not in _body32b.split("WHICH PACK")[-1],
"part 12h: the same two readings swapped between the packs"))
for label, caught, where in MUT:
print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}")
print(f" {where}")
@@ -3302,6 +3743,12 @@ check("...and the seventh is honestly reported as not caught, because it cannot
"same zero into FULL freedom, which part 3 shows is why headroom_w() is "
"not consumed here", not MUT[6][1]
and quarter_ceiling_w(CAP_W, _zero_peak)[0] == CAP_W)
check("...and SAFETY-32's own eight defeats are each caught too - the flat "
"cut-out inherited, the site floor graded in place of the pack's, the "
"first pack's floor borrowed for all, the reduction dropped, an absent "
"cut-out defaulted to 0 %, rule 8 grading one unit, rule 8 naming a "
"hardwired one, and the alarm naming a position instead of a pack",
all(m[1] for m in MUT[13:21]) and len(MUT) == 21)
check("...and so is the last one, which is not a defeat at all but the EDGE OF "
"THIS CARD: the grid-following loop can still take the pack past this "
"floor, because the floor bounds the peak guard's own forced discharge "