SAFETY-40: the integrator bound is not a BUG SIGNAL; numbers from the clamp tick
The clamp alarm gave `i_clamped` (normal anti-windup) the over-rating BUG SIGNAL text, printed `Decision.i_w` as "the loop asked for", and printed `self.target` (after slew/freeze) as "was clamped to". - clamp.loop_clamp_note(): i_clamped says "the loop's integrator reached its N W anti-windup bound" with this tick's command; clamped keeps BUG SIGNAL with the requested command and the clamp's own result. - Decision carries i_bound_w / requested_w / clamped_w, set at the clamp. - i_clamped still counts and notifies (same id, cadence, SAFETY-41 escalation) but as a WARNING, amber row, plain title. A bug has its own notification budget and its text is not overwritten within an episode. - test_clamp.py part 9: red on the old code per defect; 5 mutations killed. - DOCS.md alarm section (and its stale 30 s cadence), DECISIONS/SAFETY-40. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
59415ea83f
commit
fd14569a71
@@ -73,6 +73,7 @@ An alarm wired only to this module's `ClampResult.engaged` would therefore be
|
||||
silent on the exact configuration the whole ticket is about. The winding is
|
||||
visible one layer up, at the integrator bound, and that is why `Decision` now
|
||||
carries `i_clamped` / `clamped` BOOLEANS and the caller ORs them in here.
|
||||
They alarm in different words: see `loop_clamp_note()` (SAFETY-40).
|
||||
|
||||
AND WHY BOOLEANS RATHER THAN `Decision.reason`. `reason` is a single string that
|
||||
later stages overwrite: a cycle that clamps the integrator and is then slew
|
||||
@@ -200,9 +201,8 @@ REASON_CODE = "OUTPUT_CLAMP"
|
||||
# ceiling rests on one number a test author chose; if that number is ever
|
||||
# revisited, this constant moves with it.
|
||||
#
|
||||
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
|
||||
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
|
||||
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
|
||||
# ⚠️ THIS WAS A STOP-GAP; the wording and the numbers were SAFETY-40's, fixed
|
||||
# in `loop_clamp_note()` below. Nothing here is
|
||||
# suppressed: every engagement is still counted, the action-log row is written on
|
||||
# every engaged cycle regardless, and the rising edge of every episode alarms
|
||||
# unconditionally, so no alarm is DROPPED - only delayed.
|
||||
@@ -234,6 +234,31 @@ CLEAR_CYCLES = 3
|
||||
SUSTAINED_CYCLES = 3
|
||||
|
||||
|
||||
BUG_SIGNAL = ("This is a BUG SIGNAL, not a limit being used: something "
|
||||
"upstream computed a setpoint the actuator cannot deliver.")
|
||||
|
||||
|
||||
def loop_clamp_note(d) -> str:
|
||||
"""The loop-side alarm text for one `control.Decision`, or "".
|
||||
|
||||
SAFETY-40: the integrator bound is anti-windup doing its job and says so;
|
||||
only a command over the rating is a BUG SIGNAL. Every number is the
|
||||
decision's own, i.e. from the tick that clamped.
|
||||
"""
|
||||
if d.clamped:
|
||||
return (f"{REASON_CODE}: the loop computed {d.requested_w:+.0f} W, over "
|
||||
f"the {abs(d.clamped_w):.0f} W rating, and it was clamped to "
|
||||
f"{d.clamped_w:+.0f} W; the loop's command this cycle was "
|
||||
f"{d.target_w:+.0f} W. {BUG_SIGNAL}")
|
||||
if d.i_clamped:
|
||||
return (f"{REASON_CODE}: the loop's integrator reached its "
|
||||
f"{d.i_bound_w:+.0f} W anti-windup bound; the loop's command "
|
||||
f"this cycle was {d.target_w:+.0f} W. Brief "
|
||||
f"engagements after a large load or solar step are normal. If it "
|
||||
f"persists, check that the battery is following its commands.")
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
"""What the guard did, with the RAW value preserved.
|
||||
@@ -342,23 +367,51 @@ class ClampWatchdog:
|
||||
# spoken yet - the two the escalation clause needs.
|
||||
self.run_len = 0
|
||||
self.spoken = False
|
||||
# SAFETY-40. Did this episode include a genuine over-rating (`bug`), and
|
||||
# has THAT spoken? A bug has its own budget, so anti-windup notices can
|
||||
# never delay it; with bug=True throughout, cadence is as before.
|
||||
self.bug = False
|
||||
self.bug_spoken = False
|
||||
self.last_bug_alarm: datetime | None = None
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
|
||||
def _due(self, last, now) -> bool:
|
||||
return last is None or (now - last).total_seconds() >= REASSERT_S
|
||||
|
||||
def _speak(self, now, bug) -> bool:
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
if bug:
|
||||
self.last_bug_alarm = now
|
||||
self.bug_spoken = True
|
||||
return True
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime,
|
||||
bug: bool = True) -> bool:
|
||||
"""Advance one cycle. True when the caller should raise the alarm.
|
||||
|
||||
`now` is the caller's clock, the same discipline as
|
||||
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`.
|
||||
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`. `bug` False
|
||||
is the integrator bound (SAFETY-40): counted and notified like the
|
||||
rest, but it never replaces a bug's text within an episode.
|
||||
"""
|
||||
if engaged:
|
||||
self.engagements += 1
|
||||
self.clean_cycles = 0
|
||||
self.reason = reason
|
||||
self.run_len += 1
|
||||
if not self.active:
|
||||
rising = not self.active
|
||||
if rising:
|
||||
self.active = True
|
||||
self.since = now
|
||||
self.episodes += 1
|
||||
self.spoken = False
|
||||
self.bug = False
|
||||
self.bug_spoken = False
|
||||
if bug or not self.bug:
|
||||
self.reason = reason
|
||||
self.bug = self.bug or bug
|
||||
if bug and not self.bug_spoken and self._due(self.last_bug_alarm, now):
|
||||
return self._speak(now, True)
|
||||
if rising:
|
||||
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
|
||||
# not used to be. It fired unconditionally, so a condition that
|
||||
# engages and clears repeatedly raised one notification PER
|
||||
@@ -384,11 +437,8 @@ class ClampWatchdog:
|
||||
# still written on every engaged cycle, and a genuine runaway -
|
||||
# which by definition keeps engaging - is picked up by the
|
||||
# re-assert path within REASSERT_S. Delay, not loss.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
if self._due(self.last_alarm, now):
|
||||
return self._speak(now, self.bug)
|
||||
return False
|
||||
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
|
||||
# budget was spent by an EARLIER, unrelated episode speaks anyway
|
||||
@@ -396,15 +446,10 @@ class ClampWatchdog:
|
||||
# engages every cycle and reaches it; the scattered one-cycle
|
||||
# railing that produced the live flood never does.
|
||||
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return self._speak(now, self.bug)
|
||||
# Still engaged: re-assert on the slow cadence, not every cycle.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
if self._due(self.last_alarm, now):
|
||||
return self._speak(now, self.bug)
|
||||
return False
|
||||
|
||||
self.run_len = 0
|
||||
@@ -420,5 +465,6 @@ class ClampWatchdog:
|
||||
# above. The episode ends; the notification budget does not reset
|
||||
# with it.
|
||||
self.reason = ""
|
||||
self.bug = False
|
||||
self.clean_cycles = 0
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user