Files
goodwe-addon/goodwe_controller/app/watchdog.py
T
glenn schrooyenandClaude Opus 5.5 29837dfaa7
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s
UX-11: safety log lines give one plain sentence and the code, the figures go to DEBUG
- Every WARNING/ERROR line that reported a safety reason (direction, delivery,
  stuck meter, stale reading, peak guard, circulation, over-rating) now ends in
  the reason's code (messages.reason_code()). The reason itself, figures and
  limits included, is on a DEBUG line right after it and in the action log.
- The reasons and state.json are unchanged, so an old state file loads and
  shows the same way: every saved reason opens with its code.
- test_messages part 6 checks every WARNING+ line at 25 words as logged; nine
  non-safety families are listed with a reason and capped. The byte pin in
  test_watchdog now pins the meaning, in order.
- The meter logs one WARNING per quiet episode, and INFO when back, so the red
  row's "the add-on log shows why" holds. The Why line past the hold names
  the missing reading. Action log: "25 engagements", "Shadow mode off: ...".

No trigger, latch, threshold, log level, notification id, cadence, row colour
or banner order changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 13:34:30 +02:00

701 lines
40 KiB
Python

"""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 212 / 17 280 93.4 % (healthy)
2026-08-20 7 182 / 17 280 56.4 % (the whole fault day)
cluster A 15:26-16:43 0.3 %
cluster B 17:11-18:28 0.9 %
cluster C 20:01-20:31 10.5 %
cluster D 20:55-21:11 0.0 %
⚠️ THOSE AGREEMENT FIGURES ARE THE WEAKEST EVIDENCE ON THIS PAGE, and an earlier
version of this comment leaned on them as though they were the strongest. The
clusters are found by looking for sustained opposed samples, so measuring "how
opposed is this cluster" over them is close to a tautology - and "the healthy day
contains no such cluster" is the same sentence as "the healthy day produces no
trip", not independent corroboration of it.
What actually carries the argument is two things neither the detector nor the
clustering assumes:
1. The RUN-LENGTH DISTRIBUTION. The longest unbroken opposed run on the healthy
day is 30 s. On the fault day there are 18 runs over 180 s and the longest is
1750 s. Nothing about that gap was chosen - it is what the two days measure.
2. The clusters land where a human independently wrote down onsets. NOTES.md
§4.7 records 15:01, 16:56, 19:57 and 20:23 from watching it happen, before
any of this existed, and cluster D coincides with the separately documented
inverter-off runaway of §4.7 item 2.
Be precise about (2), because the convenient overstatement is available and wrong:
the clustering does NOT reproduce four episodes. It yields THREE clusters over the
four documented onsets - C spans both 19:57 and 20:23 - plus a fourth for §4.7
item 2, which is not one of the four. Every trip does fall inside a documented
window, which is the claim worth making.
------------------------------------------------------------------------------
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 1500 W into a house
drawing 500 W puts +2000 W on the meter against a -1500 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.
⚠️ 1500 W, NOT 2500 W, AND THE REASON IS NOT COSMETIC. The example has to be
a charge the arbiter actually produces. When this was written the shipped
`maintenance_charge_w` was 2500 against a `max_w` of 2000, so the cheap-window
claim was `hi = -2500` against a supervised `lo = -2000`: lo > hi, which
arbiter.py resolves as a CONTRADICTION and 0 W, not as a -2500 W charge - a
default maintenance charge that silently never happened. MAINT-04 refused that
pair in validate.py and moved the shipped rate to 2000; CFG-03 gave the
default one source of truth so main.py and MaintConfig could not go on
answering 2500 to a dict that had never said so. The number here stays 1500
because the point of the example is a charge STRICTLY INSIDE the ceiling, not
one that happens to sit exactly on it.
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 212 of 17 280 samples (24 %) are even gated in; the other 76 % 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.
⚠️ ALL OF THAT DEPENDS ON ONE PROPERTY OF THE CONTROL LAW, so it is an invariant
and not a happy accident: the saturation freeze in control.py must keep letting
the command FALL, CROSS ZERO AND REVERSE - "may not wind further in the direction
it is already pushing", not "only corrections that shrink |i_w|". control.py
records the shrink-only encoding as tried and rejected because it pins the command
at its last value indefinitely; a pinned command against a meter that has moved on
is precisely a sustained sign disagreement, so under that encoding this watchdog
WOULD false-trip on a plain load step. If anyone ever revisits that freeze, this
module's no-false-positive argument is downstream of the answer.
------------------------------------------------------------------------------
WHAT IT DOES NOT CATCH
An inverter that ACTS opposite to what it was told. Tell it to discharge 2000 W
and have it charge instead, and the meter moves FURTHER the way it was already
going - so the command and the meter agree, all the way into the rail. This
watchdog compares the loop's output direction against the input state it was
regulating; it is blind to the plant lying about which way it went. That is the
saturation freeze's question (|command - the battery's actual power| > 500 W,
NOTES.md §4.7 row 4), and the two checks are complementary rather than
overlapping. Neither substitutes for the other.
⚠️ THAT PARAGRAPH IS ABOUT THE COMMAND-VERSUS-METER COMPARISON, AND SINCE
SAFETY-30 THAT IS NOT THE ONLY ONE THIS MODULE DRIVES. At a roster of two or
more each unit is ALSO graded against its own measured power, which is exactly
the comparison the paragraph says nothing here makes - see the SAFETY-30 section
below, which says what that does and does not take away from the freeze. At the
shipped roster of one the paragraph stands unchanged, word for word: the site
comparison is the only detector armed and the freeze is still the whole answer.
Nor does it see a sign inversion introduced DOWNSTREAM of this add-on - in the
firmware, on the wire, or in a future driver. Our view stays self-consistent and
this stays silent. That is a wire-versus-intent problem and it belongs to the
witness, which is the only vantage point that sees both.
------------------------------------------------------------------------------
SAFETY-30 - AT TWO INVERTERS THIS IS TWO QUESTIONS, NOT ONE
Everything above was written at a roster of ONE, and at N=1 it is right for a
reason that stops being true at N=2. `commanded_w` is what THIS controller put
on THIS actuator. `grid_w` is what the WHOLE SITE is exchanging with the
network. At one inverter those are two views of one thing - §6.2's identity -
which is what makes comparing them sound. At two they are not:
* A FALSE TRIP THE COMPARISON CANNOT AVOID. "Inverter A commanded -1500 W
while the meter reads +1500 W" is evidence of nothing at N=2: inverter B may
be discharging 3000 W and doing exactly what it was told. No fault anywhere,
and the check NOTES.md calls the highest-value entry in the safety table -
93 % agreement while healthy against 4 % during runaway, four episodes, zero
false positives - fires.
* THE CONVERSE, WHICH IS WORSE. A unit genuinely driving the wrong way is
MASKED, because the site figure NETS. Commanded +1500 W, measured -1500 W,
beside a sibling measured at +1500 W: the meter sits exactly where a healthy
site leaves it. This is not a threshold that is set too high - the
information is destroyed by the summation, and no tuning recovers it.
So the one comparison becomes two, each asked of the evidence that can answer
it, and they are DIFFERENT FAULTS rather than one fault at two scales:
PER UNIT this unit's standing command against ITS OWN measured power -
`Inverter.batt`, which on the live system is holding register
0x0518, this inverter's own AC power, per-instance since DEV-02.
Conclusion: "THE ACTUATOR I AM DRIVING IS NOT DOING WHAT I TOLD
IT". The evidence is about one unit, so the remedy is that unit:
the `failsafe` SET, which arbiter.CLAIM_SCOPE has called a UNIT
claim from the day it was written, for this exact reason.
SITE the SUM of the standing commands against the meter. Conclusion:
"THE SITE AS A WHOLE IS DRIVING THE WRONG WAY" - an inverted gain,
an integrator wound past the rail, a strategy pushing the wrong
side of the meter. That is the fault the 2026-08-20 capture
contains and it is a property of the CONNECTION, so the remedy is
the connection: a SITE SET that stops every inverter.
⚠️ AND AT N=1 THERE IS EXACTLY ONE ARMED DETECTOR, WHICH IS THE ONE THAT
SHIPPED. The two questions above are the SAME SENTENCE at a roster of one: the
sum of one command is that command, and "the site" and "the unit" name one
actuator. Arming both would be arming a duplicate - two latches, two banners and
two chances to spend a false-positive record that was earned by ONE comparison
on two captured bus days. `Controller.cycle()` therefore hands the single unit's
watchdog THE METER, under the shipped `sign_disagree_w` / `sign_disagree_s`,
exactly as before, and never arms the site one. `arm()` below is where that
choice is written down. Nothing about a one-inverter install changes - not the
comparison, not the thresholds, not the store keys, not the claim, not the
banner.
⚠️ WHAT THE PER-UNIT COMPARISON IS NOT. Two guards that overlap without saying
so is how a false-alarm generator gets built, so both neighbours are named:
* IT IS NOT SAFETY-31. `circulation.py` compares the units AGAINST EACH OTHER
- `sum(|B_i|) - |sum(B_i)|` over MEASURED power - and catches two inverters
pushing THROUGH each other, which nets to zero at the meter and about which
no single command is evidence at all. This compares each unit against ITS
OWN COMMAND. Take two units faithfully executing an allocation that ALLOC-01
would refuse: circulating power is the whole loop, and every per-unit sign
check here reads perfect agreement. Take one unit doing the exact opposite
of its share while its sibling covers for it: this reads the whole
disagreement, and circulating power can be zero, because the two measured
powers may well agree in sign. Neither is the other with a different
threshold.
* IT IS NOT THE SATURATION FREEZE, AND THE OVERLAP IS REAL AND BOUNDED. The
freeze asks `|command - measured| > saturation_w` (500 W) and concludes "the
inverter cannot follow"; it then stops the integrator winding, which is a
control action taken every day, on a unit that is merely SATURATED as
readily as on one that is reversed, and it neither latches nor parks
anything. This asks for OPPOSITION - a sign, on both sides of a threshold,
held continuously - which a saturated-but-obedient unit cannot produce at
all, and it LATCHES and commands that unit to zero. The freeze is a
magnitude test that shapes the loop; this is a direction test that stops an
actuator.
⚠️ GATE 1 STILL APPLIES TO BOTH, AND THAT IS A DELIBERATE CHOICE WITH A COST.
The per-unit comparison could have been armed whenever ANY non-zero command is
standing - a unit should do what it is told whoever told it - and that would
catch a wedged inverter during a maintenance charge. It is not, because the
whole no-false-positive argument above is built on `following`, because the
freeze already grades that window, and because this card's own warning is that
SAFETY-02's record was earned at N=1 and must not be spent on a check that fires
when nothing is wrong. What it costs is written here rather than discovered:
between the start of a maintenance cycle and its end, a unit reversing against
its own command is the freeze's to notice, not this module's. Widening it is a
card, with a rig campaign behind it, not an edit.
------------------------------------------------------------------------------
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.
⚠️ AND THE LATCH HAS TO OUTLIVE THE PROCESS. Held only in memory it is not a
latch at all - a container restart clears it and the add-on comes back armed,
into the same fault, unattended. Worse than merely losing state: "restart the
add-on" is the first thing an operator confronted with a FAILSAFE banner and a
0 W inverter will try, and under a supervisor that restarts a crashed add-on it
is a loop of three-minute wrong-direction bursts. `tripped` and `reason` are
therefore constructor arguments, restored from `Store` in `Controller.__init__`
and written back the moment the trip happens. See store.py.
"""
from dataclasses import dataclass
from datetime import datetime
# SAFETY-30. CFG-03: THE ONE IN-CODE COPY OF THE FOUR MULTI-INVERTER DEFAULTS.
# `config.yaml`'s `options:` block is the source of truth an operator edits and
# the Supervisor reads, and `schema:` holds the bounds. This dict exists for the
# reason `circulation.CIRC_OPTION_DEFAULTS` and `maintenance.MAINT_OPTION_DEFAULTS`
# do - the Dockerfile does not put config.yaml in the image, so the running
# add-on cannot read it - and `test_watchdog.py` pins the two together by READING
# config.yaml, so a drift goes red in CI rather than shipping.
#
# ⚠️ EVERY KEY HERE IS DEAD AT A ROSTER OF ONE, and that is the point rather
# than a caveat. `Controller._arm_sign_watchdogs()` arms the single unit's
# detector against the meter with the SHIPPED `sign_disagree_w` /
# `sign_disagree_s`, which are read in main.py exactly where they always were
# and are not in this dict. Nothing below is consulted until a second inverter
# exists.
SIGN_OPTION_DEFAULTS = {
# --- THE MAGNITUDE GATE, AND IT IS NOT SAFETY-02's 300 W BY ACCIDENT ---
# ⚠️ DERIVED FROM `control.Tuning.saturation_w`, WHICH IS 500 W, AND THE
# DERIVATION IS THE POINT. This check gates on BOTH sides being over
# `min_w`, so the smallest divergence it can ever see between a command and
# the power that command produced is `2 * min_w` - the two are OPPOSED, so
# the gap is the sum of the magnitudes. At 250 W that is exactly 500 W: the
# gap at which `compute()`'s own anti-windup test already concludes THE
# INVERTER IS NOT FOLLOWING and freezes the integrator. So this watchdog
# cannot fire on a divergence the shipped control law itself calls ordinary
# tracking, and the two guards meet at a number rather than overlapping in a
# band nobody has drawn.
#
# MEASURED, on 1890 closed-loop two-inverter runs in which NOTHING is faulty
# (every unit executes the share the allocator gave it), swept over the
# WHOLE range config.yaml accepts for `gain` float(0.05,1.0) and `slew_w`
# int(50,5000), across both families of stimulus that can produce legitimate
# opposition. Worst CONTINUOUS opposition between a unit's standing command
# and its own 0x0518 power, as a function of this threshold:
#
# 50 W -> 25.0 s 250 W -> 10.0 s 700 W -> 5.0 s
# 150 W -> 15.0 s 300 W -> 10.0 s 1000 W -> 0.0 s
# 200 W -> 15.0 s 500 W -> 5.0 s
#
# ⚠️ SAFETY-39 MOVED THAT SCHEMA FLOOR FROM 50 TO 0, AND THE CAMPAIGN ABOVE
# WAS NOT RE-RUN. It does not have to be, and here is the argument rather
# than the assurance. What these windows are about is how long the command
# takes to CROSS ITS RAIL - the swept worst case is the SLOWEST crossing,
# which is `slew_w` at its floor, 5000/50 = 100 cycles. `slew_w: 0` means
# "follow the rail" and resolves to `SLEW_RAIL_FRAC * max_w`, which is
# exactly 9 cycles at EVERY rail by construction. So the new value can never
# be the slowest crossing at any `max_w`, and it is strictly inside the
# envelope this campaign measured. An explicit number still spans 50..5000
# and is unchanged. If `SLEW_RAIL_FRAC` is ever loosened past 1/100, this
# paragraph stops being true and the campaign has to be re-run.
#
# ⚠️ AND THE CURVE IS FLAT, WHICH IS THE FINDING THAT DECIDED THE SHAPE OF
# THIS PAIR. Unlike the site comparison - where the meter is driven by the
# LOAD and a flapping load holds it opposed for minutes - a unit's own power
# CHASES ITS OWN COMMAND through a bounded ramp and a first-order lag, so the
# opposition can only last as long as the crossing does. Dropping the
# threshold from 300 W to 50 W buys 15 s of extra transient and nothing else.
# The work is therefore done by the WINDOW below, and this number is set
# where the control law already draws its line rather than being tuned.
#
# The largest opposed magnitude seen anywhere in those 1890 runs is 875 W;
# a threshold above that would gate NOTHING legitimate in at all, and is
# rejected because it would also be blind to a unit reversing a 900 W share.
"unit_sign_disagree_w": 250,
# --- THE WINDOW -------------------------------------------------------
# Three bounds, and the value clears all three.
#
# LOWER, from the rig: 10.0 s - two control cycles, the worst CONTINUOUS
# legitimate opposition above 250 W across all 1890 runs. It is a
# reversing site whose second write lands a cycle after the first, both
# units then slewing through zero behind a 10 s lag; nothing is wrong.
#
# LOWER, from the firmware: `inverter.LINK_STALE_S`, 30 s. A unit not
# executing its share for less than the firmware's own veto window is a
# LINK fault, and the add-on already answers it - liveness drops the unit
# and the survivors carry its share, with the record saying so. Firing
# inside that window would raise a second, louder alarm about a state
# already being handled, which is the SAFETY-15/SAFETY-19 failure exactly.
#
# UPPER, from SAFETY-02 itself: 180 s, the SITE hold. A unit that is not
# doing what it was told must not be parked LATER than the site would be
# parked for the same watts, and unlike the site comparison this one needs
# nothing to net out before it can conclude - the evidence is complete on
# the first cycle it appears.
#
# 85 s: seventeen control cycles - 8.5x the worst legitimate episode this
# rig can produce, 2.8x the firmware's own veto window, and under HALF the
# site hold. ⚠️ IT IS DELIBERATELY NOT 60, 75, 90, 120 OR 180. A round
# number here would be a guess wearing a measurement's clothes, and the
# three bounds above are what the number has to answer to.
#
# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST. Its ramp and lag in the rig
# are inverter A's, COPIED, carrying `measured=False` - SPIKE-04 measures
# the real ones. A slower second unit lengthens every crossing above, which
# is what the 8.5x buys headroom for. SPIKE-04 is the revisit trigger for
# both numbers here, not a reason to move them now.
"unit_sign_disagree_s": 85,
# --- THE SITE COMPARISON AT N>=2, AND IT IS NOT THE SHIPPED PAIR -------
# ⚠️ THE THRESHOLD IS CARRIED AND THE WINDOW IS NOT, WHICH IS A FINDING
# RATHER THAN A COMPROMISE. Only one of the two arguments behind 300 W /
# 180 s depends on how many inverters the site has:
#
# THE THRESHOLD is about NOISE. "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`. The
# deadband is 15 W and P1 is one meter whatever the roster, so nothing
# about that argument moves. 300 W, carried - and the two-inverter
# campaign says the carry is safe rather than merely convenient: in the
# load-reversal family the site figure ever goes opposed AND over 300 W in
# 994 of 1050 runs at two inverters and 955 of 1050 at one. The gate opens
# about as often either way; what changed is how LONG it stays open, which
# is the window's problem and not the threshold's.
#
# THE WINDOW is about a TRANSIENT, and the transient scales with the site.
# The measured worst legitimate case is a load reversal that the loop
# crosses zero slowly: the command has to travel the whole rail at
# `slew_w` per cycle, and at N>=2 the rail is `Controller.site_max_w` - the
# ROSTER SUM - while `slew_w` is unchanged. So the crossing takes about
# twice as long at two inverters as at one, and that is exactly what the
# rig measures.
#
# 1890 closed-loop runs in which nothing is faulty, the SAME campaign the
# per-unit pair above is read off, run at BOTH rosters as its own control.
# Worst CONTINUOUS opposition between the standing command(s) and the meter,
# LOAD-REVERSAL family, at a 300 W threshold:
#
# N=1 (the shipped comparison) 110.0 s - inside 180 s, 1.6x
# N=2 (the sum, same stimuli) 215.0 s - OVER 180 s
#
# ⚠️ SO CARRYING 180 s WOULD HAVE SHIPPED A FALSE TRIP, and it is a config a
# schema-legal install can reach: `gain` 0.05 and `slew_w` 50 are both the
# floor of what config.yaml accepts, and a +-2200 W load reversal is an oven
# and a heat pump. That is the number this card exists to not assume.
#
# 325 s: 1.51x the measured worst, well inside the 900 s quarter that bills
# the capacity tariff, and still inside every episode the fault day
# contains - 2026-08-20's four clusters run 16, 30, 77 and 77 minutes and
# its opposed runs reach 1750 s, so a 325 s window fires in all of them.
# What it costs is stated rather than hidden: about two and a half extra
# minutes of wrong-direction SITE power before the stop, bought to remove a
# false trip that a legal configuration produces. ⚠️ DELIBERATELY NOT 300 OR
# 360; the measurement is what it answers to.
#
# ⚠️ AND IT IS NOT THE PER-UNIT WINDOW EITHER, IN THE OTHER DIRECTION. The
# per-unit comparison is 85 s because a unit's own power CHASES ITS OWN
# COMMAND and the transient is two cycles; the site comparison is 325 s
# because the meter is driven by the LOAD and the transient is the loop
# crossing zero. Same predicate, two instruments, two windows, both measured.
"site_sign_disagree_w": 300,
"site_sign_disagree_s": 325,
}
# ⚠️ A LIMIT OF THE SITE COMPARISON THAT SAFETY-30 DID NOT INTRODUCE AND DOES
# NOT FIX, RECORDED HERE BECAUSE THE CAMPAIGN FOUND IT AND SILENCE WOULD BE
# WORSE. The rig's other stimulus family is a FLAPPING LOAD, and a square wave
# whose half period is about one control cycle opposes the command on EVERY
# cycle: the command chases the previous half-cycle's meter while the meter has
# already reversed. Across the 840 flapping runs the worst continuous opposition
# at a 300 W threshold is 980 s at two inverters - and 1000 s at ONE, which is
# the whole point. The N=1 arm is the control, and it says this is a property of
# comparing a lagged command against a meter, not a property of the second
# inverter. No window inside the 900 s billing quarter survives it, and no
# threshold up to 1000 W does either.
#
# It is not acted on here for three reasons, in order of weight: it is identical
# at the roster this add-on actually ships, so acting on it would be changing a
# live safety path on evidence that has nothing to do with this card; a
# ~6 kW square wave at 0.1 Hz sustained for a quarter of an hour is not a house
# (the captured healthy day's longest opposed run is 30 s); and the fix is not a
# number - it wants the detector to notice that the disagreement is ALTERNATING,
# which is a different check. It is a card, and this comment is its evidence.
# SAFETY-30. THE TWO NOUNS THE TRIP SENTENCE CAN NAME, WRITTEN ONCE. They are
# constants rather than literals at the call sites because `main.py` arms the
# detectors and `test_watchdog.py` grades what they say, and a string typed in
# two places is a string that drifts in one of them - the same argument
# `store.unit_key()` makes about a key name. `AGAINST_METER` is the wording that
# has always shipped and must not change: it is what a one-inverter install's
# `reason` reads, in the store, in the action log and on the status page.
AGAINST_METER = "a meter reading"
AGAINST_OWN_POWER = "its own measured power"
@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
def disagrees(commanded_w, against_w, following: bool,
cfg: SignWatchdogConfig) -> bool:
"""The predicate, and the ONLY place it is written down.
⚠️ Public because `sim/replay_sign.py` needs it too - to describe episodes, to
measure agreement, to sweep the clock-skew guard - and it had reimplemented it
inline. Two copies of a safety predicate is one copy plus a future
disagreement about what the check means, and the copy that drifts is always
the one in the harness that grades it.
⚠️ `against_w` WAS CALLED `grid_w`, AND THE RENAME IS THE WHOLE OF SAFETY-30
IN ONE PARAMETER. The arithmetic is unchanged - opposed signs, neither side
near zero, and the loop actually driving - but WHAT the command is opposed
TO is now a property of the caller rather than a fact about this function.
The site comparison hands it the meter; each unit's own comparison hands it
that unit's measured power (register 0x0518). One predicate, two callers,
for `loop_is_following()`'s reason: a safety predicate written down twice is
one copy plus a future disagreement about what the check means. The
parameter is positional at every call site in both repositories, so the
rename moves no caller.
"""
return (following
and commanded_w is not None and against_w is not None
and commanded_w * against_w < 0
and abs(commanded_w) > cfg.min_w
and abs(against_w) > cfg.min_w)
def loop_is_following(resolution, loop_asked) -> bool:
"""Did the grid-following loop actually choose the number going on the wire?
Gate 1 of the predicate above, and it lives here rather than inline in
`cycle()` for the reason `disagrees()` does: the test suite has to be able to
assert on THE SHIPPED EXPRESSION, not on a transcription of it that drifts
apart from it in silence.
⚠️ IT WAS A TRANSCRIPTION, AND THAT WAS MEASURED, NOT FEARED. The P_PEAK case
in test_watchdog.py exists to make whoever lands peak shaving come here and
change an expectation. With the expression copied into the test it did not do
that: editing the shipped gate alone to `winner in ("loop", "peak")` left every
suite green while the watchdog went blind for the whole of every peak event.
One definition, two callers, and that test now bites.
`resolution` the arbiter's Resolution. Duck-typed on `.winner` and
`.target_w` so this module still imports nothing from arbiter.
`loop_asked` what the grid-following loop claimed this cycle, or None if it
did not claim at all.
"The loop won" is NOT the question, 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 cheap-window shaping is `hi = -maintenance_charge_w`, so
at 1500 W the loop can ask for +500 W of discharge, win the arbitration, and
have the emitted value come out at -1500 W of grid charge. Winner says "loop";
the wire carries maintenance's intent.
So the test is on the NUMBER: the loop is following only while the emitted
value points the way the loop asked AND is no larger than it asked - limits
reduced it, they did not redirect it. The magnitude half is load-bearing on
its own, because a `lo` limit can raise magnitude without flipping sign, and
that is not the loop following either. Zero on either side falls out as
not-following, which costs nothing: a 0 W command cannot be a
wrong-direction command.
⚠️ FORWARD HAZARD, and the claim block in cycle() already anticipates it.
`winner == "loop"` disarms the watchdog under ANY higher-priority SET claim.
Today only maintenance and the safety stops emit one and neither follows the
meter, so the gate costs nothing. A peak-shaving strategy claiming SET at
P_PEAK **is** grid-following in sign, and the moment it lands this goes blind
for the duration of every peak event. Widening it means asking "does the
winning strategy follow the meter", not adding another name to a comparison.
"""
return (resolution.winner == "loop" and loop_asked is not None
and resolution.target_w * loop_asked > 0
and abs(resolution.target_w) <= abs(loop_asked))
class SignWatchdog:
"""One instance per COMPARISON, evaluated once per control cycle.
⚠️ SINCE SAFETY-30 THAT IS NOT THE SAME SENTENCE AS "one per actuator",
which is what this docstring used to say. There is one instance per
inverter - `Inverter.sign_wd`, latched under that unit's own store keys -
AND one for the site, `Controller.site_sign_wd`, latched under flat ones.
The two ask different questions of different evidence and reach different
remedies; `arm()` below is where an instance is told which of them it is.
At a roster of one they would be the same question, so only the per-unit
instance is ever armed there - see the SAFETY-30 section of the module
docstring.
⚠️ 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, *,
tripped: bool = False, reason: str = "",
against: str = AGAINST_METER):
self.cfg = cfg or SignWatchdogConfig()
# SAFETY-30. WHAT THE COMMAND IS BEING COMPARED AGAINST, as the operator
# reads it in the trip sentence. The default is the meter, which is what
# every trip this add-on has ever taken was compared against and is what
# keeps a one-inverter install's `reason` string byte-identical.
self.against = str(against)
self.disagree_since: datetime | None = None
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED. A latch that lives only in
# memory is not a latch: a container restart clears it, and the add-on
# comes back armed, into the same fault, with no operator involved. See
# store.py's note on `sign_tripped` for the measured behaviour.
self.tripped = bool(tripped)
self.reason = reason if self.tripped else ""
# -- which of SAFETY-30's two questions this instance is asking ----------
def arm(self, cfg: SignWatchdogConfig, against: str) -> None:
"""Set the thresholds and the subject of the comparison. Nothing else.
⚠️ IT IS CONFIGURATION, NOT STATE, AND IT DELIBERATELY DOES NOT TOUCH
`disagree_since`, `tripped` OR `reason`. A detector that cleared its own
window when it was told what it was comparing against would lose the
episode it is in the middle of, every cycle, and would never accumulate
a hold at all - the check would be permanently green and nothing would
say so.
⚠️ AND IT IS RE-STATED EVERY CYCLE RATHER THAN FROZEN AT CONSTRUCTION.
The answer is a property of the ROSTER and of nothing else, and
`Controller.cycle()` already re-reads the roster every cycle for
liveness and for the allocation. Freezing it in `__init__` would also
make it wrong for any caller that appends an inverter afterwards, which
is exactly how a two-inverter fixture is built in this repository - and
a fixture that silently got the one-inverter comparison would grade
nothing while looking green.
"""
self.cfg = cfg
self.against = str(against)
# -- the whole check, and it is a pure function of its arguments ----------
def evaluate(self, commanded_w, against_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. For a per-unit instance it is
THAT UNIT'S share of it; for the site instance it is the
roster sum.
`against_w` what the command is graded against, and SAFETY-30 made
this a decision rather than a fact: net P1 for the SITE
comparison (+ = importing), and this unit's own measured
power for the PER-UNIT one (+ = discharging, register
0x0518). Both carry the same sign convention as the
command, which is why one predicate serves both. None
when the reading 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, and the SAFETY-30 section
on why the per-unit comparison keeps it.
`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
if not disagrees(commanded_w, against_w, following, cfg):
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.against` IS THE ONLY THING SAFETY-30 CHANGED IN THIS STRING,
# and it defaults to the wording that shipped - "a meter reading" - so a
# one-inverter install's `reason` is byte-identical to what an operator
# has always read (test_watchdog.py pins its meaning). A per-unit
# instance names what it actually compared instead, because "commanded
# +1500 W against a meter reading -1500 W" would be a false statement
# about which instrument produced the evidence.
self.reason = (
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against "
f"{self.against} {against_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
def held_s(self, now: datetime) -> float:
"""How long the current disagreement has been standing, for the UI.
⚠️ Takes the clock rather than reading one. It was a property calling
`datetime.now()`, which quietly broke the property this module's header
advertises - every other observable here is a pure function of what it
was handed - and an unclocked function is one nothing can test, which is
exactly what happened to it.
"""
if self.disagree_since is None:
return 0.0
return (now - self.disagree_since).total_seconds()
def reset(self) -> None:
self.disagree_since = None
self.tripped = False
self.reason = ""