diff --git a/goodwe_controller/DECISIONS/SAFETY-07-peak-guard.md b/goodwe_controller/DECISIONS/SAFETY-07-peak-guard.md new file mode 100644 index 0000000..1d4ef8f --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-07-peak-guard.md @@ -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.