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-05 03:01:57 +02:00
committed by glenn schrooyen
co-authored by Claude Opus 5
parent 67e649ad6b
commit f9e7fbf86d
12 changed files with 1193 additions and 63 deletions
+51 -3
View File
@@ -1065,6 +1065,26 @@ class Controller:
return None
return float(min(float(v) for v in vals))
@property
def packs(self) -> list:
"""SAFETY-32. Each pack as its own THREE numbers, in roster order.
`pack_floor_soc` above is the site's ONE verdict and stays exactly what
DEV-02 made it - the emptiest measured pack. This is the thing that
reduction cannot produce: by the time `min()` has answered, WHICH pack
the number came from and how far that pack was from ITS OWN cut-out have
both been thrown away, and two packs at 15 % against cut-outs of 10 % and
20 % have opposite answers that are indistinguishable afterwards.
⚠️ EVERY unit, live or not. Same reason as `inputs_ok`: a pack whose SoC
did not answer is a pack nothing can grade, and dropping it from this
list would let the site spend the packs that DID answer while one sat
unmeasured. `peakguard.discharge_permitted()` treats a None SoC as no
permission, which is where that must be decided.
"""
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
for inv in self.inverters]
@property
def inputs_ok(self) -> bool:
"""The site meter, and EVERY pack's own two readings.
@@ -1881,9 +1901,21 @@ class Controller:
# protect. None stays None: `decide()` treats it as floor-limited,
# which costs a shave rather than a pack.
soc=self.pack_floor_soc,
soc_floor=float(self.o.get(
# ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN
# CUT-OUTS. `peak_soc_floor` is still one site-wide EMS floor and is
# still read from that one key with CFG-03's one mirror behind it;
# what `binding_soc_floor()` adds is the conjunction with each pack's
# OWN `bms_soc_floor`, so the floor this site is graded against can
# never be below the deepest cut-out on the roster. MAXIMUM here
# pairs with the MINIMUM in `pack_floor_soc`: both reductions move
# the verdict towards refusing a shave and away from spending a pack.
# Under any config rule 8 accepts it IS the option, for every pack -
# asserted over the whole schema range in test_peakguard.py part 12
# rather than assumed, because "another rule refuses that" is a claim
# about rule ordering and sim/inject.py does not run rule 8 at all.
soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get(
"peak_soc_floor",
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])),
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))),
max_hold_s=self.p1.averager.max_hold_s,
# ⚠️ SAFETY-20. The device range goes in TOO, and this call site is
# the one that needed it most: `standing` is `last_write`, i.e. the
@@ -2596,6 +2628,22 @@ class Controller:
# notification that matters into a traceback.
_at_floor = ("at the floor" if g.soc_floor is None
else f"at {float(g.soc_floor):.0f} %")
# ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor
# the decision was actually taken with, so this re-grades nothing and
# cannot disagree with the alarm it is printed inside. At a roster of one
# the sentence is omitted entirely and this notification is byte-for-byte
# what SAFETY-25 shipped - there is exactly one pack and naming it would
# be noise on every existing install.
_blocked = [name for name, why
in peakguard.pack_permissions(self.packs, g.soc_floor)
if why is not None]
_which = ("" if len(self.inverters) < 2 else
f"\n\nSAFETY-32 - WHICH PACK: this is graded per pack against "
f"that pack's own BMS cut-out (bms_soc_floor), and the "
f"pack(s) at or below their own floor right now are "
f"{', '.join(_blocked) if _blocked else '(none - see the reason above)'}"
f". The other packs on the roster are not the reason the guard "
f"stopped and raising their cut-out will not change this.")
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_peak_soc_floor",
"title": "GoodWe: the capacity-peak guard has run out of battery",
@@ -2615,7 +2663,7 @@ class Controller:
f"{_at_floor} - ordinary self-consumption has no SoC "
f"floor at all and will go on discharging past it, down to the "
f"pack's own BMS cut-out. What has stopped is the guard buying "
f"you a lower bill, not the pack being spent."),
f"you a lower bill, not the pack being spent." + _which),
})
async def check_circulating(self) -> None: