167 lines
9.2 KiB
Markdown
167 lines
9.2 KiB
Markdown
# 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.
|