Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-07-peak-guard.md
T

9.2 KiB

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.