`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:
@@ -940,6 +940,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.
|
||||
@@ -1734,9 +1754,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
|
||||
@@ -2423,6 +2455,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",
|
||||
@@ -2442,7 +2490,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:
|
||||
|
||||
Reference in New Issue
Block a user