SAFETY-03: alarm when a computed setpoint exceeds the rating, do not just clip it
The clamp was never the missing half. specs.md 10.3 records an unclamped integrator climbing ~130 W per 4 s past 10 kW and reporting 14 768 W pinned at the 5 kW rail; clipped silently that is a flat, legal-looking 5000 W hold and nothing says the loop asked for three times it. write_setpoint() - the one path every write takes - now clamps to the VALIDATED max_w and reports whether the raw value differed; check_output_clamp() raises OUTPUT_CLAMP as a notification, an ERROR, an event row and a red status banner. Not a trip: the clamped value is written and the loop keeps running. The rating is max_w and nothing is min()ed at runtime - CFG-01 already refuses a max_w outside the device range at startup. The device range is applied second as defence in depth; it cannot bite after a successful validation, so if it ever does it alarms too and names the bound. No new config key. Decision grew i_clamped / clamped booleans, and that is the load-bearing part: `reason` is one string that slew-limiting and the saturation freeze overwrite, so an alarm keyed on it is silent on exactly the cycles a winding loop produces. Mutating main.py back onto the string drops the 10.3 replay from 2 engagements to 0. Distinct from saturation by construction - no shared reason code, notification id, status row or counter - and checked in the negative, after first asserting the saturation freeze really did engage. Two things found by running it rather than by reading it: a row per engagement flushed the 40-row event log and evicted SAFETY-18's own METER FROZEN? row (now coalesced per episode with an exact count), and a naive clamp turns a nan into a command at the full rating because every comparison against nan is False. test_clamp.py is new: assert-based, no framework. Six author mutations reproduced red, including "clamp kept, alarm deleted". Two of the check's own hypotheses about the freeze/clamp interaction were refused by its sweep and rewritten to what it measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
co-authored by
Claude Opus 5
parent
0cdaa3bd3e
commit
c040aac4b8
@@ -0,0 +1,271 @@
|
||||
"""SAFETY-03 - the output clamp, and the alarm that makes it audible.
|
||||
|
||||
WHAT IT CATCHES, in one sentence: a control law that computes a setpoint the
|
||||
actuator cannot deliver, which every layer below it will happily clip to
|
||||
something legal-looking while the thing that produced it goes on winding.
|
||||
|
||||
THE CLAMP IS NOT THE DELIVERABLE. specs.md 10.3 is explicit that this project
|
||||
already clipped, and that the clipping is what hid the defect:
|
||||
|
||||
with the inverter switched off entirely, an unclamped integrator climbed
|
||||
~130 W per 4 s to over 10 kW and, pinned at the 5 kW rail, reported
|
||||
14 768 W
|
||||
|
||||
A clamp alone turns that into a silent, perfectly flat 5000 W hold. Every
|
||||
dashboard reads "commanded 5000 W", which is a legal number, and nothing
|
||||
anywhere says the loop asked for three times it. 11.2's watchdog row says
|
||||
"Clamp AND ALARM - the loop is winding"; the second verb is this module.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHAT "THE RATING" IS HERE, AND WHY IT IS ONE NUMBER AND NOT A MIN()
|
||||
|
||||
The card names two bounds and warns against choosing between them:
|
||||
|
||||
- `DriverCapabilities.setpoint_limit_w` (3.2), the inverter's own magnitude
|
||||
rating. In this add-on that arrives as the setpoint entity's `min`/`max`
|
||||
from `hass.limits()` - `Controller.dev_min` / `dev_max`.
|
||||
- `optimizer.loop.clamp_w` (14), the configured loop clamp. In this add-on
|
||||
that is the `max_w` option, which `Tuning.max_w` and the arbiter's
|
||||
`supervised` limit both already use.
|
||||
|
||||
14 says these two must AGREE, validated at startup with a refusal on
|
||||
mismatch - and that gate already ships: `validate_startup_config()` refuses
|
||||
`max_w > dev_max` and `-max_w < dev_min` (validate.py, problem 1), and
|
||||
run_control() re-runs it once ESPHome is up and the real rating is known.
|
||||
|
||||
So this guard clamps to `max_w`, THE VALIDATED VALUE, and does not `min()` it
|
||||
with the device rating at runtime. That is the card's instruction and it is also
|
||||
the only reading that keeps `max_w` meaningful: a silent runtime `min()` is
|
||||
precisely the "helper bounded to +-500 W under a loop clamped to 1500 W" failure
|
||||
the 14 warning box is about.
|
||||
|
||||
WARNING - THE DEVICE BOUND IS STILL APPLIED, SECOND, AND IT IS NOT REDUNDANT.
|
||||
After a successful validation `+-max_w` lies inside `[dev_min, dev_max]`, so the
|
||||
second stage is arithmetically a no-op and can never engage. If it DOES engage,
|
||||
the validation did not run or no longer holds - `hass.limits()` returns
|
||||
(None, None) whenever ESPHome is not up yet, and the range checks are skipped
|
||||
rather than guessed at - and that is a louder fault than the first stage, not a
|
||||
quieter one. It therefore alarms too, through the same channel, with the bound
|
||||
named. This is NOT SAFETY-20; nothing here changes what `freeze.py` measures its
|
||||
rail against.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHERE THE WIND ACTUALLY SHOWS UP - READ THIS BEFORE "SIMPLIFYING" THE CALLER
|
||||
|
||||
`control.compute()` bounds the integrator to `integrator_max_w or max_w` and
|
||||
THEN derives the command from it, so on a default install (`integrator_max_w`
|
||||
unset, i.e. following `max_w`) the command can never exceed `max_w` and the
|
||||
actuator-side clamp below IS UNREACHABLE. control.py says so in as many words:
|
||||
|
||||
SAFETY-03 ... must watch for THIS, not for "clamped" below.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
limited reports "slew-limited", and one that clamps while saturated reports
|
||||
"saturated-freeze". Both are exactly the winding-into-a-rail case, and on the
|
||||
string the clamp signal is destroyed before anything can alarm on it. The two
|
||||
flags are set where the clamp happens and are never overwritten. A check in
|
||||
test_clamp.py drives that precise combination.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
DISTINCT FROM SATURATION - THIS IS ACCEPTANCE CRITERION 2, NOT A NICETY
|
||||
|
||||
clamp the COMPUTED value exceeded the rating. A software bug signal:
|
||||
something upstream is winding. Alarm, every engagement.
|
||||
saturation |command - inverter AC power| > `saturation_w` for
|
||||
`saturation_cycles`. The HARDWARE cannot follow a perfectly legal
|
||||
command. Action is "freeze the integrator" (11.2), not an alarm.
|
||||
|
||||
A loop can be legally saturated all day - a cold pack, a curtailed inverter, an
|
||||
export limit - so a saturation-shaped alarm is one an operator learns to ignore.
|
||||
Routing the clamp through it puts the bug signal inside the noise, which is the
|
||||
same silence 10.3 is complaining about. They share no reason code, no
|
||||
notification id, no status row and no counter.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
ANTI-FLOOD, WITHOUT GOING SILENT
|
||||
|
||||
A genuinely winding loop clamps on EVERY cycle for as long as it winds. One HA
|
||||
notification per cycle is how an operator learns to dismiss notifications
|
||||
unread, and this add-on already made that argument once (see the fixed
|
||||
`notification_id` note in main.check_meter_frozen).
|
||||
|
||||
So the two surfaces are split, deliberately:
|
||||
|
||||
- EVERY engagement is recorded, never deduplicated, and that record is what
|
||||
says how hard the loop is pushing. 15's action log is this add-on's
|
||||
`Controller.events`, and it gets a row from `write_setpoint()` on every
|
||||
engaged WRITE; `engagements` here counts every engaged CYCLE. The two layers
|
||||
are counted separately on purpose - the heartbeat re-writes an
|
||||
already-clamped value between cycles, so a per-write count would inflate and
|
||||
a per-cycle log would miss an out-of-cycle write.
|
||||
- THE NOTIFICATION fires on the rising edge and then RE-ASSERTS every
|
||||
`REASSERT_S`, so a loop pinned on the rail for an hour does not go quiet.
|
||||
- It DE-ASSERTS only after `CLEAR_CYCLES` consecutive clean cycles, which is
|
||||
the 10.2 hysteresis: a command sitting exactly on the rail dithers in and
|
||||
out of engagement by one quantisation step, and without this the alarm
|
||||
chatters.
|
||||
|
||||
`REASSERT_S` and `CLEAR_CYCLES` are module constants, not options, for the same
|
||||
reason `freeze.RAIL_FRAC` is: they describe the event bus, not the plant, and
|
||||
every knob 10.3 asked to keep exposed is a LOOP TUNING knob and is untouched by
|
||||
this module. No new config key is added anywhere - CFG-03 already has three
|
||||
places a default has to be kept in step and this ticket does not earn a fourth.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
NOT A TRIP
|
||||
|
||||
Clamping does not stop the loop and does not command 0 W. The clamped value is
|
||||
written and control continues, which is what 11.2 asks for ("Clamp and alarm")
|
||||
and what distinguishes this from SAFETY-01/02, whose action is 0 W. It follows
|
||||
that this must NOT drive the failsafe status - `publish()` keeps reporting
|
||||
"running", because the controller is.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
# The reason code, distinct from any saturation-freeze code by construction:
|
||||
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
|
||||
REASON_CODE = "OUTPUT_CLAMP"
|
||||
|
||||
# Re-assert the notification this often while the clamp stays engaged. 30 s is
|
||||
# the planning cadence (2.3), i.e. slow enough not to flood and fast enough
|
||||
# that a pinned loop is never quiet for a whole billing quarter.
|
||||
REASSERT_S = 30.0
|
||||
|
||||
# Consecutive clean cycles before the alarm de-asserts (10.2 hysteresis).
|
||||
CLEAR_CYCLES = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
"""What the guard did, with the RAW value preserved.
|
||||
|
||||
AC 2 turns on "the raw computed value differed from the clamped one", so the
|
||||
raw value has to survive the call - a guard that returns only the applied
|
||||
number cannot be asked the question the acceptance criterion asks.
|
||||
"""
|
||||
|
||||
requested_w: float
|
||||
applied_w: float
|
||||
limit_w: float
|
||||
engaged: bool
|
||||
bound: str = "" # "" | "max_w" | "device" - which stage bit
|
||||
|
||||
def describe(self) -> str:
|
||||
return (f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W "
|
||||
f"exceeds the {self.limit_w:.0f} W rating and was clamped to "
|
||||
f"{self.applied_w:+.0f} W by the {self.bound} bound - something "
|
||||
f"upstream is winding")
|
||||
|
||||
|
||||
def is_commandable(value) -> bool:
|
||||
"""False for None, NaN and the infinities.
|
||||
|
||||
A non-finite setpoint is LOSS OF INPUT, not an out-of-range value, and
|
||||
11.1 rule 1 says loss of input means command zero and keep commanding. It
|
||||
must not reach the clamp arithmetic: `min(limit, nan)` returns nan on
|
||||
CPython, so a nan would sail straight through a naive clamp and then report
|
||||
`engaged` True on the `applied != requested` test - because nan != nan - i.e.
|
||||
a transport bug filed in the winding-loop counter, with a nan on the wire.
|
||||
"""
|
||||
if value is None:
|
||||
return False
|
||||
try:
|
||||
return math.isfinite(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def clamp_to_rating(requested_w, limit_w, dev_min=None, dev_max=None) -> ClampResult:
|
||||
"""Bound a computed setpoint to the validated rating. The ONLY clamp.
|
||||
|
||||
`limit_w` the validated magnitude bound - `max_w`. Symmetric, because
|
||||
3.2 calls `setpoint_limit_w` a MAGNITUDE clamp: it binds import
|
||||
(+) and export (-) identically.
|
||||
`dev_min` / `dev_max` the device's own asymmetric range, applied second and
|
||||
expected never to bite. See the module note.
|
||||
|
||||
Public, and the predicate lives here rather than in the test, for the reason
|
||||
`freeze.looks_frozen()` gives: a safety expression transcribed into its own
|
||||
check is one copy plus a future disagreement about what the check means.
|
||||
|
||||
EXACTLY +-`limit_w` IS NOT AN ENGAGEMENT. The bound is inclusive; a loop
|
||||
resting on the rail is doing what it was told, and alarming there would fire
|
||||
on every healthy full-power hour.
|
||||
"""
|
||||
requested = float(requested_w)
|
||||
limit = abs(float(limit_w))
|
||||
|
||||
applied = max(-limit, min(limit, requested))
|
||||
if applied != requested:
|
||||
return ClampResult(requested, applied, limit, True, "max_w")
|
||||
|
||||
# Second stage. Arithmetically dead after a successful startup validation -
|
||||
# and therefore worth alarming on precisely BECAUSE it fired.
|
||||
if dev_min is not None and dev_max is not None:
|
||||
bounded = max(float(dev_min), min(float(dev_max), applied))
|
||||
if bounded != applied:
|
||||
return ClampResult(requested, bounded, limit, True, "device")
|
||||
applied = bounded
|
||||
|
||||
return ClampResult(requested, applied, limit, False, "")
|
||||
|
||||
|
||||
class ClampWatchdog:
|
||||
"""One instance per controller. Counts every engagement, alarms sparsely.
|
||||
|
||||
`note()` is called once per write with whether the clamp bit; the caller ORs
|
||||
in the loop's own `i_clamped` / `clamped` flags, which is where the winding
|
||||
is actually visible on a default install (see the module note).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.active = False
|
||||
self.engagements = 0 # every engagement, never deduplicated
|
||||
self.episodes = 0 # rising edges
|
||||
self.since: datetime | None = None
|
||||
self.last_alarm: datetime | None = None
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime) -> 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()`.
|
||||
"""
|
||||
if engaged:
|
||||
self.engagements += 1
|
||||
self.clean_cycles = 0
|
||||
self.reason = reason
|
||||
if not self.active:
|
||||
self.active = True
|
||||
self.since = now
|
||||
self.episodes += 1
|
||||
self.last_alarm = now
|
||||
return True
|
||||
# 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
|
||||
return True
|
||||
return False
|
||||
|
||||
if not self.active:
|
||||
return False
|
||||
self.clean_cycles += 1
|
||||
if self.clean_cycles >= CLEAR_CYCLES:
|
||||
self.active = False
|
||||
self.since = None
|
||||
self.last_alarm = None
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
return False
|
||||
@@ -64,6 +64,23 @@ class Decision:
|
||||
# and quantisation. Carry it back in as `i_w` next cycle; that is what keeps
|
||||
# it a separate quantity from the command.
|
||||
i_w: float = 0.0
|
||||
# SAFETY-03. Did a clamp actually engage this cycle - i.e. did the RAW
|
||||
# computed value differ from the bounded one?
|
||||
#
|
||||
# ⚠️ THESE EXIST BECAUSE `reason` CANNOT ANSWER THAT QUESTION. `reason` is
|
||||
# one string and every later stage overwrites it: a cycle that clamps the
|
||||
# integrator and is then slew limited reports "slew-limited", and one that
|
||||
# clamps while saturated reports "saturated-freeze". Both ARE the winding
|
||||
# case, and on the string the clamp signal is gone before anything can alarm
|
||||
# on it. These two are written where the clamp happens and are never
|
||||
# overwritten, so they mean exactly what they say and nothing else.
|
||||
#
|
||||
# `i_clamped` is the one that fires on a default install: the integrator
|
||||
# bound is reached first, so `clamped` is unreachable unless
|
||||
# `integrator_max_w` is deliberately set above `max_w`. Alarm on BOTH - see
|
||||
# the comment at the bound below, and clamp.py.
|
||||
i_clamped: bool = False
|
||||
clamped: bool = False
|
||||
|
||||
|
||||
def compute(
|
||||
@@ -188,7 +205,8 @@ def compute(
|
||||
# bound is not. It is what makes the worst-case unwind time finite and
|
||||
# knowable instead of a function of how long the error happened to stand.
|
||||
bounded = max(-limit, min(limit, i_w))
|
||||
if bounded != i_w:
|
||||
i_clamped = bounded != i_w
|
||||
if i_clamped:
|
||||
# ⚠️ SAFETY-03 (alarm whenever the loop winds into a rail) must watch
|
||||
# for THIS, not for "clamped" below. At the default limit == max_w the
|
||||
# integrator bound is reached first and the command derived from it can
|
||||
@@ -209,7 +227,8 @@ def compute(
|
||||
# --- ORDER MATTERS: clamp -> slew -> freeze ----------------------------
|
||||
# An early draft applied a floor after the clamp and let demand escape it.
|
||||
target = max(-tuning.max_w, min(tuning.max_w, want))
|
||||
if target != want:
|
||||
clamped = target != want
|
||||
if clamped:
|
||||
reason = "clamped"
|
||||
|
||||
slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target))
|
||||
@@ -235,7 +254,8 @@ def compute(
|
||||
step = max(1, int(tuning.step_w))
|
||||
target = round(target / step) * step
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w))
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped)
|
||||
|
||||
|
||||
def maintenance_charge_floor(
|
||||
|
||||
@@ -36,6 +36,10 @@ from .arbiter import (
|
||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_SAFETY, Claim, resolve,
|
||||
)
|
||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from .clamp import (
|
||||
REASON_CODE as CLAMP_REASON, ClampResult, ClampWatchdog, clamp_to_rating,
|
||||
is_commandable,
|
||||
)
|
||||
from .freeze import FreezeWatchdog, FreezeWatchdogConfig, rail_w
|
||||
from .hass import HomeAssistant
|
||||
from .maintenance import IDLE, MaintConfig, Maintenance
|
||||
@@ -140,6 +144,19 @@ class Controller:
|
||||
self.freeze_wd = FreezeWatchdog(
|
||||
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
|
||||
|
||||
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
|
||||
# itself is the cheap half.
|
||||
#
|
||||
# ⚠️ NO CONFIG OF ITS OWN, on purpose. The bound it clamps to is `max_w`,
|
||||
# which is already an option, already validated against the device's
|
||||
# rating at startup, and already used by Tuning and the arbiter. Giving
|
||||
# this a second key would be a fourth place a limit has to be kept in
|
||||
# step (CFG-03 already tracks three) and, worse, would recreate exactly
|
||||
# the two-numbers-that-disagree failure the §14 warning box describes.
|
||||
# The anti-flood cadence is a module constant for the same reason
|
||||
# freeze.RAIL_FRAC is: it describes the event bus, not the plant.
|
||||
self.clamp_wd = ClampWatchdog()
|
||||
|
||||
# P1 ingestion (TEL-01). `meter_source: off` keeps the original
|
||||
# single-entity meter_entity path, so an existing install is unchanged
|
||||
# until it opts in.
|
||||
@@ -171,6 +188,11 @@ class Controller:
|
||||
self.last_write_ok = None
|
||||
self.inputs_bad_since = None
|
||||
self.dev_min = self.dev_max = None
|
||||
# SAFETY-03. What the last write's clamp did, and what the loop's own
|
||||
# bounds did last cycle. `loop_clamped` is a (engaged, note) pair so the
|
||||
# alarm can say WHICH bound bit without re-deriving it from a string.
|
||||
self.clamp_last: ClampResult | None = None
|
||||
self.loop_clamped: tuple[bool, str] = (False, "")
|
||||
self.events: list[str] = []
|
||||
self.stopping = False
|
||||
|
||||
@@ -229,11 +251,37 @@ class Controller:
|
||||
entity = self.o.get("setpoint_entity", "")
|
||||
if not entity:
|
||||
return
|
||||
# Never send a value the device will reject outright. A rejected write is
|
||||
# silent from the controller's point of view, and the inverter then keeps
|
||||
# doing whatever it was already doing.
|
||||
if self.dev_min is not None and self.dev_max is not None:
|
||||
value = max(self.dev_min, min(self.dev_max, value))
|
||||
|
||||
# --- SAFETY-03: THE CLAMP, AND IT IS ON THE ONLY PATH TO THE WIRE -----
|
||||
# Every write in this add-on comes through here - the cycle, the
|
||||
# heartbeat, the operator's stop button and shutdown() - so no optimizer,
|
||||
# maintenance or manual path can route around it. That placement is the
|
||||
# first acceptance criterion, and it is why the guard is not in
|
||||
# control.py: a guard the control law owns is a guard every other claimer
|
||||
# bypasses.
|
||||
#
|
||||
# ⚠️ A NON-FINITE VALUE IS LOSS OF INPUT, NOT AN OUT-OF-RANGE ONE. §11.1
|
||||
# rule 1: command zero and keep commanding. It must not reach the clamp
|
||||
# arithmetic - see is_commandable() for what a nan does to it - and it
|
||||
# must not be counted as a clamp engagement, or a transport bug lands in
|
||||
# the winding-loop counter.
|
||||
if not is_commandable(value):
|
||||
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W")
|
||||
_LOG.error("refusing to write a non-finite setpoint %r - commanding "
|
||||
"0 W and continuing to command. This is a bug upstream, "
|
||||
"not a limit.", value)
|
||||
value = 0.0
|
||||
result = clamp_to_rating(value, float(self.o.get("max_w", 2000)),
|
||||
self.dev_min, self.dev_max)
|
||||
self.clamp_last = result
|
||||
if result.engaged:
|
||||
# ⚠️ EVERY engagement, never deduplicated: this is the action log
|
||||
# (§15), and it is a different surface from the notification, which
|
||||
# IS deduplicated. The count is what says how hard the loop pushed;
|
||||
# the notification only says that it did.
|
||||
self.log_event(result.describe())
|
||||
value = result.applied_w
|
||||
|
||||
changed = (self.last_write is None or value != self.last_write)
|
||||
if not (changed or force):
|
||||
return
|
||||
@@ -379,11 +427,29 @@ class Controller:
|
||||
self.sat_count = decision.sat_count
|
||||
self.i_w = decision.i_w
|
||||
self.loop_w = decision.target_w
|
||||
# --- SAFETY-03 ---------------------------------------------------
|
||||
# ⚠️ READ THE BOOLEANS, NOT `decision.reason`. On a default install
|
||||
# the integrator bound is what bites and the output clamp is
|
||||
# unreachable (control.py says so), and `reason` is then overwritten
|
||||
# by whichever of slew-limiting or the saturation freeze ran after
|
||||
# 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.
|
||||
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 "")),
|
||||
)
|
||||
claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason))
|
||||
else:
|
||||
# Stopped or blind: no accumulation may survive the outage.
|
||||
self.i_w = 0.0
|
||||
self.loop_w = None
|
||||
# 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, "")
|
||||
|
||||
resolution = resolve(claims)
|
||||
if resolution.contradiction:
|
||||
@@ -412,8 +478,92 @@ class Controller:
|
||||
# chose it has been standing still. Evaluated before write_setpoint()
|
||||
# it would be reasoning about the PREVIOUS command against THIS cycle's
|
||||
# meter - the one combination that is true of neither moment.
|
||||
await self.check_output_clamp()
|
||||
await self.check_meter_frozen()
|
||||
|
||||
async def check_output_clamp(self) -> None:
|
||||
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
|
||||
|
||||
Runs AFTER write_setpoint() for the same reason SAFETY-18 does: the
|
||||
clamp is arithmetic done inside the write, so before the write there is
|
||||
nothing yet to report.
|
||||
|
||||
⚠️ PUBLISHING IS NOT IN THE WRITE PATH. The clamp itself is synchronous
|
||||
inside write_setpoint(); this - the awaits, the notification, anything
|
||||
that can block on Home Assistant - is out here. A stalled supervisor must
|
||||
never be able to delay or drop a frame.
|
||||
|
||||
⚠️ THIS IS NOT A TRIP. No claim is filed, nothing is latched, 0 W is not
|
||||
commanded and `publish()` still reports "running". §11.2's action for
|
||||
this row is "clamp and alarm", and the clamped value is written and the
|
||||
loop keeps running. Turning this into a trip means coming here and
|
||||
changing that deliberately, not discovering it in the field.
|
||||
"""
|
||||
engaged_write = bool(self.clamp_last and self.clamp_last.engaged)
|
||||
engaged_loop, loop_note = self.loop_clamped
|
||||
engaged = engaged_write or engaged_loop
|
||||
if engaged:
|
||||
where = (self.clamp_last.describe() if engaged_write
|
||||
else f"{CLAMP_REASON}: {loop_note}, above the "
|
||||
f"{float(self.o.get('max_w', 2000)):.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.")
|
||||
else:
|
||||
reason = ""
|
||||
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc))
|
||||
|
||||
# --- THE ACTION LOG, AND IT IS COALESCED PER EPISODE ------------------
|
||||
# Every engagement is recorded, but consecutive ones are recorded by
|
||||
# UPDATING one row's count rather than inserting a row each.
|
||||
#
|
||||
# ⚠️ THAT IS NOT COSMETIC, AND IT WAS FOUND THE HARD WAY. `log_event()`
|
||||
# keeps 40 rows; a winding loop clamps on every cycle, so a row per
|
||||
# engagement flushes the whole ring within 40 cycles - and the first
|
||||
# thing it evicted, in test_freeze.py, was SAFETY-18's "METER FROZEN?"
|
||||
# row. A guard whose log drowns out the other guards' logs has moved the
|
||||
# flood rather than removed it. The count here is `clamp_wd.engagements`,
|
||||
# so nothing is lost: the row says exactly how many, and the number is
|
||||
# read off the counter rather than off the number of rows.
|
||||
#
|
||||
# This lives here rather than only in write_setpoint() because on a
|
||||
# default install the actuator-side clamp is unreachable - compute() has
|
||||
# already bounded the integrator - so the only engagements that exist are
|
||||
# loop-side ones. Measured: 60 cycles of the §10.3 runaway produce 0
|
||||
# write-path engagements and 2 loop-side ones.
|
||||
if engaged and not engaged_write:
|
||||
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
|
||||
row = (f"{tag}: {self.clamp_wd.engagements} engagement(s) - "
|
||||
f"{reason}")
|
||||
if self.events and tag in self.events[0]:
|
||||
stamp = self.events[0].split(" ", 1)[0]
|
||||
self.events[0] = f"{stamp} {row}"
|
||||
else:
|
||||
self.log_event(row)
|
||||
if not fired:
|
||||
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 "
|
||||
"dashboard read a legal number while the thing producing it goes on "
|
||||
"climbing. The command IS clamped and the loop is STILL RUNNING - "
|
||||
"this is an alarm, not a trip. Engagements so far: %d.",
|
||||
self.clamp_wd.reason, self.clamp_wd.engagements)
|
||||
# A fixed notification_id, for the reason check_meter_frozen() gives -
|
||||
# and a DIFFERENT one from every other alarm this add-on raises, because
|
||||
# a clamp is not a frozen meter and neither is a saturation freeze.
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_output_clamp",
|
||||
"title": "GoodWe: a computed setpoint exceeded the rating",
|
||||
"message": (
|
||||
f"{self.clamp_wd.reason}\n\nThe command has been clamped and "
|
||||
f"the controller is STILL RUNNING. This is NOT the inverter "
|
||||
f"failing to follow a legal command (that is the saturation "
|
||||
f"freeze, which is normal and does not alarm) - the value asked "
|
||||
f"for was itself out of range."),
|
||||
})
|
||||
|
||||
async def check_meter_frozen(self) -> None:
|
||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||
|
||||
@@ -694,6 +844,23 @@ class Controller:
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": f"setpoint {sp} (range {self.dev_min:g}…{self.dev_max:g} W)"})
|
||||
|
||||
# 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
|
||||
# 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:
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": "OUTPUT CLAMP ENGAGED - " + 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 "
|
||||
f"engaged {self.clamp_wd.engagements} time(s) in "
|
||||
f"{self.clamp_wd.episodes} episode(s) since "
|
||||
f"start - see the event log"})
|
||||
|
||||
if self.last_write_ok is False:
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": "last write to the inverter was REJECTED - check the log"})
|
||||
|
||||
Reference in New Issue
Block a user