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
@@ -0,0 +1,26 @@
|
||||
# SAFETY-40: the integrator bound is not a bug
|
||||
|
||||
**Decision.** `i_clamped` (the integrator reaching `integrator_max_w`, i.e.
|
||||
anti-windup) keeps alarming through the same `ClampWatchdog`: it is counted,
|
||||
notified on the same id and cadence, and escalates on a sustained run
|
||||
(SAFETY-41). It gets its own text, a `WARNING` and an amber status row. Only
|
||||
`clamped` (a command over `max_w`) and write-path clamps keep the BUG SIGNAL,
|
||||
the `ERROR` and the red row.
|
||||
|
||||
**Why it still notifies.** On a default install `clamped` is unreachable
|
||||
(`control.py`, at the integrator bound), so the integrator bound is the only
|
||||
loop-side signal of a runaway such as the 14 768 W one. Silencing it would
|
||||
remove that. The card says the same: "the fix is to say the true thing, not to
|
||||
say less."
|
||||
|
||||
**Why a bug has its own budget.** A genuine over-rating must not wait up to
|
||||
`REASSERT_S` behind anti-windup notices. `ClampWatchdog` tracks a separate
|
||||
`last_bug_alarm`, and a bug's text is never overwritten by later anti-windup
|
||||
text within an episode. With only bug engagements, the cadence is the same as
|
||||
before (`test_clamp.py` part 9).
|
||||
|
||||
**Numbers.** They come from the `Decision` of the clamping tick (`i_bound_w`,
|
||||
`requested_w`, `clamped_w`, `target_w`), not from `i_w` or `self.target`.
|
||||
|
||||
**Open for the owner.** Whether scattered one-cycle anti-windup episodes should
|
||||
notify at all, or only log and count. They notify now, as before.
|
||||
@@ -1630,7 +1630,9 @@ share no reason code, no notification id, no status row and no counter.
|
||||
running - this is not the sign-disagreement failsafe, which commands 0 W. You
|
||||
get a persistent notification (`goodwe_output_clamp`, replaced rather than
|
||||
stacked), an `ERROR` in the log with the reason code `OUTPUT_CLAMP`, a row in
|
||||
the event list, and a red status banner while it is engaged. Once it clears, a
|
||||
the event list, and a red status banner while it is engaged. The message quotes
|
||||
the command the loop computed and the value it was clamped to, both from the
|
||||
cycle that clamped. Once it clears, a
|
||||
warning row keeps the engagement count so the evidence does not vanish with the
|
||||
condition. Consecutive engagements share one event row carrying the exact count,
|
||||
because the event list keeps only 40 rows and a row per cycle would flush every
|
||||
@@ -1639,9 +1641,11 @@ other guard's entries out of it.
|
||||
**There is nothing to configure**, deliberately. The bound is `max_w`, which you
|
||||
already set; giving the guard a limit of its own would be a second number that
|
||||
can disagree with the first, which is exactly the failure the startup validation
|
||||
exists to prevent. The alarm's cadence - first engagement, then re-asserted every
|
||||
30 s while it persists, cleared after 3 clean cycles - is fixed for the same
|
||||
reason `RAIL_FRAC` is: it describes the notification bus, not the plant.
|
||||
exists to prevent. The alarm's cadence - at most one notification per 150 s
|
||||
(an episode that has not yet notified does so once it has engaged 3 cycles in
|
||||
a row), cleared after 3 clean
|
||||
cycles - is fixed for the same reason `RAIL_FRAC` is: it describes the
|
||||
notification bus, not the plant.
|
||||
|
||||
WARNING: **on a default install the engagement you will see is the integrator's,
|
||||
not the output's.** The control law bounds the integrator to `max_w` and *then*
|
||||
@@ -1650,6 +1654,15 @@ rating unless `integrator_max_w` has been deliberately raised above `max_w`.
|
||||
Both are watched. A guard that watched only the emitted value would be silent on
|
||||
every standard configuration.
|
||||
|
||||
**The integrator bound is not a bug, and it is reported as what it is.** It
|
||||
reads *"the loop's integrator reached its +5000 W anti-windup bound; the loop's
|
||||
command this cycle was +2000 W"*, titled *"the control loop is at its integrator
|
||||
bound"*, logged as a `WARNING`, with an amber (not red) status row. Brief
|
||||
engagements after a large load or solar step are normal. If it stays engaged,
|
||||
check that the battery is following its commands. Only a command over the rating
|
||||
carries the BUG SIGNAL text, and it never waits behind integrator-bound notices.
|
||||
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
|
||||
|
||||
### The frozen-meter alarm
|
||||
|
||||
A meter that has died is easy — it stops answering, the sample age climbs past
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -340,6 +340,12 @@ class Decision:
|
||||
# the comment at the bound below, and clamp.py.
|
||||
i_clamped: bool = False
|
||||
clamped: bool = False
|
||||
# SAFETY-40. The alarm's numbers, taken at the clamp so they are one tick's:
|
||||
# the bound the integrator reached, and the command before/after the
|
||||
# rating clamp. `target_w` is this tick's emitted command.
|
||||
i_bound_w: float = 0.0
|
||||
requested_w: float = 0.0
|
||||
clamped_w: float = 0.0
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
@@ -563,6 +569,7 @@ def compute(
|
||||
# winding, "clamped" is a command that came out over the rating anyway.
|
||||
reason = "i-clamped"
|
||||
i_w = bounded
|
||||
i_bound_w = bounded if i_clamped else 0.0
|
||||
want = i_w
|
||||
|
||||
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
|
||||
@@ -574,6 +581,7 @@ def compute(
|
||||
# An early draft applied a floor after the clamp and let demand escape it.
|
||||
target = max(-tuning.max_w, min(tuning.max_w, want))
|
||||
clamped = target != want
|
||||
clamped_w = target
|
||||
if clamped:
|
||||
reason = "clamped"
|
||||
|
||||
@@ -740,7 +748,8 @@ def compute(
|
||||
target = round(target / step) * step
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped)
|
||||
i_clamped, clamped, float(i_bound_w), float(want),
|
||||
float(clamped_w))
|
||||
|
||||
|
||||
def maintenance_charge_floor(
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
Reference in New Issue
Block a user