SAFETY-81: no false 'Load too high' alarms
checks / unit (pull_request) Successful in 16m11s
infra-pin / fresh (pull_request) Successful in 1s

The alarm now also needs peakguard.load_too_high(): with the battery at
full power, the quarter still ends more than 10 W over the real limit.
The battery reading is the lower of this cycle's and the last, so a meter
sample from before the battery's last increase is not counted twice.
At most one alarm per quarter. Control outputs unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goodwe-agent
2026-09-29 19:55:38 +02:00
parent 5a4cdc273e
commit 6eb67c0f9c
6 changed files with 433 additions and 7 deletions
+28 -3
View File
@@ -922,6 +922,10 @@ class Controller:
"peak_margin_w", peakguard.PEAK_OPTION_DEFAULTS["peak_margin_w"]))
self._peak_engaged = False
self._peak_saturated = False
# SAFETY-81: what the alarm is graded on, beside the guard's decision.
self._peak_proj = None
self._peak_batt_prev = None
self._peak_alarm_block = None
self._peak_floor_limited = False
self._maint_shape_suspended = False
# OPT-11. Battery modes, off by default so an install that has not
@@ -3712,8 +3716,10 @@ class Controller:
# is not measured on that path, and the honest report is NOT COVERED -
# the same answer, for the same reason, that the status page already
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
self._peak_proj = (None if not self.p1_enabled
else self.peak.project(self.p1.averager, now))
self.peak_guard = None if not self.p1_enabled else peakguard.decide(
self.peak.project(self.p1.averager, now),
self._peak_proj,
# ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is
# ALREADY removing from the meter, and the meter sees every
# inverter. One unit's power here would size the floor from a
@@ -4816,15 +4822,34 @@ class Controller:
available to it. That is the one state where the money is going to be
spent and a human is the only remaining actuator.
SAFETY-81: `g.saturated` alone is not enough. It is graded against the
aim, and a 1 s meter spike, noise or the last seconds of a quarter set
it while the quarter lands on target; `peakguard.load_too_high()` must
agree too. The battery reading it gets is the lower of this cycle's and
the last one's - see there.
Edge-triggered, and de-asserting arms it again - the same shape as
FreezeWatchdog's `evaluate()`, and for the same reason: one notification
per episode, not one per cycle.
per episode, not one per cycle. SAFETY-81: and at most one per quarter.
"""
g = self.peak_guard
saturated = bool(g is not None and g.saturated)
batt, prev = self.site_batt_w, self._peak_batt_prev
self._peak_batt_prev = batt
if batt is not None and prev is not None:
batt = min(float(batt), float(prev))
saturated = bool(
g is not None and g.saturated and self._peak_proj is not None
and peakguard.load_too_high(
self._peak_proj, batt_w=batt, ceiling_w=g.ceiling_w,
achievable_w=self.site_rating_w))
was, self._peak_saturated = self._peak_saturated, saturated
if not saturated or was:
return
# SAFETY-81: at most once per quarter. Near its threshold the test
# above flickers with the meter, and each flicker would re-alarm.
if self._peak_proj.block_start == self._peak_alarm_block:
return
self._peak_alarm_block = self._peak_proj.block_start
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
hint=messages.hint("peak_saturated"))
_LOG.error(