SAFETY-24: an energy floor under the peak guard's forced discharge
checks / unit (pull_request) Successful in 3m29s

SAFETY-07 can command a discharge to hold a billed quarter down, and
nothing below it consulted the state of charge. On the 2026-08-20 replay
it asks for 878 Wh in a day. Relying on the BMS is not absurd - it is a
real floor and it works - but it is the pack's protection rather than the
operator's, and a month of aggressive quarters leaves the battery
repeatedly at that cut-out with no EMS-side record of why. The first
symptom is a pack never available when wanted, which is indistinguishable
from the SoC drift MAINT-01 exists to correct.

decide() now takes soc and soc_floor, both required and neither
defaulted, and discharge_permitted() is the one place the question is
answered. Above the floor nothing changes. At or below it the guard files
a 0 W floor instead of a sized one - one-directional, so charging stays
blocked and no discharge is capped - and reports floor_limited with the
reason it bound. It is not a trip: every stop in 11.2's table still
outranks the claim, and the floor is never negative, so 9.1's asymmetry
survives as a property of the object (0 negative floors over 1296 swept
combinations, SoC included).

The SoC is graded only after `wanted > 0` is established, so it is never
evaluated where it governs nothing. An unreadable SoC is floor-limited,
not a full battery - a full battery is exactly the value that would
disarm the floor, and this project shipped that defaulting once already.

Failing to shave is not silent: one notification per episode
(goodwe_peak_soc_floor), distinct from the saturation alarm because the
two ask for opposite operator actions, plus its own status row and an
action-log line.

Verified by replay. The 2026-08-20 capture is driven against a modelled
pack sized from the day's own measured appetite, so it genuinely
exhausts: without the floor it reaches the BMS cut-out and holds there
for 1227 guard cycles; with it the pack stops at 20.0 % and never touches
the BMS. The energy withheld equals the energy between the two floors to
within one cycle at the rail. The honest half is reported too - the worst
quarter goes 4338 W -> 5030 W, because a floor-limited guard shaves less
on purpose.

Two new options, mirrored once in peakguard.PEAK_OPTION_DEFAULTS
(TARIFF-02's pattern), imported by validate.py and main.py rather than
typed as literals and pinned to config.yaml by a check that reads the
file - so CFG-03 still tracks three places. validate.py rule 8 refuses
peak_soc_floor <= bms_soc_floor at startup, in CFG-01/MAINT-04's manner.
maintenance_soc_floor is deliberately not reused and not renamed: it is a
drain target, not a spend limit, and the rename is recorded as a decision
to revisit if a third floor appears.

Eleven mutations of the shipped source were run against the checks; all
eleven go red and there are no survivors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
glenn schrooyen
2026-08-31 00:29:36 +02:00
co-authored by Claude Opus 5
parent e7c95bfd8a
commit e709554857
7 changed files with 1177 additions and 62 deletions
+54
View File
@@ -131,6 +131,25 @@ NOT READY banner already names the problem, (b) does not apply.
installed base is on a fixed or static tariff. Refusing to start without a
price feed would lock those sites out of the add-on entirely.
8. `peak_soc_floor` at or below `bms_soc_floor`. [(a)]
SAFETY-24, and the same shape as 1, 4, 5 and 6. The peak guard can COMMAND
a discharge to hold a billed quarter down (SAFETY-07), and `peak_soc_floor`
is the EMS-side bound on how far down it may take the pack. The BMS has its
own cut-out and enforces it regardless. Set the EMS floor at or below that
cut-out and THE BMS WINS: the EMS bound can never be the thing that stops
the discharge, so the add-on carries an energy backstop that is incapable
of binding while its status page reports one - rule 5's "a disarmed guard
that advertises itself as armed", on the guard whose failure mode is a pack
that is quietly never available when it is wanted.
⚠️ `<=`, NOT `<`, and the equality case is the interesting one. At exactly
equal, the EMS floor and the pack's cut-out act at the same percent, so the
EMS floor buys ZERO margin - it can never prevent the pack reaching the BMS
limit, which is the entire thing this option exists to prevent. That is not
"close to the rail", it is the disarmed case with an extra step. Rule 6
permits equality because equality there leaves one reachable point that
works; here it leaves none.
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
@@ -151,11 +170,16 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
not refused.
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
every ordering of the two is a legitimate site configuration. Refusing on
an ordering would be inventing a relationship the system does not have.
"""
import logging
from .p1 import is_enabled, validate_meter_options
from .peakguard import PEAK_OPTION_DEFAULTS
from .price import PROVIDERS
from .static_tariff import ProfileInvalid, parse_static_profile
@@ -369,6 +393,36 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# --- 7. two price sources on one site ------------------------------------
problems.extend(_price_problems(opts))
# --- 8. an EMS energy floor the BMS does not already enforce -------------
# ⚠️ THE FALLBACKS ARE peakguard.PEAK_OPTION_DEFAULTS, NOT LITERALS, and
# that is CFG-03 read as a rule rather than as a complaint. Rule 6's own
# comment documents a LIVE divergence in this repo caused by typing 2000
# here and 2500 in main.py; this card refuses to add a second instance of
# it. The dict is the single in-code mirror, pinned to config.yaml by
# test_peakguard.py, and main.py reads the same one.
soc_floor = _number(opts, "peak_soc_floor",
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
bms_floor = _number(opts, "bms_soc_floor",
PEAK_OPTION_DEFAULTS["bms_soc_floor"])
if soc_floor is None or bms_floor is None:
problems.append(
"peak_soc_floor and bms_soc_floor must both be numbers (got "
f"{opts.get('peak_soc_floor')!r} / {opts.get('bms_soc_floor')!r})")
elif soc_floor <= bms_floor:
problems.append(
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
f"(bms_soc_floor) is {bms_floor:.0f} %, so SAFETY-07's forced "
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
"the pack's protection rather than yours. The guard would report a "
"SoC floor on its status page while being incapable of ever being "
"the thing that stops the discharge, and the symptom of that is a "
"battery repeatedly sitting at the BMS limit with no record of why "
f"- which looks exactly like the SoC drift the maintenance cycle "
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
f"(the shipped default is "
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
"bms_soc_floor if it does not match this pack.")
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
integ_w = None if not integ else _number(opts, "integrator_max_w", None)