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
+127
View File
@@ -360,6 +360,29 @@ distinction in DOCS.md and in this comment. Revisit if a THIRD floor appears -
a loop-side guard would be the third - at which point the migration buys enough
to be worth its cost.
------------------------------------------------------------------------------
SAFETY-32: ONE `bms_soc_floor` FOR TWO PACKS GRADES ONE AGAINST THE OTHER'S BMS
`bms_soc_floor` above describes A PACK, not a site, and it was a flat option
because there was one pack. At two, two batteries of different models have
different cut-outs, and one number for both is wrong in the direction that
empties a battery: the SHALLOWER pack (the higher cut-out) is protected by a
floor that belongs to the DEEPER one. So it became a roster key - `Pack` carries
each unit's own value, `pack_floor_pct()` conjoins it with the site's EMS floor,
and validate.py rule 8 grades EVERY unit and NAMES the one it refuses.
⚠️ `peak_soc_floor` DID NOT MOVE, AND THE RESIDUAL IS RECORDED RATHER THAN
HIDDEN. It is one number for the site, so with cut-outs of 10 % and 18 % the
operator must set it above 18 and the deeper pack is then withheld above its own
need. That half - a per-pack EMS floor - is NOT this card and must not be
smuggled in with it; DECISIONS/SAFETY-32-per-pack-bms-floor.md states it as the
open question it is.
⚠️ AND THE MIN() OVER MEASURED SoC IS STILL RIGHT. `Controller.pack_floor_soc`
reduces to the emptiest pack and this card does not touch it. What changed is
the FLOOR it is graded against, and the addition of a per-pack answer beside the
site's one verdict - see `pack_permissions()`.
------------------------------------------------------------------------------
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
@@ -572,6 +595,110 @@ def discharge_permitted(soc, soc_floor) -> str | None:
return None
@dataclass(frozen=True)
class Pack:
"""SAFETY-32. ONE pack's three numbers for the floor question, NAMED.
`name` the roster name, because a refusal that does not say WHICH pack
stopped the site sends an operator to the wrong device - the
same defect CFG-04's `unit_caps_indexed()` was written for.
`soc` THIS pack's measured state of charge. None when its sensor did
not answer, and None is not a full battery.
`bms_floor` THIS pack's OWN cut-out percent (`bms_soc_floor`), which is a
property of the hardware in the cupboard and NOT of the site.
None when the roster entry did not state one - see
`pack_floor_pct()` for why that is withheld and not 0 %.
⚠️ IT CARRIES THE CUT-OUT, NOT THE EMS FLOOR. `peak_soc_floor` is one number
for the whole site (the depth a money-saving discharge may never reach) and
stays a flat option; `bms_soc_floor` describes a battery and became a roster
key here, because two packs of different models have different cut-outs and
grading both against one of them is wrong in the direction that empties the
shallower one.
"""
name: str
soc: float | None
bms_floor: float | None
def pack_floor_pct(pack, soc_floor):
"""The percent THIS pack may not be spent below, or None if that is unknown.
`max(the site's EMS floor, this pack's own cut-out)` - the CONJUNCTION, and
the direction matters: the EMS floor is what the operator asked for and the
cut-out is what the hardware will do anyway, so a pack is protected by
whichever is higher. Taking the site floor alone is the shipped defect this
card removes (the shallower pack graded against the deeper one's cut-out);
taking the cut-out alone would throw SAFETY-24's margin away.
⚠️ AN UNSTATED CUT-OUT IS None, NOT ZERO, AND NOT THE FLAT OPTION. 0 % would
be a floor that permits everything - the most permissive value there is,
invented from an absence - and borrowing the flat `bms_soc_floor` is CFG-04's
"nothing is inherited" broken in one line, which is this card's whole subject
restated. `discharge_permitted()` already treats a None floor as NO
permission, so an entry that forgot the key costs a shave rather than a pack.
⚠️ AND UNDER A rule-8-VALID CONFIG THIS IS `soc_floor`, FOR EVERY PACK.
validate.py rule 8 refuses `peak_soc_floor <= bms_soc_floor` on EVERY unit,
so `max()` selects the EMS floor everywhere the add-on will actually start -
which is exactly why the reduction is written here rather than assumed at the
call site. "Another rule refuses that config" is a claim about today's rule
ordering, not a property of this function, and `validate_startup_config()` is
also `sim/inject.py`'s entry point. `unit_caps_indexed()`'s docstring records
the same argument, and CFG-04's index desynchronisation is what happened the
last time it was left implicit.
"""
if soc_floor is None or pack.bms_floor is None:
return None
return max(float(soc_floor), float(pack.bms_floor))
def pack_permissions(packs, soc_floor) -> list[tuple[str, str | None]]:
"""SAFETY-32. Per pack: None if it may be spent, else the reason it may not.
ONE `(name, reason)` pair per pack, in roster order, from the SAME
`discharge_permitted()` the site verdict uses - a second transcription of
"may this pack be spent" is a second answer to it, which is the argument
that made that predicate public in the first place.
⚠️ THIS IS THE OBSERVABLE THE MIN() CANNOT PRODUCE. `Controller.pack_floor_soc`
reduces the measured SoC to the emptiest pack BEFORE anything is graded, and
that reduction is right for the site's one verdict - but it has thrown away
which pack it came from and how far that pack was from ITS OWN floor by the
time the answer exists. Two packs at 15 % against floors of 10 % and 20 %
have opposite answers and are indistinguishable after the `min()`.
"""
return [(p.name, discharge_permitted(p.soc, pack_floor_pct(p, soc_floor)))
for p in packs]
def binding_soc_floor(packs, soc_floor):
"""The ONE floor `decide()` may grade `pack_floor_soc` against, at a roster.
MAXIMUM over the packs' own floors, and it is the conservative reduction that
pairs with the MINIMUM `pack_floor_soc` reduction: `min(soc) <= max(floor)`
is implied by "some pack is at or below its own floor", so this verdict is
never more permissive than grading each pack separately would be. The other
direction is not true and is not claimed - a site can be refused here while
every pack individually has headroom, which costs a shave and never a pack.
None when ANY pack's floor is unknown, for `Controller.site_dev_bound()`'s
reason and in the same direction: `discharge_permitted()` treats a None floor
as no permission, so an unknown floor withholds rather than defaults.
⚠️ AT N=1 THIS IS `soc_floor` ITSELF under any config rule 8 accepts, and
test_peakguard.py part 12 asserts that over the whole schema range rather
than assuming it.
"""
if not packs:
return None if soc_floor is None else float(soc_floor)
floors = [pack_floor_pct(p, soc_floor) for p in packs]
if any(f is None for f in floors):
return None
return max(floors)
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
max_hold_s=None, frozen: bool = False) -> PeakDecision:
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.