SAFETY-40: the integrator bound is not a BUG SIGNAL; numbers from the clamp tick
checks / unit (pull_request) Successful in 8m47s
infra-pin / fresh (pull_request) Successful in 1s

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:
glenn schrooyen
2026-09-26 22:37:01 +02:00
co-authored by Claude Opus 5.5
parent 59415ea83f
commit fd14569a71
6 changed files with 340 additions and 54 deletions
+39 -22
View File
@@ -49,7 +49,8 @@ from .circulation import (
circulating_w, opposing,
)
from .clamp import (
REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, is_commandable,
BUG_SIGNAL as CLAMP_BUG_SIGNAL, REASON_CODE as CLAMP_REASON, ClampWatchdog,
clamp_to_rating, is_commandable, loop_clamp_note,
)
from . import freshness
from .freeze import (
@@ -994,7 +995,8 @@ class Controller:
# the counterpart to `clamp_last` moving onto the Inverter: the integrator
# that wound is the SITE integrator, of which there is exactly one, while
# the value a clamp bit is a value written to ONE device.
self.loop_clamped: tuple[bool, str] = (False, "")
# (engaged, note, over_rating) - SAFETY-40 splits the last one out.
self.loop_clamped: tuple[bool, str, bool] = (False, "", False)
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
@@ -3271,11 +3273,12 @@ class Controller:
# it - so a string test is silent on the two cases a winding loop
# most often presents as. These flags are set where the clamp
# happens and nothing downstream touches them.
# SAFETY-40: every number comes from THIS decision, at clamp time,
# never from `self.target` after slew/freeze/arbiter rewrote it.
self.loop_clamped = (
decision.i_clamped or decision.clamped,
(f"the loop asked for {decision.i_w:+.0f} W"
+ (" (integrator bound)" if decision.i_clamped else "")
+ (" (output bound)" if decision.clamped else "")),
loop_clamp_note(decision),
decision.clamped,
)
# SITE. compute() regulates `grid_w` - the P1 measurement - to
# zero. Its subject is the meter, and §6.2's architecture is ONE
@@ -3299,7 +3302,7 @@ class Controller:
# A loop that is not running cannot be winding. Clearing this here
# rather than letting the last value stand is what makes the alarm
# de-assert when control is stopped.
self.loop_clamped = (False, "")
self.loop_clamped = (False, "", False)
# SAFETY-28. A new resolution is a new tick, and the previous record is
# stale from this line onwards. Incremented BEFORE resolve() so that the
@@ -3716,20 +3719,17 @@ class Controller:
clamped_units = [inv for inv in self.inverters
if inv.clamp_last is not None and inv.clamp_last.engaged]
engaged_write = bool(clamped_units)
engaged_loop, loop_note = self.loop_clamped
engaged_loop, loop_note, loop_bug = self.loop_clamped
engaged = engaged_write or engaged_loop
if engaged:
where = (", ".join(f"{inv.name}: {inv.clamp_last.describe()}"
for inv in clamped_units) if engaged_write
else f"{CLAMP_REASON}: {loop_note}, above the "
f"{self.site_max_w:.0f} W rating, and "
f"was clamped to {self.target:+.0f} W")
reason = (f"{where}. This is a BUG SIGNAL, not a limit being used: "
f"something upstream computed a setpoint the actuator "
f"cannot deliver.")
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
bug = engaged_write or loop_bug
if engaged_write:
reason = (", ".join(f"{inv.name}: {inv.clamp_last.describe()}"
for inv in clamped_units) + ". " + CLAMP_BUG_SIGNAL)
else:
reason = ""
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc))
reason = loop_note if engaged else ""
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc),
bug=bug)
# --- THE ACTION LOG, AND IT IS COALESCED PER EPISODE ------------------
# Every engagement is recorded, but consecutive ones are recorded by
@@ -3760,6 +3760,18 @@ class Controller:
self.log_event(row)
if not fired:
return
if not self.clamp_wd.bug:
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
# WARNING and a plainly titled notice, same id and cadence.
_LOG.warning("SAFETY-03 - %s Engagements so far: %d.",
self.clamp_wd.reason, self.clamp_wd.engagements)
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_output_clamp",
"title": "GoodWe: the control loop is at its integrator bound",
"message": (f"{self.clamp_wd.reason}\n\nNo command exceeded "
f"the rating and the controller is STILL RUNNING."),
})
return
_LOG.error(
"SAFETY-03 - %s Silently clipping this is how a winding integrator "
"reached 14 768 W on this project before: the clamp makes every "
@@ -4893,16 +4905,21 @@ class Controller:
out.append({"ok": False, "warn": "NOT COMMANDABLE" not in note,
"text": note})
# SAFETY-03. Red while a clamp is engaged, and the engagement COUNT is
# shown even after it clears - a loop that wound once and stopped is
# still a bug, and a row that vanishes with the condition takes the
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
# integrator bound, SAFETY-40), and the engagement COUNT is shown even
# after it clears - a row that vanishes with the condition takes the
# evidence with it. Reported only when there is something to report:
# unlike the freeze detector this guard has no "not covered" state, it
# runs on every write on every transport.
if self.clamp_wd.active:
if self.clamp_wd.active and self.clamp_wd.bug:
out.append({"ok": False, "warn": False,
"text": "OUTPUT CLAMP ENGAGED - " + self.clamp_wd.reason
+ f" Engagements: {self.clamp_wd.engagements}."})
elif self.clamp_wd.active:
# SAFETY-40: the integrator bound alone is amber, not red.
out.append({"ok": False, "warn": True,
"text": "LOOP AT INTEGRATOR BOUND - " + self.clamp_wd.reason
+ f" Engagements: {self.clamp_wd.engagements}."})
elif self.clamp_wd.engagements:
out.append({"ok": False, "warn": True,
"text": f"output clamp: not engaged now, but it has "