SAFETY-32: bms_soc_floor per pack, rule 8 per unit

`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 21:42:07 +02:00
committed by glenn schrooyen
co-authored by Claude Opus 5
parent 3934ad7ea5
commit 041ec0a61b
12 changed files with 1185 additions and 60 deletions
@@ -40,8 +40,15 @@ DECISIONS/CFG-04-inverter-roster.md:
reconstruction happens to lack FIRST, which is an artefact of the
reconstruction and NOT evidence about `inverters`. B is that evidence.
C. A two-inverter blob validates, per-entry types enforced.
D. An entry missing `name`, `setpoint_entity` or `max_w` is refused BY NAME,
and a JSON string where the list belongs is refused outright.
D. An entry missing `name`, `setpoint_entity`, `max_w` or `bms_soc_floor` is
refused BY NAME - one key varied from an ACCEPTED baseline, and the key's
own name asserted in the refusal - and a JSON string where the list belongs
is refused outright.
⚠️ RE-RUN 2026-09-04 FOR SAFETY-32, which made `bms_soc_floor` a REQUIRED
per-entry key. That change broke §C and, worse, left §D printing PASS on a
refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C
now carries the key and §D is an isolated counterfactual asserting the key name.
"""
import sys, json, copy
sys.path.insert(0, "/usr/src/supervisor")
@@ -145,13 +152,22 @@ show("the same blob MERGED with these defaults - what App.options actually is",
f"max_w={out.get('max_w')!r}" if ok else out)
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
# ⚠️ SAFETY-32 ADDED `bms_soc_floor` AS A REQUIRED PER-ENTRY KEY, and this blob
# is what proves the operator can still POST a two-inverter config. The TWO
# VALUES DIFFER ON PURPOSE (10 and 15): equal values would not exercise a
# per-entry number at all, and this file's own §B is the record of what an
# instrument that cannot distinguish its two cases is worth.
# ⚠️ NEITHER IS A MEASUREMENT OF INVERTER B. Only inverter A's 10 % is read off
# hardware (register 0x0560 on the reference GW5000S-BP); there is no second
# pack on this site, so B's 3000 W and its 15 % are BOTH placeholders in a
# schema-shape probe and must not be quoted as anything else.
TWO = dict(LIVE, setpoint_entity="", inverters=[
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
"max_w": 5000},
"max_w": 5000, "bms_soc_floor": 10},
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
"max_w": 3000}])
"max_w": 3000, "bms_soc_floor": 15}])
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
# The validated blob, for anyone who wants to POST it. Optional: the probe's
@@ -162,14 +178,38 @@ except OSError:
pass
print("\n== D. what the schema itself refuses, per entry ==")
for title, entry in (
("no name", {"setpoint_entity": "number.b", "max_w": 3000}),
("no setpoint_entity", {"name": "inverter-b", "max_w": 3000}),
("no max_w", {"name": "inverter-b", "setpoint_entity": "number.b"}),
("max_w over the range", {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 9999})):
ok, out = validate(dict(TWO, inverters=[entry]))
show(f"an entry with {title} is refused", not ok, out)
ok, out = validate(dict(TWO, inverters="[]"))
# ⚠️ ONE KEY VARIED FROM AN ACCEPTED BASELINE, AND THE REFUSAL IS ASSERTED BY
# NAME - §B's two lessons, applied here because SAFETY-32 is what broke this
# section. These entries used to omit several keys at once and assert a bare
# `not ok`; the moment `bms_soc_floor` became required, the "no max_w" case went
# on printing PASS while the refusal it printed said `Missing option
# 'bms_soc_floor'`. A refusal of unknown provenance is not a measurement of
# anything, so the baseline below is COMPLETE and each case deletes exactly one
# key - or, for the range case, moves exactly one value out of range.
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 3000,
"bms_soc_floor": 10}
# MERGED with the shipped defaults, exactly as §C is and as App.options is: the
# raw LIVE blob is missing root keys unrelated to this section (`maintenance_
# start_hour` on the run that caught it), and a baseline refused for one of THOSE
# is the provenance problem §B2 is kept as a warning about.
D_BASE = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO))
ok, out = validate(dict(D_BASE, inverters=[D_FULL]))
show("the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere "
"else to come from", ok, json.dumps(out.get("inverters")) if ok else out)
for title, entry, expect in (
("no name", {k: v for k, v in D_FULL.items() if k != "name"}, "name"),
("no setpoint_entity",
{k: v for k, v in D_FULL.items() if k != "setpoint_entity"}, "setpoint_entity"),
("no max_w", {k: v for k, v in D_FULL.items() if k != "max_w"}, "max_w"),
("no bms_soc_floor (SAFETY-32)",
{k: v for k, v in D_FULL.items() if k != "bms_soc_floor"}, "bms_soc_floor"),
("max_w over the range", dict(D_FULL, max_w=9999), "at most 5000"),
("bms_soc_floor over the range (SAFETY-32)",
dict(D_FULL, bms_soc_floor=99), "at most 50")):
ok, out = validate(dict(D_BASE, inverters=[entry]))
show(f"an entry with {title} is refused, and the refusal NAMES it",
(not ok) and expect in str(out), out)
ok, out = validate(dict(D_BASE, inverters="[]"))
show("a JSON STRING where the list belongs is refused", not ok, out)