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