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
+182 -7
View File
@@ -38,6 +38,9 @@ acceptance criteria; part 6 is TEST-11's and part 7 is SAFETY-28's:
the check that replaced a config-shape proxy: `ACTUATOR_WRITE_KEYS` graded
how many option KEYS exist, which stays 1 while a per-inverter config adds
a second real writer.
9. SAFETY-40. The integrator bound (normal anti-windup) is reported in its
own words and never as a BUG SIGNAL; the over-rating keeps it; and every
number in either message is the clamp tick's own Decision.
------------------------------------------------------------------------------
WHY PART 4 ALSO ASSERTS ON `Decision`'s BOOLEANS AND NOT ON `reason`
@@ -501,8 +504,11 @@ check("...with its own notification_id, not shared with any other guard",
all(d["notification_id"] == CLAMP_ID for d in notes_r))
check("...one episode, re-asserted rather than one per cycle",
ctl_r.clamp_wd.episodes == 1 and len(notes_r) < ctl_r.clamp_wd.engagements)
check("...and it names itself a bug signal, not a limit in use",
bool(notes_r) and "out of range" in notes_r[0]["message"])
# SAFETY-40: on a default install this runaway is the INTEGRATOR bound, so it
# says so, and does not call anti-windup a bug (part 8 grades the wording).
check("...and it names the integrator bound, not a BUG SIGNAL",
bool(notes_r) and "anti-windup bound" in notes_r[0]["message"]
and "BUG SIGNAL" not in notes_r[0]["message"])
rows_r = [e for e in ctl_r.events if REASON_CODE in e]
check(f"the action log records every engagement, coalesced into one row per "
f"episode ({len(rows_r)} row(s), {ctl_r.clamp_wd.engagements} "
@@ -528,8 +534,9 @@ check("...and the count in the row is the counter's, so nothing is lost",
f"{ctl_l.clamp_wd.engagements} engagement(s)" in nth(rows_l, -1))
# The RED row exists only while engaged, and the episode above is short, so it
# is captured mid-run rather than asserted after the hysteresis has cleared it.
check("the status page goes red WHILE it is engaged",
any(not c["ok"] and not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
check("the status page flags it WHILE it is engaged - amber, since it is the "
"integrator bound (SAFETY-40; red is part 9's over-rating case)",
any(not c["ok"] and c["warn"] and "LOOP AT INTEGRATOR BOUND" in c["text"]
for c in ctl_r.red_snapshot))
check("...and once clear, a warn row still says how many times it engaged",
any(c["warn"] and f"{ctl_r.clamp_wd.engagements} time(s)" in c["text"]
@@ -806,11 +813,11 @@ class _SpyWatchdog(ClampWatchdog):
self.seen = [] # clamp_last.requested_w at each consultation
self.seen_write = [] # was the WRITE-path half engaged, per cycle
def note(self, engaged, reason, now):
def note(self, engaged, reason, now, **kw):
cl = self.owner.clamp_last
self.seen.append(None if cl is None else cl.requested_w)
self.seen_write.append(bool(cl and cl.engaged))
return super().note(engaged, reason, now)
return super().note(engaged, reason, now, **kw)
def probed(cls=Controller, grid=9000.0, cycles=5, **over):
@@ -986,7 +993,7 @@ check("PRECONDITION: the write really did clamp, on the DEVICE bound",
and hass_w.writes[-1] == 2500.0)
check("...and the loop-side half is SILENT here, so self.clamp_last is the "
"only evidence that this happened at all",
ctl_w.loop_clamped == (False, ""))
ctl_w.loop_clamped == (False, "", False))
check("AC 3: the guard alarmed on write-path evidence alone",
ctl_w.clamp_wd.engagements == 1 and len(notes_w) == 1)
check("...and the operator is told WHICH bound bit, so a device-rating clamp "
@@ -1858,6 +1865,174 @@ check("...EDGE-logged, not level-logged: 25 cycles did not flush the 40-row "
len(_shadow_lines) < 25 and len(_shad_c.events) <= 40)
# ==============================================================================
print()
print("9. SAFETY-40: the integrator bound is not a BUG SIGNAL, and the numbers "
"are the clamp tick's own")
# Three defects, each graded on the wired Controller.cycle() with compute()
# spied, so every number is compared with the Decision of the SAME tick:
# 1. `i_clamped` (normal anti-windup) used the over-rating BUG SIGNAL text;
# 2. "the loop asked for" printed `Decision.i_w`, the integrator, as a request;
# 3. "was clamped to" printed `self.target`, read after slew/freeze rewrote it.
# The fixtures are chosen so the numbers the old text used DIFFER from the true
# ones (MUTATION.md habit 4); the PRECONDITION checks pin that.
import logging
import app.main as _main_mod
_real_compute = _main_mod.compute
class _LogCap(logging.Handler):
def __init__(self):
super().__init__()
self.records = []
def emit(self, record):
self.records.append(record)
def s40_run(grid, cycles, track, **over):
"""Per tick: (Decision, self.target, clamp_wd.reason, engaged) + hass + log."""
ticks, decisions = [], []
def spy(*a, **k):
d = _real_compute(*a, **k)
decisions.append(d)
return d
cap = _LogCap()
lg = logging.getLogger("goodwe")
lg.addHandler(cap)
_main_mod.compute = spy
async def go():
hass = _Hass(grid, track=track)
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15},
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
red = []
for _ in range(cycles):
n = len(decisions)
before = c.clamp_wd.engagements
await c.read_inputs()
await c.cycle()
d = decisions[-1] if len(decisions) > n else None
ticks.append((d, c.target, c.clamp_wd.reason,
c.clamp_wd.engagements > before))
if c.clamp_wd.active and not red:
red = c.checks()
return hass, c, red
try:
hass, c, red = asyncio.run(go())
finally:
_main_mod.compute = _real_compute
lg.removeHandler(cap)
return ticks, hass, c, red, cap.records
def _f(d, name):
"""A Decision field, NaN when absent - so an old tree goes red, not crashes."""
return getattr(d, name, float("nan"))
# --- 9a. default install: only the INTEGRATOR bound can engage ---------------
ticks_i, hass_i, ctl_i, red_i, log_i = s40_run(6000.0, 20, track=False)
wind = [(d, t, r) for d, t, r, e in ticks_i
if e and d is not None and d.i_clamped and not d.clamped]
for d, t, r in wind:
print(f" tick: bound {_f(d, 'i_bound_w'):+.0f} i_w {d.i_w:+.0f} command "
f"{d.target_w:+.0f} self.target {t:+.0f}\n -> {r}")
check("PRECONDITION: the integrator bound engaged, and on a tick where the "
"carried integrator AND the emitted command both differ from the bound",
any(d.i_w != _f(d, 'i_bound_w') and d.target_w != _f(d, 'i_bound_w')
for d, t, r in wind))
check("defect 1: the integrator bound is NOT reported as a BUG SIGNAL",
wind and all("BUG SIGNAL" not in r for d, t, r in wind))
check("defect 1: ...it names the anti-windup bound, from the same tick",
wind and all(f"its {_f(d, 'i_bound_w'):+.0f} W anti-windup bound" in r
for d, t, r in wind))
check("defect 2: the integrator accumulator is not printed as a request",
wind and all(f"{d.i_w:+.0f} W" not in r and "asked for" not in r
for d, t, r in wind))
check("the command quoted is that tick's Decision, not a later read",
wind and all(f"command this cycle was {d.target_w:+.0f} W" in r
for d, t, r in wind))
check("at the bound nothing is described as 'above' or 'over' the rating",
wind and all(" above " not in r and " over " not in r
for d, t, r in wind))
notes_i = clamp_notes(hass_i)
check("it still NOTIFIES (not suppressed), under a title that claims no bug",
notes_i and all("integrator bound" in n["title"]
and "exceeded" not in n["title"] for n in notes_i))
check("...logged as a WARNING, not an ERROR",
any(x.levelno == logging.WARNING and "anti-windup" in x.getMessage()
for x in log_i)
and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage()
for x in log_i))
check("...and the status row is amber, not red",
any(c["warn"] and "LOOP AT INTEGRATOR BOUND" in c["text"] for c in red_i)
and not any(not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
for c in red_i))
# --- 9b. the genuine over-rating: integrator_max_w above max_w ----------------
ticks_o, hass_o, ctl_o, red_o, log_o = s40_run(9000.0, 12, track=True,
integrator_max_w=9000)
over = [(d, t, r) for d, t, r, e in ticks_o
if e and d is not None and d.clamped]
for d, t, r in over[:2]:
print(f" tick: requested {_f(d, 'requested_w'):+.0f} clamped {_f(d, 'clamped_w'):+.0f}"
f" i_w {d.i_w:+.0f} self.target {t:+.0f}\n -> {r}")
check("PRECONDITION: the output clamp engaged on a tick where the slew limit "
"rewrote the command AND the integrator differs from the request",
any(t != _f(d, 'clamped_w') and d.i_w != _f(d, 'requested_w') for d, t, r in over))
check("the over-rating KEEPS the BUG SIGNAL",
over and all("BUG SIGNAL" in r for d, t, r in over))
check("defect 2: it quotes the requested COMMAND of that tick, not i_w",
over and all(f"computed {_f(d, 'requested_w'):+.0f} W" in r for d, t, r in over))
check("defect 3: 'clamped to' is the clamp's own result, not self.target",
over and all(f"clamped to {_f(d, 'clamped_w'):+.0f} W" in r for d, t, r in over))
check("...and the 14 768 W rationale is still in its ERROR",
any(x.levelno == logging.ERROR and "14 768 W" in x.getMessage()
for x in log_o))
check("...under the 'exceeded the rating' title, with a red status row",
clamp_notes(hass_o)
and all("exceeded the rating" in n["title"] for n in clamp_notes(hass_o))
and any(not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
for c in red_o))
# --- 9c. the text, directly, and the two budgets -------------------------------
from app.clamp import loop_clamp_note # noqa: E402
d_at = compute(prev_w=1000.0, grid_w=9000.0, actual_w=1000.0,
tuning=Tuning(max_w=5000.0, slew_w=1000.0, gain=0.6), i_w=4500.0)
check(f"exactly at the rating ({d_at.i_bound_w:+.0f} W) is 'reached', never "
f"'above' - {loop_clamp_note(d_at)!r}",
d_at.i_clamped and not d_at.clamped and abs(d_at.i_bound_w) == 5000.0
and "above" not in loop_clamp_note(d_at))
check("a healthy decision produces no text",
loop_clamp_note(compute(0.0, 100.0, 0.0, Tuning(max_w=5000.0))) == "")
# Anti-windup notices must never spend the budget a BUG SIGNAL needs.
wd = ClampWatchdog()
fired_w = wd.note(True, "wind", T0, bug=False)
fired_b = wd.note(True, "bug", T0 + timedelta(seconds=5), bug=True)
wd.note(True, "wind again", T0 + timedelta(seconds=10), bug=False)
check("an anti-windup notice does not delay a bug in the same episode, and "
"does not overwrite its text",
fired_w and fired_b and wd.bug and wd.reason == "bug")
wd2 = ClampWatchdog()
seq = [wd2.note(True, "b", T0 + timedelta(seconds=5 * i)) for i in range(40)]
check("bug-only cadence is unchanged: rising edge, then every REASSERT_S",
seq[0] and sum(seq) == 1 + int(5 * 39 // REASSERT_S))
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")