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:
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.
|
||||
Reference in New Issue
Block a user