`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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user