Merge release/1.0 (SAFETY-81, #155) into opt11-p4-solar-forecast

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-29 22:08:52 +02:00
co-authored by Claude Opus 5.5
7 changed files with 558 additions and 9 deletions
+46 -5
View File
@@ -929,6 +929,11 @@ 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_high_since = None
self._peak_floor_limited = False
self._maint_shape_suspended = False
# OPT-11. Battery modes, off by default so an install that has not
@@ -3821,8 +3826,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
@@ -4925,22 +4932,56 @@ 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, for `ALARM_HOLD_S`. 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: the notification and action
log line at most once per quarter; the ERROR line once per episode.
"""
g = self.peak_guard
saturated = bool(g is not None and g.saturated)
batt, prev = self.site_batt_w, self._peak_batt_prev
if batt is None:
# Unknown, not "fine": the hold below neither grows nor restarts.
return
self._peak_batt_prev = batt
if prev is not None:
batt = min(float(batt), float(prev))
p = self._peak_proj
high = bool(
g is not None and g.saturated and p is not None
and p.block_start is not None
and peakguard.load_too_high(
p, batt_w=batt, ceiling_w=g.ceiling_w,
achievable_w=self.site_rating_w))
# SAFETY-81: and for ALARM_HOLD_S, on the projection's clock.
saturated = False
if high:
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
if self._peak_high_since is None:
self._peak_high_since = t
saturated = ((t - self._peak_high_since).total_seconds()
>= peakguard.ALARM_HOLD_S)
else:
self._peak_high_since = None
was, self._peak_saturated = self._peak_saturated, saturated
if not saturated or was:
return
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
hint=messages.hint("peak_saturated"))
_LOG.error(
"Switch off large loads, the battery alone cannot keep this "
"15-minute period under the peak limit: %s.",
messages.reason_code(g.reason))
_LOG.debug("Evidence: %s.", g.reason)
# SAFETY-81: notified once per quarter; a later episode is logged only.
if p.block_start == self._peak_alarm_block:
return
self._peak_alarm_block = p.block_start
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
hint=messages.hint("peak_saturated"))
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
# is not a frozen meter and is not an out-of-range setpoint.