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