SAFETY-81 review: 10 s hold, ERROR line per episode, more scenarios
checks / unit (pull_request) Successful in 20m31s
infra-pin / fresh (pull_request) Successful in 2s

- The alarm must hold for peakguard.ALARM_HOLD_S (10 s) on the projection's
  clock. A load that tops cap + battery for ~5 s every 30 s alarmed at every
  quarter start while the quarter landed under the aim (sine4500_fast).
- A cycle with no battery reading leaves the hold alone.
- Notified once per quarter, but the ERROR line is written per episode.
- New sim scenarios (sine4500_fast, twice7000, step6500_gap) with 5a4cdc2
  digests; control still byte-identical. Unit checks for export and the
  hold across a missing reading. test_peakguard's saturated fixture moves
  the hold start back instead of waiting it out.
- DECISIONS and DOCS updated: costs of the hold, tolerance reasoning, the
  double count in control left to a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 20:58:37 +02:00
co-authored by Claude Opus 5.5
parent 6eb67c0f9c
commit f967073cfe
7 changed files with 170 additions and 47 deletions
+30 -14
View File
@@ -926,6 +926,7 @@ class Controller:
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
@@ -4825,38 +4826,53 @@ class Controller:
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.
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. SAFETY-81: and at most one per quarter.
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
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 batt is not None and prev is not None:
if 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
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(
self._peak_proj, batt_w=batt, ceiling_w=g.ceiling_w,
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
# 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(
"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.