9.2 KiB
SAFETY-07 - the quarter-peak projection guard (with SAFETY-24 and CFG-03)
Status: SHIPPED. Tests: test_peakguard.py. Written down by MAINT-20 from
the long module comment in app/peakguard.py, so the module can stay short.
Later changes have their own notes: SAFETY-25, SAFETY-32, SAFETY-45,
SAFETY-79, SAFETY-81, SAFETY-87, SAFETY-90, SAFETY-125,
SAFETY-126.
The hazard
The capacity tariff bills the month's single worst 15-minute average offtake,
with no averaging and no forgiveness. specs.md:583 records one 4998 W quarter
that moved a monthly peak from 4832 W to 5013 W and billed the whole month at
it. SAFETY-18 alarms on a frozen meter (meter_freeze_s, 300 s) but a frozen
meter can rail the command in 75 s (freeze.py's table, max_w: 5000, meter at
3000 W or more), against a 900 s quarter. The guard is the cap on that. It
reads freeze.py's looks_frozen(); it is not a second freeze detector.
The mechanism: one lo limit at P_PEAK
specs.md 10.1 row 2 wants pre-emptive discharge near the limit and a hard clamp on charging above it; 9.1 says discharge and export are never capped by the peak guard. A lower bound on the setpoint is the only shape that does both: a floor cannot stop the loop discharging harder (so 9.1 holds by structure), it clamps charging when above the standing charge, and it forces discharge when above zero.
P_PEAK(50) is aboveP_LOOP(10) and belowP_MAINTENANCE(60) andP_SAFETY(100). arbiter clause 2 applies only limits at or above the winning set's priority, so a P_SAFETY 0 W stop is not pulled off zero.- A limit does not out-vote another limit: clause 2 takes the most restrictive
with no priority between limits. The floor (
lo >= 0) and the maintenance charge-only shaping (hi <= 0) therefore resolve tolo > hi, a contradiction, which the arbiter answers with 0 W (battery parked in the worst quarter). The rule for two crossing limits is suppression, not priority: main.py drops the shaping while the guard is engaged (shaping_suspended()). The floor is clamped toachievable_wto close the only other crossing pair (the P_SAFETY rating bounds). - Arbitrage arrives as a SET below
P_PEAK, so clause 2 binds it with this floor without any edit here (9.1: peak shaving outranks arbitrage).
The floor is derived
With projected = (acc + held * remaining) / 900, the watts that must come off
the meter are excess_w = 900 * (projected - ceiling) / remaining, and the
floor is floor_w = batt_w + excess_w.
- The claim is filed while the floor is positive, not while the excess is.
excessfalls to zero as soon as the battery is already removing it, and a guard that released there handed the actuator back and re-engaged next cycle. The first cut did that on 2026-08-20's billed quarter: 17 engage/release cycles in 900 s, and the quarter still ended 49 W over. Thresholdingbatt_w + excess(the offtake with the battery at zero, less the level the quarter can afford) decays continuously, so there is no hysteresis constant. 10.2: an EMS that oscillates is worse than none. - The gain 900/remaining is 1.0 at the start of a quarter and grows. The guard is gentlest when its evidence is weakest. No minimum-evidence threshold was added: it would delay the guard on a sustained rail from second zero.
batt_wis the measured battery power, not the command. The bill is what flowed, and a floor built on the command winds up to the rail when the inverter cannot follow; one built on the measurement stops rising when delivery does.
The ceiling
ceiling_w = max(peak_cap_w, the month's banked peak). Once a month's peak
stands at 5013 W, holding later quarters to 3500 W buys nothing. Only an
OBSERVED reading may raise it; NO_RECORD and NOT_OBSERVED leave it at
peak_cap_w, so a peak store that cannot be read makes the guard stricter,
never looser. That is also why the guard does not use headroom_w(): a peak
read as 0 W returns full headroom, which is a guard that is not there.
SAFETY-79 aims peak_margin_w under the ceiling; the ceiling stays the real
limit for reports and the bill.
Blindness comes first
The projection is graded only if the quantity under it was measured. Three ways it was not, checked before grading:
- No open block: a fresh averager answers an all-zero
Projection, which sits under every ceiling. That is "no quarter", not "the quarter is fine". - Coverage: a meter gap longer than
max_hold_sleaves seconds the averager does not accumulate (holding a dead meter's value forward is how a fabricated peak gets onto a permanent record), so the projection reads low. The threshold ismax_hold_s, not a new number. SAFETY-90 fills gaps once readings return. - Frozen: samples keep arriving, coverage is perfect and the projection is a confident zero while the real meter is at the rail.
The blind action is floor_w = 0.0: no importing while the billed quantity
cannot be observed. It is one-directional (forbidding charge only lowers
offtake), so a false blindness costs a charge and never pack energy.
Why it trips where SAFETY-18 only notifies
The healthy-day false-engagement rate is zero (2026-08-23, 95 quarters; test_peakguard.py re-measures it from the committed capture). The action is level-triggered and unlatched: it is re-derived each cycle and releases when the projection drops, so a false engagement costs one cycle, not a parked battery. It is a limit, so every safety stop outranks it. It is the only guard whose trigger is the billed quantity itself.
Saturation is reported, not alarmed as a bug
10.3 treats an out-of-range setpoint as a bug (SAFETY-03). A peak floor above
the inverter rating is not: it is a 5900 W house on a 5000 W inverter (2026-08-20
15:45). The floor is clamped to achievable_w, the decision carries
saturated, and main.py raises one notification.
SAFETY-24: the energy backstop the guard shipped without
The graded branch can force discharge (878 Wh in a day on the 2026-08-20 replay)
and nothing below it consulted the state of charge; the BMS was the only
backstop, which protects the pack and not the operator. decide() now takes
soc and soc_floor and discharge_permitted() answers.
- Not a trip. When it binds, the guard stops asking for more discharge and commands nothing; every stop in 11.2 still outranks it.
- The action is
floor_w = 0.0(forbids charging, forbids nothing else). Alolimit cannot cap discharge, and a negative floor would destroy that. The loop is not bounded: self-consumption may still drain the pack past the floor. SAFETY-25 measured it (2026-08-20, 19:00-22:00 UTC, 830 W mean: the pack ends at the BMS floor, 10.0 %, whateverpeak_soc_flooris) and the owner decided on 2026-09-02 that the BMS stays the only backstop for demand-driven discharge, a loop-side reserve being deferred to ALLOC-01. - Graded only after
wanted > 0, where a forced discharge is asked for. - An unreadable SoC is not a full battery.
soc=Noneandsoc_floor=Noneare floor-limited. Both arguments are required keyword-only, no default (TEST-14). - Failing to shave is not silent:
floor_limited=Trueand one notification. It excludessaturated, since it asks for nothing.
The action log says "the peak reserve", not "the SoC floor": peak_soc_floor
(“Peak reserve”) bounds this guard only, and the wider words would contradict
a loop that is draining the pack at that moment.
CFG-03: two options, mirrored once
peak_soc_flooris the EMS floor, notmaintenance_soc_floor(the monthly recalibration's drain target); sharing a key would couple features whose right values differ. Default 20: the reference pack is 300 Ah at 51.2 V, DoD 90, 13.8 kWh usable; the replay's 878 Wh is 6.4 %; a floor 10 points above the 10 % cut-out leaves about 1.6 worst days of room.bms_soc_flooris the pack's own cut-out (register 0x0560, 10 on the reference unit). It exists so validate.py rule 8 can refuse apeak_soc_floorat or below it.- Both defaults live once in
PEAK_OPTION_DEFAULTS(TARIFF-02's pattern), imported by validate.py and main.py and pinned to config.yaml by the test. The module reads no option itself. maintenance_soc_flooris the ambiguous name of the two. Decision: keep it, carry the distinction in DOCS.md; revisit if a third floor appears.- SAFETY-07 itself added no option:
peak_cap_wwas already validatedint(500,15000), and 14'scapacity_peak_limit_kwstays declined (a kW key beside a W key is the mismatch trap).
Per-pack floors (SAFETY-32) and phases
bms_soc_floor became a roster key; pack_floor_pct() conjoins it with the
site's EMS floor, and rule 8 names the unit it refuses. peak_soc_floor stayed
one number for the site, so with cut-outs of 10 and 18 the operator sets it
above 18; the per-pack EMS floor is the open question in SAFETY-32. The
min() over measured SoC (Controller.pack_floor_soc) is still right for the
site verdict; pack_permissions() is the per-pack answer.
Nothing here reads a per-phase quantity: the sum/max/net choice is an open
owner question, defaulted to NET inside billed_w(), so this file does not
change whichever way it is answered.