SAFETY-30: grade each inverter against its own power, and the site's total against the meter
checks / unit (pull_request) Successful in 4m38s

THE DEFECT, in one line. `inv.sign_wd.evaluate(stood, self.grid, ...)` compared
the command THIS controller put on ONE actuator against what the WHOLE SITE is
exchanging with the network. At a roster of one those are two views of the same
thing - specs.md §6.2's identity - and that is what makes the check sound and
what earned it the best record in the safety table (NOTES.md: 93 % agreement
while healthy against 4 % during runaway, four observed episodes, zero false
positives). At two inverters they are not, and it fails in BOTH directions:

  FALSE TRIP  inverter A commanded -1500 W while the meter reads +1500 W is
              evidence of nothing when inverter B is discharging 3000 W exactly
              as told. Nothing is faulty and the highest-value check fires.
  MISSED      the converse, and it is worse. A unit commanded +1500 W and
              MEASURED at -1500 W, beside a sibling at +1500 W, leaves P1
              exactly where a healthy site leaves it. The summation destroys the
              evidence and no threshold recovers it.

DEV-02 had already shipped the per-INSTANCE half - one SignWatchdog per
inverter, namespaced latch, a trip on B no longer latching A. What was left was
the COMPARISON, and it is two questions rather than one:

  PER UNIT  this unit's standing command against ITS OWN measured power
            (`Inverter.batt`, holding register 0x0518, per-instance since
            DEV-02). Conclusion: "the actuator I am driving is not doing what I
            told it" - a fact about one device, so the remedy stays the per-unit
            `failsafe` SET that arbiter.CLAIM_SCOPE has classified SCOPE_UNIT
            from the day it was written. That entry read "the evidence is
            site-wide and the remedy is not"; the first half is now simply true.
  SITE      the SUM of the standing commands against the meter, as a new
            `sign_site` SET at P_SAFETY on the site actuator, latched under flat
            `site_sign_tripped` / `site_sign_reason`. This is the 2026-08-20
            fault day and NO per-unit check can see it, because every inverter
            may be faithfully executing its share of a number that is itself
            wrong. It stops every inverter and the banner names the site.

WHAT IT IS NOT, said here because two guards that overlap without saying so is
how a false-alarm generator gets built. It is not SAFETY-31: circulation.py
compares the units AGAINST EACH OTHER and catches them pushing power THROUGH
each other, which nets to zero at the meter and about which no single command is
evidence. This compares each unit against ITS OWN COMMAND. They overlap in
exactly one scenario - a unit reversing while its sibling pulls the other way -
and test_watchdog.py asserts on that fixture that both fire and what each
concludes. It is also not the saturation freeze, which is a MAGNITUDE test
(|command - measured| > 500 W) that shapes the loop and fires on a merely
saturated unit; this is a DIRECTION test that latches and parks one.

THE FOUR NUMBERS, RE-DERIVED AT TWO INVERTERS - one carried with a justification
and one refused. 1890 closed-loop runs on admin/goodwe's TEST-19 two-master rig
in which NOTHING is faulty, swept over the whole range config.yaml accepts for
`gain` and `slew_w`, across load reversals with write staggers and flapping
loads, and RUN AT BOTH ROSTERS so the N=1 arm is the control:

  unit_sign_disagree_w  250   `saturation_w / 2`. The check gates on both sides,
                              opposed, so the smallest gap it can see is 500 W -
                              exactly where compute()'s own anti-windup test
                              already says the inverter is not following. It
                              cannot fire on a divergence the control law calls
                              ordinary tracking. Worst legitimate opposition at
                              250 W: 10.0 s; the curve is nearly flat, because a
                              unit's own power chases its own command.
  unit_sign_disagree_s   85   8.5x that 10.0 s, 2.8x the firmware's own 30 s
                              link veto (inverter.LINK_STALE_S), under half the
                              180 s site hold.
  site_sign_disagree_w  300   CARRIED. Its argument is the meter's noise floor
                              and the 15 W deadband; the roster touches neither,
                              and the campaign agrees - the gate opens in 994 of
                              1050 runs at two inverters and 955 of 1050 at one.
  site_sign_disagree_s  325   NOT CARRIED, and this is the number the card
                              exists to not assume. The worst legitimate
                              crossing at 300 W is 110 s with one inverter and
                              215 s with TWO, because the command must travel a
                              rail that is the roster sum at a `slew_w` that is
                              not - reachable at gain 0.05, slew_w 50 and a
                              2.2 kW load reversal, all schema-legal. 180 s
                              would have shipped a false trip. 325 s is 1.51x
                              the measured worst, inside the 900 s billing
                              quarter and inside every episode of the fault day.

AT ONE INVERTER NOTHING CHANGES, and "nothing" is asserted rather than assumed.
The two questions are the same sentence there, so only the detector that shipped
is armed: same comparison against the meter, same 300 W / 180 s, same reason
string byte for byte, same namespaced latch and flat rollback mirror, same
`failsafe` claim, same banner, same single status row - and the site detector is
NEVER CALLED, counted on the object over twelve cycles.

VERIFIED BY BREAKING IT. Thirteen mutations on fresh per-mutation trees with no
__pycache__, mounted :ro, with the battery refusing to report at all unless the
baseline is green. Twelve red on the first pass. M4 - the site half armed at N=1
as well - SURVIVED, because the check read `disagree_since is None` and the unit
detector trips first, parks the inverter and leaves the site standing at 0 W:
the assertion ran, the sentence was true, and the data could not reach the
failure it named. Replaced with a call counter on the object; M4 is red now.
Recorded as a survivor in DECISIONS/SAFETY-30-per-unit-sign.md §5, with the
first battery's own RED BASELINE - a shared mutation directory read mid-copy -
discarded rather than reported.

RECORDED, NOT FIXED: the site comparison is defeated by a square-wave load whose
half period is about one control cycle, which holds it opposed for ~1000 s. The
N=1 control arm is what makes that interpretable - it is identical at the roster
this add-on ships, so it is a property of comparing a lagged command against a
meter and has nothing to do with the second inverter. It wants an alternation
test rather than a number, and a card of its own.

SAFETY-24's lo-only asymmetry proof re-run unchanged: 1296 combinations, 0
negative floors, `hi` +inf on every claim, every claim on the SITE actuator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-04 09:33:20 +02:00
co-authored by Claude Opus 5
parent b30797e1f9
commit f000547518
14 changed files with 2126 additions and 89 deletions
+317 -14
View File
@@ -62,7 +62,10 @@ from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
from .store import migrate_unit_keys
from .validate import ConfigRefused, refuse, validate_startup_config
from .watchdog import SignWatchdogConfig, loop_is_following
from .watchdog import (
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
SignWatchdog, SignWatchdogConfig, loop_is_following,
)
from . import web
OPTIONS_PATH = "/data/options.json"
@@ -171,10 +174,42 @@ class Controller:
# first or every existing tripped install comes up ARMED, into the
# fault, with a green banner - which is the exact failure the latch was
# persisted to prevent, rebuilt by a key rename.
sign_cfg = SignWatchdogConfig(
# SAFETY-02's pair, and since SAFETY-30 it is THE SITE COMPARISON's
# pair - which at the shipped roster of one is the same thing under a
# more careful name. It is what `Inverter.sign_wd` is armed with at N=1,
# byte for byte, and what the site detector is armed with at N>=2.
self.sign_cfg = sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("sign_disagree_w", 300)),
hold_s=float(opts.get("sign_disagree_s", 180)),
)
# SAFETY-30. THE PER-UNIT COMPARISON'S OWN PAIR, RE-DERIVED AT TWO
# INVERTERS AND NOT CARRIED ACROSS. `sign_disagree_w` / `sign_disagree_s`
# were measured on two SINGLE-INVERTER bus captures against a command
# opposed to THE METER; this comparison is a command against ONE UNIT'S
# OWN POWER, where the magnitudes, the transient and the worst
# legitimate episode are all different quantities. watchdog.py's
# SIGN_OPTION_DEFAULTS carries the campaign that produced these, and
# DECISIONS/SAFETY-30-per-unit-sign.md carries the tables.
self.unit_sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("unit_sign_disagree_w",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"])),
hold_s=float(opts.get("unit_sign_disagree_s",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"])),
)
# SAFETY-30. THE SITE COMPARISON'S OWN PAIR, USED ONLY AT N>=2. The
# threshold is the shipped 300 W - carried, because the argument behind
# it is about the meter's noise floor and the deadband, neither of which
# the roster touches - and the WINDOW is not, because the transient it
# rejects is the loop crossing zero at `slew_w` per cycle across a rail
# that IS the roster sum. Measured at both rosters on the same stimuli:
# 110 s at one inverter, 215 s at two, against a 180 s hold. See
# watchdog.SIGN_OPTION_DEFAULTS.
self.site_sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("site_sign_disagree_w",
SIGN_OPTION_DEFAULTS["site_sign_disagree_w"])),
hold_s=float(opts.get("site_sign_disagree_s",
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])),
)
self.store_migration = migrate_unit_keys(store, UNIT_A)
self.inverters = build_roster(opts, store, sign_cfg)
@@ -241,6 +276,45 @@ class Controller:
"opposed the meter.", inv.name,
inv.sign_wd.reason or "(reason not recorded)")
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
# ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2.
# The per-unit watchdogs above ask "is THIS actuator doing what I told
# it", graded on that unit's own measured power. This one asks "is the
# SITE driving the wrong way", graded on the SUM of the standing
# commands against P1 - the comparison SAFETY-02 shipped, re-scoped to
# the quantity it was always actually about.
#
# ⚠️ AT A ROSTER OF ONE IT IS CONSTRUCTED AND NEVER EVALUATED, and that
# is the whole compatibility promise of this card rather than an
# oversight. With one inverter the sum of the commands IS that
# inverter's command, so arming this would be arming a duplicate of
# `inverters[0].sign_wd`: two latches for one fault, two banners, and a
# false-positive record measured on ONE comparison being spent twice.
# `cycle()` is where that is enforced and says so at the line.
#
# Restored from the store like SAFETY-02's and SAFETY-31's latches, for
# the reason store.py gives: "restart the add-on" is the first thing an
# operator looking at a FAILSAFE banner tries, and a latch held only in
# memory brings the controller back armed, into the fault, unattended.
# FLAT keys, not namespaced - store.py carries that argument too.
self.site_sign_wd = SignWatchdog(
self.site_sign_cfg,
tripped=bool(store.data.get("site_sign_tripped", False)),
reason=str(store.data.get("site_sign_reason", "") or ""))
if self.site_sign_wd.tripped:
_LOG.error(
"SAFETY-30 (site) was TRIPPED before this restart and stays "
"tripped: %s. Every inverter is held at 0 W. Restarting does "
"not clear this - start control from the UI once you know why "
"the site's total command opposed the meter.",
self.site_sign_wd.reason or "(reason not recorded)")
# And the per-unit detectors are told which question they are asking,
# here as well as in `cycle()`. `checks()` and the status page read
# `inv.sign_wd.cfg` and can be called before the first cycle has run, so
# a roster of two would otherwise advertise the site thresholds on a
# detector that is about to be graded against a unit's own power.
self._arm_sign_watchdogs()
# SAFETY-18. The frozen-but-fresh meter detector - see freeze.py for why
# it is a conjunction and not an `unchanged_s` threshold, and for the
# measurements behind the default.
@@ -692,6 +766,45 @@ class Controller:
def sign_wd(self):
return self._only().sign_wd
# -- SAFETY-30: which comparison each sign detector is making -------------
def _arm_sign_watchdogs(self) -> None:
"""Tell every sign detector which of SAFETY-30's two questions it asks.
⚠️ A PURE FUNCTION OF `len(self.inverters)`, AND THE ONLY PLACE THE
ROSTER DECIDES ANYTHING ABOUT THIS CHECK. Written as one method rather
than as a conditional at the two call sites for `loop_is_following()`'s
reason: the arrangement that does not keep two things in step is the one
where they are written down twice.
AT ONE INVERTER - §6.2's identity, and the whole of AC 8. The single
unit's detector is armed against THE METER with the shipped
`sign_disagree_w` / `sign_disagree_s`, which is what it has always been
armed against, and the site detector is not armed at all. A one-inverter
install therefore makes the same comparison, at the same thresholds,
writes the same reason string, latches under the same keys and files the
same `failsafe` claim as it did before this card.
AT TWO OR MORE - the identity is gone and the meter stops being a view
of any one actuator. Each unit's detector is armed against ITS OWN
MEASURED POWER (`Inverter.batt`, register 0x0518) under the re-derived
per-unit pair, and the site detector is armed against the meter under
the site pair. Two questions, two remedies, two latches.
⚠️ CALLED AGAIN AT THE TOP OF EVERY `cycle()`, not only from
`__init__`. The roster is re-read every cycle for liveness and for the
allocation, and this repository's two-inverter fixtures append an
`Inverter` AFTER the Controller is constructed - a detector frozen at
construction would then be graded against the meter at a roster of two,
which is the defect this card exists to remove, wearing a fixture's
name. `arm()` touches no timer and no latch, so calling it every cycle
cannot disturb an episode in progress.
"""
one = len(self.inverters) <= 1
for inv in self.inverters:
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg,
AGAINST_METER if one else AGAINST_OWN_POWER)
self.site_sign_wd.arm(self.site_sign_cfg, AGAINST_METER)
# -- the site aggregates, each one named for the question it answers ------
# ⚠️ THESE ARE NOT "THE SCALARS WITH A SUM IN FRONT". Each one picks a
# DIFFERENT reduction, because the questions are different, and getting the
@@ -1219,17 +1332,44 @@ class Controller:
# case where the last write was REJECTED - the inverter is then still
# latched on `last_write`, which is exactly what `last_write` holds).
# ⚠️ ONE WATCHDOG PER INVERTER, EVALUATED ON THAT INVERTER'S OWN
# STANDING COMMAND. The meter is site-wide - there is one P1 - but what
# the watchdog concludes is "THE UNIT I AM DRIVING is pushing power the
# wrong way", and that evidence cannot be pinned on a sibling this
# controller commanded differently. `inv.target` is that unit's share
# from the last resolution, which at N=1 is the site target itself.
# STANDING COMMAND. What the watchdog concludes is "THE UNIT I AM
# DRIVING is pushing power the wrong way", and that evidence cannot be
# pinned on a sibling this controller commanded differently.
# `inv.target` is that unit's share from the last resolution, which at
# N=1 is the site target itself.
#
# ⚠️ SAFETY-30: AND WHAT IT IS COMPARED AGAINST IS DECIDED BY THE ROSTER.
# This block used to hand every unit's detector `self.grid`. There is
# ONE P1, so at N>=2 that compares a number this controller put on ONE
# actuator against a measurement that answers for ALL of them, and it is
# wrong in both directions:
#
# FALSE TRIP inverter A commanded -1500 W while the meter reads
# +1500 W is evidence of nothing when inverter B is
# discharging 3000 W exactly as instructed. Nothing is
# faulty and the highest-value check in the safety table
# fires - on a record of zero false positives.
# MISSED the converse, and it is worse. A unit commanded +1500 W
# and MEASURED at -1500 W, beside a sibling at +1500 W,
# leaves P1 exactly where a healthy site leaves it. The
# sum destroys the evidence; no threshold recovers it.
#
# So at N>=2 each unit is graded against ITS OWN measured power -
# `inv.batt`, register 0x0518, per-instance since DEV-02 - which is the
# only instrument that can answer "is THIS actuator doing what I told
# it". At N=1 §6.2's identity holds, the meter IS the view of that one
# unit, and the comparison stays the meter, byte for byte. The site's
# own question has not gone away; it is asked below, once, of the sum.
# `_arm_sign_watchdogs()` states the choice and its docstring argues it.
self._arm_sign_watchdogs()
n_units = len(self.inverters)
standing = {}
for inv in self.inverters:
was_tripped = inv.sign_wd.tripped
stood = inv.last_write if inv.last_write is not None else inv.target
standing[inv.name] = stood
if inv.sign_wd.evaluate(stood, self.grid, self.following, now) \
against = self.grid if n_units <= 1 else inv.batt
if inv.sign_wd.evaluate(stood, against, self.following, now) \
and not was_tripped:
# ⚠️ REASON FIRST, FLAG SECOND, and each `set` writes the file.
# The ordering is the whole atomicity story: a crash between the
@@ -1270,6 +1410,50 @@ class Controller:
# share of it. At the shipped roster of one the two are the same number.
site_standing = float(sum(standing.values()))
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
# The comparison SAFETY-02 shipped, asked of the quantity it was always
# about: the TOTAL this controller has standing on the wire, against the
# meter that total is answerable for. What it catches is a fault no
# per-unit check can see, because every unit is faithfully executing its
# share of a site target that is itself pointing the wrong way - an
# inverted gain, an integrator wound past the rail, a strategy on the
# wrong side of the meter. That is the 2026-08-20 fault day, and it is
# the reason the site question survives rather than being replaced.
#
# ⚠️ ONLY AT N>=2, AND THIS BRANCH IS AC 8. At one inverter the sum of
# the commands IS that inverter's command and the loop above has already
# made exactly this comparison, at exactly these thresholds, on the
# detector whose latch, store keys, claim and banner an install has had
# since SAFETY-02 shipped. Arming this as well would be two latches for
# one fault, two banners, and the same false-positive record spent
# twice. A shipped install does not reach this block at all.
if n_units > 1:
was_site = self.site_sign_wd.tripped
if self.site_sign_wd.evaluate(site_standing, self.grid,
self.following, now) and not was_site:
# REASON FIRST, FLAG SECOND - the same atomicity ordering the
# per-unit trip uses above and SAFETY-31 uses below: a crash
# between the two must not persist a latch with no explanation
# for the operator who has to decide about clearing it.
#
# ⚠️ AND THE KEYS ARE FLAT, NOT NAMESPACED, AND NOT THE
# PRE-DEV-02 PAIR EITHER. `sign_tripped` / `sign_reason` are the
# ROLLBACK VIEW of a UNIT latch (store.py rule 2) and mirroring
# a site trip onto them would hand a downgraded add-on - which
# drives exactly one actuator - a latch taken on evidence about
# a roster it cannot have. `site_sign_*` is its own pair, for
# the same reason `circ_*` is.
self.store.set("site_sign_reason", self.site_sign_wd.reason)
self.store.set("site_sign_tripped", True)
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason)
_LOG.error("SAFETY-30 TRIPPED at the SITE - %s. That is the "
"SUM of every standing command opposing the meter, "
"which no single inverter is answerable for. "
"Commanding 0 W on every inverter and holding it "
"until control is started again. This survives an "
"add-on restart; restarting will not clear it.",
self.site_sign_wd.reason)
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
# not yet stale" hold below. A run of held cycles would otherwise leave
@@ -1309,8 +1493,14 @@ class Controller:
# list below, because its `failsafe` SET is what parks it. One tripped
# unit therefore takes the whole roster past the hold, which is the
# conservative direction: every other unit's claims are re-filed too.
# ⚠️ AND SAFETY-30's SITE LATCH TAKES THE ROSTER PAST THE HOLD TOO, for
# the same reason: its `sign_site` SET is what parks every inverter, and
# a latched site that returned early here would leave each unit's last
# share standing for the heartbeat to re-emit - i.e. the trip would stop
# acting the moment a poll was missed.
if not self.inputs_ok and not any(i.sign_wd.tripped
for i in self.inverters):
for i in self.inverters) \
and not (n_units > 1 and self.site_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
@@ -1364,6 +1554,30 @@ class Controller:
"sign disagreement",
actuator=self.actuator_of("failsafe", inv)))
# --- SAFETY-30: THE SITE SIGN TRIP, AND IT IS A SITE SET ------------
# ⚠️ THE TWIN OF THE LOOP ABOVE, NOT A DUPLICATE OF IT. That loop files
# one `failsafe` per unit on the evidence of THAT unit's own measured
# power, and parks that unit. This files ONE claim on the SITE, because
# what tripped is the SUM of every standing command against the meter -
# a fact about the grid connection, whose offending number is the site
# target and which no single inverter owns. Parking one unit on it would
# leave the rest of the roster pushing the same way with the guard
# satisfied. See arbiter.CLAIM_SCOPE, which classifies `sign_site` and
# `failsafe` side by side and says why they differ.
#
# ⚠️ AND THE `n_units > 1` GUARD IS NOT REDUNDANT, EVEN THOUGH `cycle()`
# never arms this detector at a roster of one. The latch is RESTORED
# FROM THE STORE, so an install that ran with two inverters, tripped,
# and was then reconfigured back to one would come up with
# `site_sign_tripped: true` and would otherwise file a claim on evidence
# about a roster it no longer has. Guarded, that install comes up
# driving - which is right: the fault it latched on is not a statement
# about the site it is now.
if n_units > 1 and self.site_sign_wd.tripped:
claims.append(Claim.set("sign_site", P_SAFETY, 0.0,
"site sign disagreement",
actuator=self.actuator_of("sign_site")))
# --- SAFETY-31: WHAT "ACTED ON" MEANS, AND WHAT IT HONESTLY CANNOT --
# ⚠️ A SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. Read the next
# paragraph before changing it, because the obvious objection is right
@@ -2578,8 +2792,15 @@ class Controller:
# 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.
# SAFETY-30 adds the site sign latch to the same vocabulary, for
# the reason the note above gives: "failsafe" is additive to a
# sensor that already exists and is already on every dashboard, and
# a P_SAFETY stop that did not reach it would be a site at 0 W
# reporting "running".
"status": ("failsafe" if any(i.sign_wd.tripped
for i in self.inverters)
or (self.site_sign_wd.tripped
and len(self.inverters) > 1)
or self.circ_wd.tripped
else "running" if self.auto else "stopped"),
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
@@ -2906,24 +3127,70 @@ class Controller:
# and a trip on B leaves A commanding. A single row would have to pick
# one of them to describe, and the honest answer at a roster of two is
# both. At a roster of one the wording is unchanged.
# ⚠️ AND SINCE SAFETY-30 THE ROW SAYS WHAT THE UNIT WAS COMPARED
# AGAINST, because at a roster of two that is no longer the meter and a
# row that still said "the command opposed the meter" would send an
# operator to look at P1 for a fault that is visible only on one
# inverter's own power sensor. `sign_wd.against` is the same string the
# trip sentence uses, so the two cannot drift apart.
for inv in self.inverters:
tag = "" if len(self.inverters) == 1 else f" [{inv.name}]"
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the
# shipped roster of one the two sentences below are the ones that
# have always been on the page, word for word - AC 8 is about what
# an operator sees as much as about what the detector computes, and
# "opposed a meter reading" would be a gratuitous change to a line
# somebody has learned to recognise. At N>=2 the noun is load-
# bearing: the row would otherwise send them to look at P1 for a
# fault that is visible only on one inverter's own power sensor.
vs = ("" if len(self.inverters) == 1
else f" against {inv.sign_wd.against}")
if inv.sign_wd.tripped:
what = ("the meter" if len(self.inverters) == 1
else inv.sign_wd.against)
out.append({"ok": False, "warn": False,
"text": f"SIGN-DISAGREEMENT FAILSAFE{tag} - "
+ inv.sign_wd.reason
+ ". The inverter is held at 0 W. Find out why the "
"command opposed the meter before starting control "
+ f". The inverter is held at 0 W. Find out why the "
f"command opposed {what} before starting control "
"again; starting clears this. Restarting the "
"add-on does NOT clear it."})
else:
held = inv.sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": f"sign watchdog{tag}: armed (trips after "
"text": f"sign watchdog{tag}: armed{vs} (trips after "
f"{inv.sign_wd.cfg.hold_s:.0f} s opposed, both over "
f"{inv.sign_wd.cfg.min_w:.0f} W)"
+ (f" - opposed for {held:.0f} s now" if held else "")})
# --- SAFETY-30: the SITE row -----------------------------------------
# ⚠️ ONLY AT N>=2, and the absence at N=1 is the honest report rather
# than a missing row. With one inverter the site comparison and the row
# above are the same sentence about the same actuator; printing it twice
# would advertise two guards where there is one, which is how an
# operator learns to skim the list.
if len(self.inverters) > 1:
if self.site_sign_wd.tripped:
out.append({"ok": False, "warn": False,
"text": "SITE SIGN-DISAGREEMENT FAILSAFE - "
+ self.site_sign_wd.reason
+ ". EVERY inverter is held at 0 W. This is the "
"SUM of the commands against the meter, so it is "
"not one inverter's fault to find - look at the "
"loop, the integrator and whatever won the "
"arbitration. Starting control clears this; "
"restarting the add-on does NOT."})
else:
_sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": f"site sign watchdog: armed against the sum of "
f"the commands (trips after "
f"{self.site_sign_wd.cfg.hold_s:.0f} s opposed, "
f"both over "
f"{self.site_sign_wd.cfg.min_w:.0f} W)"
+ (f" - opposed for {_sheld:.0f} s now"
if _sheld else "")})
# --- SAFETY-31 -----------------------------------------------------
# ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and the row says
# so. There is one circulating figure for the site; naming an inverter
@@ -2980,10 +3247,28 @@ class Controller:
level, banner = "bad", ("CIRCULATING POWER — the inverters were "
"pushing against each other. Site held at "
"0 W.")
elif self.site_sign_wd.tripped and len(self.inverters) > 1:
# SAFETY-30's site half, ABOVE the per-unit rows below and below
# circulating power above, and the ordering is the same argument
# both times: the banner carries one sentence, so it carries the one
# that says the widest true thing. A site sign trip means EVERY
# inverter is parked and the fault is in what the site was asking
# for, not in one device - naming a unit here would send an operator
# to swap the wrong box.
level, banner = "bad", ("FAILSAFE — the site's total command "
"opposed the meter. Every inverter held at "
"0 W.")
elif any(i.sign_wd.tripped for i in self.inverters):
# Outranks "NOT READY": nothing is missing, something went wrong.
level, banner = "bad", ("FAILSAFE — the command opposed the meter. "
"Inverter held at 0 W.")
# ⚠️ THE SENTENCE IS UNCHANGED AT A ROSTER OF ONE, where "the meter"
# is what the unit was graded against. At N>=2 it is that unit's own
# power, so the banner says so - see checks() on why the noun is
# load-bearing rather than cosmetic.
level, banner = "bad", (
"FAILSAFE — the command opposed the meter. "
"Inverter held at 0 W." if len(self.inverters) == 1 else
"FAILSAFE — an inverter's command opposed its own measured "
"power. That inverter held at 0 W.")
elif bad:
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may
# need to say more to an operator reading the checks list than fits
@@ -3088,6 +3373,24 @@ class Controller:
# unit in particular.
self.store.set("sign_tripped", False)
self.store.set("sign_reason", "")
# SAFETY-30's SITE latch, cleared by the SAME press and for the
# same reason the per-unit ones are: the condition always goes
# away on its own, because the trip commands 0 W everywhere and
# a site commanding zero cannot oppose anything. Only a human
# who has read the banner can say the site is clear.
#
# ⚠️ UNCONDITIONAL, LIKE THE FLAT PAIR ABOVE, AND NOT GUARDED ON
# THE ROSTER. An install that ran with two inverters, tripped,
# and was then reconfigured back to one would otherwise carry
# `site_sign_tripped: true` in its file forever, with no roster
# that can reach the code which clears it - and would re-arm the
# moment a second inverter was configured again.
if self.site_sign_wd.tripped:
self.log_event("site sign-disagreement failsafe cleared "
"by operator")
self.site_sign_wd.reset()
self.store.set("site_sign_tripped", False)
self.store.set("site_sign_reason", "")
# SAFETY-31, cleared by the SAME press and for the same reason:
# the condition always goes away on its own, because the trip
# commands 0 W everywhere and two inverters at zero cannot