SAFETY-02: a loop can be fresh, tracking and inside its rating and still push the wrong way

The stale-input watchdog asks whether the meter is still talking. The saturation
freeze asks whether the inverter can follow. The output clamp asks whether the
number is too big. A loop can pass all three while charging the battery out of
the grid, because the only thing wrong is the sign.

On 2026-08-20 a commercial controller did exactly that four times in one day,
for up to 77 minutes at a stretch, reaching a commanded -14 768 W against a
±5000 W inverter. app/watchdog.py compares the emitted setpoint against the
authoritative P1 figure and, when they oppose each other with both over 300 W
for 180 s continuously, files a failsafe SET claim at P_SAFETY, commands 0 W and
latches there until an operator starts control again.

Validated against that day rather than against an argument: sim/replay_sign.py
in the infra repo drives this module over the committed captures of both
2026-08-20 and the healthy 2026-08-23 and reaches 18 trips on the fault day -
every one inside a window NOTES.md §4.7 accounts for - and 0 on the healthy day.
The four episodes do share one signature: 0.3 / 0.9 / 10.5 / 0.0 % sign
agreement inside them against 92.9 % healthy.

Three decisions the ticket could not make:

- It is DISARMED whenever the grid-following loop is not what is driving. Sign
  agreement is a property of a loop that follows the meter, and a maintenance
  grid charge opposes the meter by design - 2500 W of charge into a house
  drawing 500 W is +3000 W on the meter against a -2500 W command, for hours.
  The gate tests the emitted number, not the winning claim's name, because a
  limit claim can let the loop "win" and still replace its value.
- The trip LATCHES. Commanding 0 W removes the command side of the comparison,
  so the disagreement always clears the moment the trip works.
- sign_disagree_w at or above max_w is refused at startup. The arbiter never
  lets a command exceed max_w, so such a threshold leaves the watchdog incapable
  of firing while the status page still reports it armed.

What it does not catch, said out loud: an inverter that ACTS opposite to its
command moves the meter further the same way, leaving command and meter in
agreement. That is the saturation freeze's job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
This commit is contained in:
glenn schrooyen
2026-08-28 21:51:35 +02:00
co-authored by Claude Opus 5
parent 35856a73ae
commit 9aed43990b
9 changed files with 755 additions and 8 deletions
+104 -3
View File
@@ -41,6 +41,7 @@ from .maintenance import IDLE, MaintConfig, Maintenance
from .mqtt import MqttPublisher
from .p1 import P1Ingest, build_source, is_enabled
from .validate import ConfigRefused, refuse, validate_startup_config
from .watchdog import SignWatchdog, SignWatchdogConfig
from . import web
OPTIONS_PATH = "/data/options.json"
@@ -103,6 +104,12 @@ class Controller:
store,
)
# SAFETY-02. One instance, one actuator, one cadence - see watchdog.py.
self.sign_wd = SignWatchdog(SignWatchdogConfig(
min_w=float(opts.get("sign_disagree_w", 300)),
hold_s=float(opts.get("sign_disagree_s", 180)),
))
# 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.
@@ -120,6 +127,11 @@ class Controller:
self.sat_count = 0
self.i_w = 0.0 # the loop's integrator, carried between cycles
self.loop_w = None # what the loop asked for last cycle, or None
# Did the grid-following loop actually win the LAST arbitration? That is
# the question SAFETY-02 has to ask about the command standing on the
# wire right now, so it is recorded when the arbitration happens rather
# than re-derived a cycle later. Starts False: nothing is on the wire.
self.following = False
self.reason = "starting"
self.grid = self.soc = self.batt = None
self.peak_fc = None
@@ -214,8 +226,29 @@ class Controller:
for msg in result.events:
self.log_event(msg)
# --- SAFETY-02, and it runs BEFORE the early return below -------------
# ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing
# but not yet stale" hold, a run of held cycles would leave
# `disagree_since` untouched across the gap and the timer would resume
# from a start time that predates it. It is evaluated every cycle,
# unconditionally, so a missing meter CLEARS the window - see watchdog.py.
#
# The value compared is the command STANDING ON THE WIRE, which is what
# this cycle's fresh meter reading is a response to. `last_write` is that
# value after the device clamp and confirmed accepted; `target` covers
# the case where nothing has been written yet (and, deliberately, the
# case where the last write was REJECTED - the inverter is then still
# latched on `last_write`, which is exactly what `last_write` holds).
was_tripped = self.sign_wd.tripped
standing = self.last_write if self.last_write is not None else self.target
if self.sign_wd.evaluate(standing, self.grid, self.following, now) \
and not was_tripped:
self.log_event("FAILSAFE " + self.sign_wd.reason)
_LOG.error("SAFETY-02 TRIPPED - %s. Commanding 0 W and holding it "
"until control is started again.", self.sign_wd.reason)
stale_after = float(self.o.get("stale_input_s", 15))
if not self.inputs_ok:
if not self.inputs_ok and not self.sign_wd.tripped:
bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0
if bad_for < stale_after:
# A single missed poll is not a fault. Hold, keep the heartbeat
@@ -235,6 +268,16 @@ class Controller:
claims.append(Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"))
# --- safety stops ----------------------------------------------------
# ⚠️ SAFETY-02 is claimed FIRST and unconditionally while latched. It is
# a SET at P_SAFETY, so it outranks maintenance, peak shaving and the
# loop alike - a controller pushing power the wrong way is wrong
# whatever it thinks it is doing. Re-filed EVERY cycle: claims are not
# sticky, and one filed once would lapse the next cycle and hand the
# actuator silently back to the loop that was driving the wrong way.
if self.sign_wd.tripped:
claims.append(Claim.set("failsafe", P_SAFETY, 0.0,
"sign disagreement"))
if not self.auto:
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped"))
elif not self.inputs_ok:
@@ -306,6 +349,28 @@ class Controller:
resolution = resolve(claims)
if resolution.contradiction:
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log")
# --- SAFETY-02's arming gate ------------------------------------------
# Was the number about to go on the wire chosen by the grid-following
# loop, or by a strategy that commands against the meter on purpose?
# Only the former is expected to agree in sign with P1. See gate 1 in
# watchdog.py - without this, every maintenance grid charge trips the
# failsafe within three minutes of starting.
#
# ⚠️ "THE LOOP WON" IS NOT ENOUGH, and the difference is a whole class of
# false trip. A LIMIT claim can survive the loop winning and still
# replace its number: maintenance's charge-only shaping is
# `hi = -charge_w`, so the loop can ask for +500 W of discharge, win the
# arbitration, and have the emitted value come out at -2500 W of grid
# charge. Winner says "loop"; the wire carries maintenance's intent. So
# the test is on the NUMBER, not on the name: the loop is following only
# while the emitted value points the way the loop asked and is no larger
# than it asked - i.e. limits reduced it, they did not redirect it.
# Zero on either side falls out as not-following, which costs nothing:
# a 0 W command cannot be a wrong-direction command.
asked = self.loop_w
self.following = (resolution.winner == "loop" and asked is not None
and resolution.target_w * asked > 0
and abs(resolution.target_w) <= abs(asked))
self.target = resolution.target_w
self.reason = resolution.explain()
await self.write_setpoint(self.target)
@@ -439,7 +504,14 @@ class Controller:
"battery": self.batt,
"soc": self.soc,
"phase": self.maint.phase,
"status": "running" if self.auto else "stopped",
# ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing
# one. IF-01/IF-02 own `ems/event/failsafe` and the `failsafe active`
# binary sensor and neither is built yet; inventing a bespoke topic
# here would be a surface to migrate later. This sensor is already
# discovered, already on every dashboard, and "failsafe" is
# additive to its vocabulary rather than a change to it.
"status": ("failsafe" if self.sign_wd.tripped
else "running" if self.auto else "stopped"),
}
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
@@ -521,6 +593,22 @@ class Controller:
out.append({"ok": False, "warn": False,
"text": "last write to the inverter was REJECTED - check the log"})
# SAFETY-02. Reported in both states: a watchdog nobody can see is armed
# is a watchdog nobody trusts when it fires.
if self.sign_wd.tripped:
out.append({"ok": False, "warn": False,
"text": "SIGN-DISAGREEMENT FAILSAFE - " + self.sign_wd.reason
+ ". The inverter is held at 0 W. Find out why the "
"command opposed the meter before starting control "
"again; starting clears this."})
else:
held = self.sign_wd.held_s
out.append({"ok": True, "warn": False,
"text": f"sign watchdog: armed (trips after "
f"{self.sign_wd.cfg.hold_s:.0f} s opposed, both over "
f"{self.sign_wd.cfg.min_w:.0f} W)"
+ (f" - opposed for {held:.0f} s now" if held else "")})
if not o.get("estop_fitted", False):
out.append({"ok": False, "warn": True,
"text": "no e-stop fitted: if this host dies the battery stays "
@@ -538,7 +626,11 @@ class Controller:
def status_payload(self) -> dict:
checks = self.checks()
bad = [c for c in checks if not c["ok"] and not c["warn"]]
if bad:
if self.sign_wd.tripped:
# Outranks "NOT READY": nothing is missing, something went wrong.
level, banner = "bad", ("FAILSAFE — the command opposed the meter. "
"Inverter held at 0 W.")
elif bad:
level, banner = "bad", f"NOT READY — {bad[0]['text']}"
elif not self.auto:
level, banner = "warn", "Stopped — inverter commanded to 0 W"
@@ -572,6 +664,15 @@ class Controller:
self.auto = not self.auto
self.store.set("auto", self.auto)
self.log_event("control started" if self.auto else "control stopped")
if self.auto and self.sign_wd.tripped:
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose.
# It clears on a deliberate human start, not on the condition
# going away - the condition ALWAYS goes away, because the trip
# commands 0 W and a 0 W command cannot disagree with anything.
# An operator pressing start has, at minimum, looked at the
# banner that says why.
self.sign_wd.reset()
self.log_event("sign-disagreement failsafe cleared by operator")
if not self.auto:
self.target = 0.0
await self.write_setpoint(0.0, force=True)
+31
View File
@@ -74,6 +74,17 @@ NOT READY banner already names the problem, (b) does not apply.
was overruled. That firmware file's own comment says a lower bound on this
"belongs in CFG-01, not here" - so here it is.
5. `sign_disagree_w` at or above `max_w`. [(a)]
The arbiter's `supervised` limit bounds every command to +-max_w, and
SAFETY-02's predicate needs `abs(command) > sign_disagree_w`. Set the
threshold at or above the ceiling and the command side of the comparison
is unsatisfiable BY CONSTRUCTION: the watchdog can never fire, on any
input, ever. Same shape as 1 and 4 - the operator set a number, a layer
they cannot see makes it mean something else, and nothing says so. Worse
here, because what the status page reports in that state is "sign
watchdog: armed". A disarmed guard that advertises itself as armed is the
one thing this project has decided repeatedly it will not ship.
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
@@ -238,6 +249,26 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
"or rebuild the firmware with a matching max_age_s and update "
"validate.FIRMWARE_INGEST_VETO_S.")
# --- 5. a sign watchdog that can actually fire ---------------------------
# ⚠️ Strictly `>=`, matching the predicate exactly: watchdog.py tests
# `abs(commanded_w) > min_w`, and the arbiter holds `abs(commanded_w)` at or
# below max_w, so threshold == max_w is already unsatisfiable. Anything
# BELOW max_w is reachable and therefore fine - this refuses the disarmed
# case only, not "close to the rail".
sign_w = _number(opts, "sign_disagree_w", 300.0)
if sign_w is None:
problems.append(f"sign_disagree_w {opts.get('sign_disagree_w')!r} is not a number")
elif max_w is not None and max_w > 0 and sign_w >= max_w:
problems.append(
f"sign_disagree_w is {sign_w:.0f} W but max_w is {max_w:.0f} W, so "
"the SAFETY-02 sign-disagreement watchdog can never fire: the "
"arbiter never lets a command exceed max_w, and the watchdog needs "
f"a command over {sign_w:.0f} W. It would sit there reporting "
"'armed' on the status page while being incapable of tripping - the "
f"one runaway failure mode this system has actually observed. Lower "
f"sign_disagree_w below {max_w:.0f} W (300 W is the measured "
"default), or raise max_w.")
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
+220
View File
@@ -0,0 +1,220 @@
"""SAFETY-02 - the sign-disagreement watchdog.
WHAT IT CATCHES, in one sentence: a grid-following loop that is pushing power in
the wrong direction, which every other guard in this system is blind to.
The stale-input watchdog (SAFETY-01) asks "is the meter still talking?". The
saturation freeze asks "can the inverter follow?". The output clamp asks "is the
number too big?". A loop can pass all three and still be charging the battery
out of the grid while the house imports - the meter is fresh, the inverter is
tracking beautifully, and the magnitude is inside the rating. The only thing
that separates that from healthy operation is the SIGN.
⚠️ THIS IS NOT A HYPOTHETICAL. On 2026-08-20 the vendor controller (Illusmart)
did exactly this four times in one day (NOTES.md §4.7). Both captures of that
bus are committed - `sim/scenarios/bus-2026-08-20.log.gz` and the healthy
`bus-2026-08-23.log.gz` in the infra repo - and `sim/replay_sign.py` drives this
module over both of them. Every number in this file was measured on those two
days, not chosen:
day samples gated in sign agreement
2026-08-23 4 127 / 16 349 92.9 % (healthy)
2026-08-20 6 892 / 15 598 55.6 % (the whole fault day)
episode A 15:26-16:43 0.3 %
episode B 17:11-18:28 0.8 %
episode C 20:01-20:31 11.0 %
episode D 20:55-21:11 0.0 %
That is the signature, and it is the SAME signature in all four episodes: not a
noisier version of healthy, a near-total inversion.
------------------------------------------------------------------------------
WHY IT LIVES HERE AND NOT IN THE FIRMWARE OR THE WITNESS
Three vantage points see three different things. The firmware knows what it
WROTE, the witness Pi knows what was ON THE WIRE and whether the inverter ACKed
it, and the add-on knows what it INTENDED and holds the authoritative meter.
This check is not command-versus-wire; it is command-versus-physics. It needs
the emitted setpoint and the P1 net figure in the same place, and it needs the
authority to command zero when they fight:
- the witness is read-only by charter and cannot command anything, so it could
raise the alarm but never take the trip action the AC requires;
- the firmware has the wire but not the meter. §5.1 makes P1 the only
authoritative grid figure and the board only ever receives its AGE
(`sensor.goodwe_p1_sample_age`), never its value. Putting the check there
would mean shipping the meter to the ESP32 for no other reason, and it would
be C++ per install rather than one generic guard;
- the add-on has both halves already, plus an arbiter that can outrank every
strategy with a P_SAFETY claim.
The cost of standing here is real and worth stating: this watchdog compares what
we MEANT to put on the wire against the meter. If the firmware or the driver
inverts the sign on the way out, the add-on's own view still agrees with itself
and this check stays silent. That gap is a wire-versus-intent problem and it
belongs to the witness, not here.
------------------------------------------------------------------------------
WHAT "DISAGREEMENT" MEANS, PRECISELY
disagree = following
and commanded_w * grid_w < 0
and abs(commanded_w) > min_w
and abs(grid_w) > min_w
Four gates, and each one exists to exclude a specific thing that is NOT a fault:
1. `following`. ⚠️ THE GATE THAT IS EASIEST TO FORGET AND MOST EXPENSIVE TO
OMIT. Sign agreement is a property of a GRID-FOLLOWING loop. It is not
expected of a strategy that deliberately commands against the meter -
maintenance is exactly that. A maintenance grid charge of 2500 W into a house
drawing 500 W puts +3000 W on the meter against a -2500 W command: opposed,
both far over any sane threshold, and sustained for HOURS. Armed
unconditionally this watchdog would trip on every single maintenance cycle,
which is not a tuning problem, it is the wrong question being asked. So the
caller says whether the grid-following loop actually won the arbitration this
cycle, and the timer only accumulates when it did.
2. The strict `< 0` product. "Opposed" and "neither side is zero" in one
expression.
3. The 300 W magnitude gate ON BOTH SIDES. Near zero the sign is noise, and near
zero is where a healthy loop LIVES - the deadband holds the command still
while the meter wanders either side of `target_grid_w`. On the healthy day
only 4 127 of 16 349 samples (25 %) are even gated in; the other 75 % are the
converged band where sign carries no information. This is also why the
deadband cannot produce a false trip: inside it the meter is by definition
under 300 W.
4. The 180 s continuity requirement. This is what excludes the legitimate
transient. A load step reverses the meter's sign instantly while the command
is still where the last cycle left it; slew limiting then needs a few cycles
to cross over. Measured on the healthy day, the LONGEST such crossing is 30 s
- and that is with a vendor controller far cruder than ours. 180 s is six
times that. It is not a fitted threshold: at 60 s the healthy day still
produces zero trips, and at 300 s the fault day still produces 13.
⚠️ A BATTERY THAT REFUSES A COMMAND CANNOT TRIP THIS, and that is structural
rather than lucky. At SoC 10 % in Standby, a 1350 W discharge command into a
1350 W import is DECLINED: the battery does nothing, so the meter stays at
+1350 W and the command stays at +1350 W. Same sign. Agreement. A refusal leaves
the meter where it was; only the inverter ACTING moves it, and acting in the
commanded direction moves it toward zero, not through it. The failure mode this
check keys on is the opposite of a refusal - it is the inverter obediently doing
a wrong thing.
⚠️ A FROZEN METER cannot trip it either, for the same reason read the other way:
a stuck reading of +800 W makes the loop command discharge and keep commanding
discharge. Command and meter converge on AGREEING. Freezes are SAFETY-12's and
SAFETY-01's problem, and this watchdog neither helps nor hinders there.
------------------------------------------------------------------------------
STATE, TRIP AND LATCH
One timestamp, not a sample counter. The cycle rate is not fixed - a meter
arrival, or `cycle_floor_s` elapsing, whichever comes first - so N samples is a
different wall-clock window on every path, and the AC is written in minutes.
The latch is not optional and not a nicety. Commanding 0 W removes the command
side of the comparison (|0| is not > 300), so the disagreement disappears the
instant the trip works. Un-latched, this watchdog would trip, clear itself, and
hand the actuator straight back to the loop that was driving the wrong way -
three minutes later, forever. `tripped` therefore stays true until an operator
`reset()`s it by starting control again, which is a deliberate human act after
looking at why.
"""
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class SignWatchdogConfig:
"""Defaults, not constants - config.yaml carries both (SAFETY-02 AC 5)."""
min_w: float = 300.0
hold_s: float = 180.0
class SignWatchdog:
"""One instance per actuator, evaluated once per control cycle.
⚠️ NOT SHARED ACROSS CADENCES. `disagree_since` is a wall-clock start time,
so driving one instance from two loops at two rates would have each one
resetting the other's window. If a fast on-device path ever wants this
check, it gets its own instance - the same exactly-one-writer discipline the
arbiter applies to the actuator, applied to the detector's own state.
"""
def __init__(self, cfg: SignWatchdogConfig | None = None):
self.cfg = cfg or SignWatchdogConfig()
self.disagree_since: datetime | None = None
self.tripped = False
self.reason = ""
# -- the whole check, and it is a pure function of its arguments ----------
def evaluate(self, commanded_w, grid_w, following: bool,
now: datetime) -> bool:
"""Advance the detector one cycle. Returns True while tripped.
`commanded_w` the EMITTED setpoint - what is standing on the wire now,
after deadband, slew, quantisation and the arbiter. Not
the optimizer's raw wish: the wire carries the emitted
value, so anything else can disagree with the meter for a
reason that is not a fault.
`grid_w` net P1, + = importing. None when the meter is stale or
missing, which CLEARS the timer rather than accumulating
- a frozen figure held against a moving command would
otherwise manufacture a disagreement in about three
minutes, on top of SAFETY-01 having already commanded
zero for the same cause.
`following` did the grid-following loop actually win this cycle? See
gate 1 in the module docstring.
`now` the caller's clock, so this stays testable and replayable.
"""
if self.tripped:
# Latched. Do not re-evaluate: the trip removed the evidence.
return True
cfg = self.cfg
disagree = (
following
and commanded_w is not None and grid_w is not None
and commanded_w * grid_w < 0
and abs(commanded_w) > cfg.min_w
and abs(grid_w) > cfg.min_w
)
if not disagree:
self.disagree_since = None
return False
if self.disagree_since is None:
self.disagree_since = now
return False
held = (now - self.disagree_since).total_seconds()
if held < cfg.hold_s:
return False
self.tripped = True
self.reason = (
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against a meter "
f"reading {grid_w:+.0f} W for {held:.0f} s continuously "
f"(limits: both over {cfg.min_w:.0f} W, held over {cfg.hold_s:.0f} s)"
)
return True
@property
def held_s(self) -> float:
"""How long the current disagreement has been standing, for the UI."""
if self.disagree_since is None:
return 0.0
return (datetime.now(self.disagree_since.tzinfo)
- self.disagree_since).total_seconds()
def reset(self) -> None:
self.disagree_since = None
self.tripped = False
self.reason = ""