# 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 above `P_LOOP` (10) and below `P_MAINTENANCE` (60) and `P_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 to `lo > 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 to `achievable_w` to 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`. 1. The claim is filed while the floor is positive, not while the excess is. `excess` falls 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. Thresholding `batt_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. 2. 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. 3. `batt_w` is 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_s` leaves 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 is `max_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. 1. Not a trip. When it binds, the guard stops asking for more discharge and commands nothing; every stop in 11.2 still outranks it. 2. The action is `floor_w = 0.0` (forbids charging, forbids nothing else). A `lo` limit 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 %, whatever `peak_soc_floor` is) 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. 3. Graded only after `wanted > 0`, where a forced discharge is asked for. 4. An unreadable SoC is not a full battery. `soc=None` and `soc_floor=None` are floor-limited. Both arguments are required keyword-only, no default (TEST-14). 5. Failing to shave is not silent: `floor_limited=True` and one notification. It excludes `saturated`, 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_floor` is the EMS floor, not `maintenance_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_floor` is the pack's own cut-out (register 0x0560, 10 on the reference unit). It exists so validate.py rule 8 can refuse a `peak_soc_floor` at 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_floor` is 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_w` was already validated `int(500,15000)`, and 14's `capacity_peak_limit_kw` stays 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.