MAINT-20 part 1: DECISIONS note for the peak guard (SAFETY-07, -24, CFG-03)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-01 01:13:48 +01:00
co-authored by Claude
parent f31307105a
commit e0db3b0e8a
@@ -0,0 +1,166 @@
# 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.