SAFETY-31: measure circulating power, which nets to zero at the meter
checks / unit (pull_request) Successful in 4m28s
checks / unit (pull_request) Successful in 4m28s
Inverter A charging at 3 kW while inverter B discharges at 3 kW nets ~0 W at
P1. The site reads idle while both packs are cycled through two round-trip
conversion losses continuously, for no benefit, and the wear is real.
NOTHING EXISTING CAN SEE IT, AND THAT IS STRUCTURAL. Every guard in this
add-on is a consumer of the NET figure at the grid connection: SAFETY-02's
sign watchdog gates on abs(grid_w) > 300 and the gate never opens at a netted
zero; the peak guard and the capacity tariff both bill grid exchange.
Circulating power is precisely the component of per-inverter power that the
sum destroys, so no threshold on any existing detector could reach it.
WHAT ALLOC-01 ALREADY COVERED, AND IS NOT RE-IMPLEMENTED HERE. Its AC 7
shipped the sign-coherence gate (allocator.REFUSE_SIGN, graded by
test_alloc.py part 6): an allocation whose shares carry two signs is REFUSED,
both units named, no shares returned. That is the COMMAND half - the allocator
can no longer command circulating power. This commit is the MEASUREMENT half:
circulation the allocator NEVER COMMANDED.
app/circulation.py the sensor and the trip
main.py site_circulating_w, the evaluation, the claim, the alarm
arbiter.py `circulation` classified SCOPE_SITE, with the argument
store.py a flat, site-level durable latch
mqtt.py sensor.goodwe_circulating_power
config.yaml two options, two schema ranges, CFG-03 discipline
THE SENSOR: sum(|Bi|) - |sum(Bi)|. Zero when the units pull the same way,
2*min(|Ba|,|Bb|) when they oppose. DERIVED FROM MEASURED PER-INVERTER POWER -
Inverter.batt, register 0x0518 - and never from the allocation record. The
cheap version would be simpler and worth nothing: ALLOC-01's gate already
guarantees the record is sign-coherent, so a record-derived sensor reads zero
BY CONSTRUCTION and can only confirm that the allocator agrees with itself,
which is the third failure mode MUTATION.md names. What this catches is a unit
ignoring or lagging its share, a stale share still being executed, a
maintenance or SAFETY-25 pin holding one unit while the loop drives the other,
or HW-01's power stage running its last instruction for 6 h 25 m with the
comms dead and the allocation record impeccable throughout. batt became
per-instance in DEV-02; before that this could not have been written.
VERIFIED BY MAKING ONE UNIT IGNORE ITS SHARE, on the TEST-19 two-master rig:
S = -2500 W shares a = -1250 W b = -1250 W <- record ok, ONE sign, sums
MEASURED A = -1250 W B = +1000 W <- hardware
sensor over the RECORD : 0 W
sensor over MEASURED power : 2000 W -> trips at 190 s
and the card's own scenario, A -3000 W against B +3000 W at zero house load:
the meter reads +0 W, the sensor reads 6000 W, and the shipped SignWatchdog's
predicate is FALSE on 30 of 30 cycles.
WHAT "ACTED ON" MEANS - decided, and recorded in DECISIONS/. The trip files
Claim.set("circulation", P_SAFETY, 0.0) on the SITE actuator: every inverter
to 0 W, latched. HW-01 established that commanding a wedged inverter achieves
nothing, so the remedy is not aimed at it. A circulating loop needs TWO
participants and one of them is THIS CONTROLLER, faithfully driving the
listening sibling into the one that stopped listening. Stopping (1) halves the
loop, which needs no cooperation from the wedged unit, and (2) - the more
important half - makes the residue VISIBLE at the meter instead of cancelled
at it, where every ordinary guard can finally see it. What it cannot do is
stop the wedged unit; the notification says so and names the isolator as the
last actuator. SITE-scoped because the quantity is defined over the roster and
is identically zero for any one unit - there is no fact of the matter about
which of two opposing inverters is the offender, so filing it per-unit would
invent an attribution and could park the only unit still listening.
THE WINDOW IS FROM THE RIG, NOT A ROUND NUMBER (AC 3). 924 closed-loop runs in
which NOTHING is faulty, over both families of legitimate opposition (7
reversals x 5 write staggers; 7 flap periods x 4 amplitudes), swept across the
whole range the schema accepts for gain and slew_w. Worst CONTINUOUS
legitimate opposition by threshold: 505.0 s at 100 W, 40.0 s at 250 W, 10.0 s
at 300 W, 5.0 s at 400 W and above. The knee is 400 W and it is the schema
floor - below it, ordinary operation holds the sensor over the line for
minutes and no window inside the 900 s billing quarter could tell that from a
fault. circulation_w 700 is 1.75x the knee, the headroom bought for inverter
B's ramp and lag being PLACEHOLDERS until SPIKE-04 measures them.
circulation_hold_s 75 clears three bounds: 15x the worst legitimate episode,
2.5x the firmware's own LINK_STALE_S veto, 12x inside the quarter. A momentary
opposition while one unit slews through zero is LEGITIMATE - writes are
serialised, so B's number lands up to a cycle after A's - and 0 of the 924
runs trip.
THE ALARM SURVIVES AN HA RESTART (SAFETY-34). persistent_notification is in
memory, which is exactly how the 2026-09-02 outage went unseen for 6 h 25 m
with the detector correct throughout. The latch is persisted to /data as flat
site-level keys and the notification is RE-RAISED on the first cycle after
every start, through the same code path as a fresh trip - two paths would be
two chances for the restart one to rot unnoticed.
AT N=1 NOTHING CHANGES (AC 5). |B| - |B| is 0 for every reading including one
nobody took, no claim is ever filed, the arbitration is byte-identical, and
the status page says n/a rather than claiming to watch something that cannot
happen.
VERIFIED BY BREAKING IT (AC 4). 18 mutations, all RED on named checks, each on
a freshly copied tree with no __pycache__ mounted :ro. M10 - the sensor
rewritten to read self.allocation.shares - is the headline: it takes AC 2 red.
Three mutations are +0 B, the risky delta, and are marked as such. One
equivalent mutant (the max(0.0,...) guard) is ARGUED rather than claimed:
searched over 700 000 rosters without a negative. One genuine SURVIVOR was
found and closed - publish() turning an unknown into a published 0 W, the
sensor's own None rule defeated one layer out. Full ledger in DECISIONS/.
arbiter.CLAIM_SCOPE gains its ninth source, so test_peakguard.py's 10f and 10g
tripwires and test_arbiter.py's scope table went red on the line the append
was written. Updated deliberately, with the argument, as their own comments
require. Full 19-file suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -132,6 +132,25 @@ CLAIM_SCOPE = {
|
||||
# controller regulates against. Both must stop every inverter, and a site
|
||||
# SET at P_SAFETY is how that is said once rather than N times.
|
||||
"safety": SCOPE_SITE,
|
||||
# main.py: `Claim.set("circulation", P_SAFETY, 0.0, "circulating power")`.
|
||||
# SAFETY-31, and this one is a SITE claim for a reason worth writing down,
|
||||
# because the neighbouring `failsafe` entry looks like it and is not.
|
||||
#
|
||||
# `failsafe` is SAFETY-02: site-wide evidence, per-unit conclusion. It
|
||||
# compares the meter against THE COMMAND THIS CONTROLLER PUT ON ONE
|
||||
# ACTUATOR, so what it learns is about that actuator and the remedy is that
|
||||
# actuator.
|
||||
#
|
||||
# Circulating power is not a property any single inverter has. It is
|
||||
# `sum(|B_i|) - |sum(B_i)|`, defined over the whole roster and identically
|
||||
# zero for any one unit, and there is NO FACT OF THE MATTER about which of
|
||||
# two opposing inverters is the offender - a unit ignoring its share and a
|
||||
# sibling faithfully executing a share computed to sum with it produce the
|
||||
# same reading. Filing this per-unit would require inventing an attribution
|
||||
# the measurement cannot support, and would park one inverter while the
|
||||
# other went on feeding the loop. The evidence is site-level and so is the
|
||||
# remedy: stop commanding, everywhere, and say why.
|
||||
"circulation": SCOPE_SITE,
|
||||
|
||||
# --- UNIT: about one pack, one inverter, one device -------------------
|
||||
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""SAFETY-31 - the power two inverters move through each other, which the meter cannot see.
|
||||
|
||||
Inverter A charging at 3 kW while inverter B discharges at 3 kW nets ~0 W at
|
||||
P1. The site looks idle. In reality 3 kW is going round a loop through two
|
||||
conversion stages continuously, for no benefit, and the wear on both packs is
|
||||
real even though every instrument in this add-on says nothing is happening.
|
||||
|
||||
WHY NOTHING EXISTING SEES IT, AND IT IS STRUCTURAL RATHER THAN AN OVERSIGHT
|
||||
==========================================================================
|
||||
Every consumer of the site measurement is a consumer of the NET. SAFETY-02's
|
||||
sign-disagreement watchdog gates on `abs(grid_w) > cfg.min_w` (300 W by
|
||||
default) - with the grid at ~0 that gate never opens, on any cycle, for any
|
||||
duration. The peak guard bills the quarter-hour average AT THE GRID
|
||||
CONNECTION. The capacity tariff bills grid exchange. `freeze.looks_frozen()`
|
||||
grades P1 against the site rail. There is one meter, it reads the sum, and
|
||||
circulating power is exactly the component of per-unit power that the sum
|
||||
DESTROYS. So this cannot be fixed by tuning a threshold on any existing
|
||||
detector; it needs a quantity none of them are computing.
|
||||
|
||||
THE QUANTITY
|
||||
============
|
||||
circulating_w = sum(|B_i|) - |sum(B_i)|
|
||||
|
||||
Zero when every unit pulls the same way - then sum(|B_i|) == |sum(B_i)| by the
|
||||
triangle equality. `2 * min(|B_a|, |B_b|)` at N=2 when they oppose, which is
|
||||
twice the power going round the loop and is the right scale: that is what both
|
||||
conversion stages are processing between them. It is non-negative always, it is
|
||||
zero at N<=1 identically, and it needs no threshold to be interpretable - the
|
||||
number IS the phenomenon in watts.
|
||||
|
||||
⚠️ IT IS COMPUTED FROM MEASURED PER-INVERTER POWER, AND THAT IS THE WHOLE CARD.
|
||||
=============================================================================
|
||||
The obvious cheap version reads the allocation record - the shares the
|
||||
allocator just handed out - and reports the same formula over those. It would
|
||||
be simpler, it would never see a `None`, and it would be worth NOTHING, for two
|
||||
independent reasons.
|
||||
|
||||
The first is that it cannot fire. ALLOC-01 shipped the sign-coherence gate as
|
||||
its AC 7 (`allocator.REFUSE_SIGN`, graded by `test_alloc.py` part 6): an
|
||||
allocation whose shares carry two signs is REFUSED, both units named, no shares
|
||||
returned. So a sensor over the allocation record is a sensor over a set the
|
||||
allocator has already guaranteed is sign-coherent. It reads zero by
|
||||
construction, forever, and a check written against it passes forever.
|
||||
|
||||
The second is the one MUTATION.md names as the third way a check lies. A sensor
|
||||
derived from the allocation record can only ever confirm THE ALLOCATOR AGREES
|
||||
WITH ITSELF. Every fault in this card's list is a divergence between what was
|
||||
commanded and what the hardware is doing:
|
||||
|
||||
* a unit ignoring or lagging its share;
|
||||
* a stale share still being executed by one inverter;
|
||||
* SAFETY-25's deferred loop floor, or a maintenance drain, pinning one unit
|
||||
while the loop drives the other;
|
||||
* an inverter doing something of its own accord - and THIS HARDWARE HAS DONE
|
||||
THAT TWICE. On 2026-09-02 the comms subsystem failed and the power stage
|
||||
went on executing its last instruction for 6 h 25 m (HW-01). Through all of
|
||||
it the allocation record was impeccable.
|
||||
|
||||
In every one of those the command side is coherent and the MEASUREMENT side is
|
||||
not. So the input is `Inverter.batt` - this unit's own power, read from its own
|
||||
`batt_entity`, which on the live system is holding register 0x0518, the
|
||||
inverter's own AC power. DEV-02 made that per-instance; before it there was one
|
||||
`batt` scalar on the Controller and this sensor could not have been written at
|
||||
all.
|
||||
|
||||
⚠️ AND THIS IS THE MEASUREMENT HALF OF A PAIR. ALLOC-01's gate is the COMMAND
|
||||
half: the allocator can no longer COMMAND circulating power. This module is the
|
||||
detection half: circulating power the allocator never commanded. Neither one
|
||||
replaces the other and neither is redundant - the gate refuses an allocation
|
||||
before it is written, and cannot see a millisecond past that; this reads the
|
||||
hardware and does not care what was written. Do not "unify" them.
|
||||
|
||||
THE TRIP, AND WHY A THRESHOLD ALONE IS A FALSE-ALARM GENERATOR
|
||||
==============================================================
|
||||
A momentary opposition is LEGITIMATE. Writes to the inverters are serialised
|
||||
(one `hass.call()` at a time - see `Inverter`'s docstring on what a hung write
|
||||
to A costs B), so on a cycle where the site target reverses, one unit receives
|
||||
its new command up to a whole control cycle before its sibling. Both then slew
|
||||
through zero, with a real ramp rate and a real first-order lag, and BETWEEN THE
|
||||
TWO ZERO CROSSINGS THE UNITS GENUINELY OPPOSE. Nothing is wrong; the site is
|
||||
turning around.
|
||||
|
||||
This project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19) and
|
||||
this is exactly the shape that generates it. So the trip is a threshold AND a
|
||||
sustained window, and BOTH numbers were measured on the TEST-19 two-master rig
|
||||
rather than chosen - see `CIRC_OPTION_DEFAULTS` for the measurements and
|
||||
`DECISIONS/SAFETY-31-circulating-power.md` for the runs.
|
||||
|
||||
⚠️ THE WINDOW ACCUMULATES, WHICH IS WHY THIS IS NOT SAFETY-02's PROBLEM AGAIN.
|
||||
The reason the sign watchdog is blind to the broadcast fight is that the
|
||||
disagreement FLIPS SIGN on alternate ticks, so `disagree_since` is cleared
|
||||
between every pair of them and the 180 s hold never accumulates (allocator.py's
|
||||
docstring records the measurement). Circulating power cannot do that: it is a
|
||||
MAGNITUDE, non-negative by construction, and a sustained loop holds it high on
|
||||
every cycle rather than on every other one. An oscillation that genuinely
|
||||
alternated between opposing and agreeing is not a circulating loop and should
|
||||
not be reported as one.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
# CFG-03. THE ONE IN-CODE COPY OF THESE DEFAULTS. `config.yaml`'s `options:`
|
||||
# block is the source of truth an operator edits and the Supervisor reads;
|
||||
# `schema:` holds the bounds. This dict exists for the reason
|
||||
# `maintenance.MAINT_OPTION_DEFAULTS` does - 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.
|
||||
CIRC_OPTION_DEFAULTS = {
|
||||
# --- THE THRESHOLD, AND IT IS READ OFF A CURVE ------------------------
|
||||
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 924 closed-loop
|
||||
# runs in which NOTHING is faulty - every unit executes the share the
|
||||
# allocator gave it - over both families of stimulus that can produce
|
||||
# legitimate opposition: single reversals (7 sizes x 5 write staggers) and a
|
||||
# flapping load (7 periods x 4 amplitudes), each swept across the WHOLE
|
||||
# range config.yaml accepts for the two settings that decide how fast the
|
||||
# site can turn around, `gain` float(0.05,1.0) and `slew_w` int(50,5000).
|
||||
# The worst CONTINUOUS opposition, as a function of the threshold:
|
||||
#
|
||||
# 100 W -> 505.0 s 300 W -> 10.0 s 700 W -> 5.0 s
|
||||
# 200 W -> 445.0 s 350 W -> 10.0 s 1000 W -> 5.0 s
|
||||
# 250 W -> 40.0 s 400 W -> 5.0 s 1700 W -> 0.0 s
|
||||
#
|
||||
# THE KNEE IS AT 400 W and it is sharp. Below ~250 W a flapping load holds
|
||||
# the sensor above the threshold for MINUTES at a time - 505 s at 100 W -
|
||||
# and no window shorter than the 900 s billing quarter could tell that from
|
||||
# a fault. At 400 W and above the worst legitimate episode collapses to 5.0
|
||||
# s, ONE control cycle, and stays there. So this number is not a sensitivity
|
||||
# preference; it is the edge of the regime in which the guard is
|
||||
# answerable at all.
|
||||
#
|
||||
# 700 W is 1.75x the knee, and the margin is bought for one named reason:
|
||||
# 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 or faster second unit moves the transient's shape, and
|
||||
# the headroom above the knee is what absorbs that without re-opening this
|
||||
# card. Revisit it when SPIKE-04 lands, not before.
|
||||
#
|
||||
# What it costs: a circulating loop smaller than 350 W per unit is not
|
||||
# reported. That is deliberate - at that scale the round-trip loss is a few
|
||||
# watts and the wear is proportionate to it.
|
||||
"circulation_w": 700,
|
||||
|
||||
# --- THE WINDOW -------------------------------------------------------
|
||||
# Two lower bounds, one upper, and the chosen value clears all three.
|
||||
#
|
||||
# LOWER, from the rig: 5.0 s. The worst continuous legitimate opposition
|
||||
# above 700 W across all 924 runs is exactly one control cycle - a
|
||||
# reversing site whose second write lands a cycle after the first, which
|
||||
# is what the serialised write path does on every reversal.
|
||||
#
|
||||
# LOWER, from the firmware: `inverter.LINK_STALE_S`, 30 s. A unit still
|
||||
# executing a stale share for less than that is a LINK fault, and the
|
||||
# add-on already has an answer for 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 that is already
|
||||
# being handled, which is the SAFETY-15/SAFETY-19 failure exactly.
|
||||
#
|
||||
# UPPER, from the bill: 900 s, the quarter that sets the capacity tariff.
|
||||
# A circulating loop must be caught and acted on well inside one.
|
||||
#
|
||||
# 75 s: fifteen control cycles - 15x the worst legitimate episode this rig
|
||||
# can produce, 2.5x the firmware's own veto window, and 12x inside the
|
||||
# quarter. ⚠️ IT IS DELIBERATELY NOT 60 OR 120. 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.
|
||||
"circulation_hold_s": 75,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CirculationConfig:
|
||||
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
||||
|
||||
min_w: float = float(CIRC_OPTION_DEFAULTS["circulation_w"])
|
||||
hold_s: float = float(CIRC_OPTION_DEFAULTS["circulation_hold_s"])
|
||||
|
||||
|
||||
def circulating_w(powers):
|
||||
"""`sum(|B_i|) - |sum(B_i)|` over MEASURED per-inverter power, in W.
|
||||
|
||||
`powers` one measured power per inverter, in roster order, + = discharge.
|
||||
A unit whose reading did not arrive is `None`.
|
||||
|
||||
Returns a non-negative float, or `None` when the answer is unknown.
|
||||
|
||||
⚠️ N<=1 IS IDENTICALLY ZERO AND RETURNS ZERO EVEN FOR A MISSING READING
|
||||
(AC 5). With one term `sum(|B|) - |sum(B)| == |B| - |B| == 0` for EVERY
|
||||
value of B, including a value nobody read. Returning `None` there would be
|
||||
reporting an unknown where the algebra has no room for one, and it would put
|
||||
a shipped one-inverter install permanently in the "cannot tell" state on a
|
||||
quantity that cannot be anything but zero. Nothing changes at the shipped
|
||||
roster; that is the compatibility promise this card makes.
|
||||
|
||||
⚠️ AND AT N>=2 A MISSING READING IS `None`, NOT ZERO, WHICH IS THE OPPOSITE
|
||||
DIRECTION AND FOR A REASON. Zero would be this sensor reporting HEALTH from
|
||||
an absence - the precise shape of the 2026-09-02 outage, where a subsystem
|
||||
stopped answering and its silence read as calm for 6 h 25 m. An unknown
|
||||
circulating figure is an unknown, it CLEARS the trip's window rather than
|
||||
accumulating it (see `CirculationWatchdog.evaluate`), and the site is
|
||||
already stopped for a missing per-unit reading anyway - `inputs_ok` requires
|
||||
every unit's `batt`, and §11.1 rule 1 commands 0 W without consulting this.
|
||||
"""
|
||||
vals = list(powers)
|
||||
if len(vals) <= 1:
|
||||
return 0.0
|
||||
if any(v is None for v in vals):
|
||||
return None
|
||||
vals = [float(v) for v in vals]
|
||||
# ⚠️ max(0.0, ...) IS DEFENSIVE, AND IT IS AN EQUIVALENT MUTANT - SAID HERE
|
||||
# RATHER THAN LEFT FOR THE NEXT MUTATION SWEEP TO REDISCOVER. The triangle
|
||||
# inequality makes this non-negative mathematically, and in floating point
|
||||
# it is non-negative too: for a same-sign roster `abs(v)` is the list itself
|
||||
# up to one sign, so the two sums accumulate THE SAME MAGNITUDES IN THE SAME
|
||||
# ORDER and are bit-identical; for a mixed-sign roster the two differ by
|
||||
# 2*min(...), which is enormous beside any rounding. Searched, not assumed -
|
||||
# 700 000 random rosters of 2 to 12 units across 10 orders of magnitude
|
||||
# produced no negative result.
|
||||
#
|
||||
# So removing this line changes no observable and no check here would go
|
||||
# red. It stays because the cost is one call and the failure it would
|
||||
# prevent - a power sensor publishing -1e-16 W, which is the kind of value
|
||||
# somebody eventually writes a `< 0` branch against - is out of proportion
|
||||
# to it. Do not report its survival as a gap in the checks.
|
||||
return max(0.0, sum(abs(v) for v in vals) - abs(sum(vals)))
|
||||
|
||||
|
||||
def opposing(powers, min_w: float = 0.0):
|
||||
"""The units pulling each way right now, as `(discharging, charging)` names.
|
||||
|
||||
Indices, not names - the caller owns the roster order. Used for the operator
|
||||
sentence: "which two inverters are pushing against each other" is the first
|
||||
thing somebody woken by this alarm needs, and reconstructing it from the
|
||||
number alone is impossible.
|
||||
|
||||
`min_w` ignores units below a magnitude, so a unit resting at 3 W of
|
||||
standby draw is not named as a participant in a 3 kW loop.
|
||||
"""
|
||||
pos = [i for i, v in enumerate(powers)
|
||||
if v is not None and float(v) > float(min_w)]
|
||||
neg = [i for i, v in enumerate(powers)
|
||||
if v is not None and float(v) < -float(min_w)]
|
||||
return pos, neg
|
||||
|
||||
|
||||
class CirculationWatchdog:
|
||||
"""ONE PER SITE, evaluated once per control cycle.
|
||||
|
||||
⚠️ ONE PER SITE, WHERE SAFETY-02's IS ONE PER ACTUATOR, AND THE ASYMMETRY IS
|
||||
THE WHOLE ATTRIBUTION ARGUMENT. `SignWatchdog` concludes "THE UNIT I AM
|
||||
DRIVING is pushing power the wrong way" - evidence about one actuator, so
|
||||
the remedy is that actuator (arbiter.CLAIM_SCOPE calls `failsafe` a UNIT
|
||||
claim for exactly this reason). Circulating power is not a property any one
|
||||
inverter has. It is a property of a PAIR, it is defined by a sum across the
|
||||
roster, and there is no fact of the matter about which of the two is the
|
||||
offender: a unit ignoring its share and a sibling faithfully executing a
|
||||
share computed to sum with it produce identical readings. So the quantity is
|
||||
site-level, the watchdog is site-level, and the remedy below is site-level.
|
||||
|
||||
Latches, for `SignWatchdog`'s reason: the trip removes its own evidence. The
|
||||
moment the site stops commanding, the loop it detected either persists (a
|
||||
wedged unit, now visible at the meter) or vanishes (the add-on was one of
|
||||
the two participants) - and in the second case a non-latching detector would
|
||||
de-assert and start the whole thing again, three minutes at a time, forever.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: CirculationConfig | None = None, *,
|
||||
tripped: bool = False, reason: str = ""):
|
||||
self.cfg = cfg or CirculationConfig()
|
||||
self.over_since: datetime | None = None
|
||||
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED - SAFETY-34, and it is the
|
||||
# reason this card exists in the shape it does. `persistent_notification`
|
||||
# is IN MEMORY: Home Assistant restarts and the card is gone, with the
|
||||
# fault still running. That is precisely how the 2026-09-02 outage went
|
||||
# unseen for 6 h 25 m while the detector had been correct for hours. The
|
||||
# latch lives in `/data/state.json` - the add-on's only durable volume -
|
||||
# and `main.py` RE-RAISES the notification from it on every start.
|
||||
self.tripped = bool(tripped)
|
||||
self.reason = reason if self.tripped else ""
|
||||
# The worst figure seen during the current episode, for the operator
|
||||
# sentence. Not persisted: it is colour on a reason string that IS.
|
||||
self.peak_w = 0.0
|
||||
|
||||
def evaluate(self, circ_w, now: datetime) -> bool:
|
||||
"""Advance the detector one cycle. Returns True while tripped.
|
||||
|
||||
`circ_w` this cycle's `circulating_w()` over MEASURED per-unit power,
|
||||
or None when a reading is missing. None CLEARS the window
|
||||
rather than accumulating it - see the module docstring, and
|
||||
`SignWatchdog.evaluate` on the identical decision for a stale
|
||||
meter. A frozen figure held against a moving one manufactures
|
||||
a fault in about a window's worth of cycles.
|
||||
`now` the caller's clock, so this is testable and replayable.
|
||||
"""
|
||||
if self.tripped:
|
||||
# Latched. Do not re-evaluate: the trip removed the evidence.
|
||||
return True
|
||||
|
||||
if circ_w is None or float(circ_w) <= self.cfg.min_w:
|
||||
self.over_since = None
|
||||
self.peak_w = 0.0
|
||||
return False
|
||||
|
||||
circ_w = float(circ_w)
|
||||
self.peak_w = max(self.peak_w, circ_w)
|
||||
if self.over_since is None:
|
||||
self.over_since = now
|
||||
return False
|
||||
|
||||
held = (now - self.over_since).total_seconds()
|
||||
if held < self.cfg.hold_s:
|
||||
return False
|
||||
|
||||
self.tripped = True
|
||||
self.reason = (
|
||||
f"CIRCULATING_POWER: {circ_w:.0f} W measured going round between "
|
||||
f"the inverters (peak {self.peak_w:.0f} W) for {held:.0f} s "
|
||||
f"continuously - the meter cannot see it, because it nets to zero "
|
||||
f"there (limits: over {self.cfg.min_w:.0f} W, held over "
|
||||
f"{self.cfg.hold_s:.0f} s)")
|
||||
return True
|
||||
|
||||
def held_s(self, now: datetime) -> float:
|
||||
"""How long the current opposition has stood, for the status page.
|
||||
|
||||
Takes the clock rather than reading one, for `SignWatchdog.held_s`'s
|
||||
reason: every observable on this object is a pure function of what it
|
||||
was handed, and an unclocked function is one nothing can test.
|
||||
"""
|
||||
if self.over_since is None:
|
||||
return 0.0
|
||||
return (now - self.over_since).total_seconds()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.over_since = None
|
||||
self.tripped = False
|
||||
self.reason = ""
|
||||
self.peak_w = 0.0
|
||||
@@ -38,6 +38,10 @@ from .arbiter import (
|
||||
Claim, Resolution, actuator_for, resolve,
|
||||
)
|
||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from .circulation import (
|
||||
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
|
||||
circulating_w, opposing,
|
||||
)
|
||||
from .clamp import (
|
||||
REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, is_commandable,
|
||||
)
|
||||
@@ -249,6 +253,39 @@ class Controller:
|
||||
self.freeze_wd = FreezeWatchdog(
|
||||
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
|
||||
|
||||
# SAFETY-31. ONE PER SITE, and restored from the store like SAFETY-02's
|
||||
# latch rather than defaulted like SAFETY-18's - the asymmetry is the
|
||||
# same one the freeze watchdog's own note draws, read the other way.
|
||||
# This one ACTS: a trip files a P_SAFETY SET that stops every inverter,
|
||||
# so a restart that cleared it would come back up straight into the loop
|
||||
# it had just stopped, which is SAFETY-02's measured three-minutes-at-a-
|
||||
# time failure with a different quantity in front of it.
|
||||
#
|
||||
# ⚠️ SITE-LEVEL KEYS, NOT NAMESPACED. store.py carries the argument:
|
||||
# circulating power is defined over the roster and is identically zero
|
||||
# for any one unit, so there is no inverter to hang the latch on.
|
||||
self.circ_wd = CirculationWatchdog(
|
||||
CirculationConfig(
|
||||
min_w=float(opts.get("circulation_w",
|
||||
CIRC_OPTION_DEFAULTS["circulation_w"])),
|
||||
hold_s=float(opts.get("circulation_hold_s",
|
||||
CIRC_OPTION_DEFAULTS["circulation_hold_s"]))),
|
||||
tripped=bool(store.data.get("circ_tripped", False)),
|
||||
reason=str(store.data.get("circ_reason", "") or ""))
|
||||
# SAFETY-34. Has the durable alarm been put back in front of the
|
||||
# operator since this process started? `persistent_notification` is in
|
||||
# memory and does NOT survive the Home Assistant restart that the store
|
||||
# does, so the latch alone is not the alarm - re-raising it is.
|
||||
# `run_control()` does that, once, before the first cycle.
|
||||
self._circ_announced = False
|
||||
if self.circ_wd.tripped:
|
||||
_LOG.error(
|
||||
"SAFETY-31 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 two "
|
||||
"inverters were pushing against each other.",
|
||||
self.circ_wd.reason or "(reason not recorded)")
|
||||
|
||||
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
|
||||
# itself is the cheap half.
|
||||
#
|
||||
@@ -720,6 +757,35 @@ class Controller:
|
||||
return None
|
||||
return float(sum(float(v) for v in vals))
|
||||
|
||||
@property
|
||||
def site_circulating_w(self):
|
||||
"""SAFETY-31. `sum(|B_i|) - |sum(B_i)|` over MEASURED per-unit power.
|
||||
|
||||
⚠️ `inv.batt`, NOT `self.allocation`. THIS IS THE WHOLE CARD, and the
|
||||
wrong version is the tempting one: the same formula over the allocation
|
||||
record would be simpler, would never be None, and would be worth
|
||||
nothing. ALLOC-01's sign-coherence gate (`allocator.REFUSE_SIGN`) already
|
||||
refuses an allocation whose shares carry two signs, so a record-derived
|
||||
sensor reads zero BY CONSTRUCTION, forever, and can only ever confirm
|
||||
that the allocator agrees with itself. Every fault this card is for is a
|
||||
divergence between what was commanded and what the hardware is doing - a
|
||||
unit ignoring or lagging its share, a stale share still being executed,
|
||||
or HW-01's power stage running its last instruction for 6 h 25 m with
|
||||
the comms dead and the allocation record impeccable throughout.
|
||||
Measured on the TEST-19 rig: with one unit wedged, the record stays
|
||||
`ok` with both shares at -1250 W - a record-derived sensor reads 0 W -
|
||||
while this one reads 2000 W.
|
||||
|
||||
`batt` became per-instance in DEV-02; before that there was one scalar
|
||||
on the Controller and this property could not have been written.
|
||||
|
||||
None when any unit has not answered, and NOT zero - circulation.py says
|
||||
why at length. The short version: zero would be this sensor reporting
|
||||
health from an absence, which is the 2026-09-02 failure itself.
|
||||
Identically 0.0 at the shipped roster of one (AC 5).
|
||||
"""
|
||||
return circulating_w([inv.batt for inv in self.inverters])
|
||||
|
||||
@property
|
||||
def pack_floor_soc(self):
|
||||
"""MINIMUM. The SoC that a discharge PERMISSION must be graded against.
|
||||
@@ -1204,6 +1270,39 @@ class Controller:
|
||||
# share of it. At the shipped roster of one the two are the same number.
|
||||
site_standing = float(sum(standing.values()))
|
||||
|
||||
# --- 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
|
||||
# `over_since` untouched across the gap and the window would resume from
|
||||
# a start time that predates it, which is the ordering bug the sign
|
||||
# watchdog's own note describes.
|
||||
#
|
||||
# ⚠️ THE INPUT IS MEASURED POWER, WHICH IS THE ENTIRE POINT OF THE CARD.
|
||||
# `site_circulating_w` reads each `Inverter.batt` - that unit's own
|
||||
# meter, holding register 0x0518. It is NOT `self.allocation`, and the
|
||||
# property's docstring carries the argument: ALLOC-01's sign-coherence
|
||||
# gate already makes the allocation record incapable of expressing
|
||||
# circulating power, so a record-derived sensor is zero by construction
|
||||
# and detects nothing. What this catches is circulation the allocator
|
||||
# NEVER COMMANDED - a unit ignoring or lagging its share, a stale share
|
||||
# still being executed, or HW-01's wedged power stage.
|
||||
circ_w = self.site_circulating_w
|
||||
was_circ = self.circ_wd.tripped
|
||||
if self.circ_wd.evaluate(circ_w, now) and not was_circ:
|
||||
# REASON FIRST, FLAG SECOND, and each `set` writes the file - the
|
||||
# same atomicity ordering SAFETY-02 uses two blocks up, and for the
|
||||
# same reason: a crash between the two must not persist a latch with
|
||||
# no explanation for the operator who has to decide about it.
|
||||
self.store.set("circ_reason", self.circ_wd.reason)
|
||||
self.store.set("circ_tripped", True)
|
||||
self._circ_announced = False
|
||||
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason)
|
||||
_LOG.error(
|
||||
"SAFETY-31 TRIPPED - %s. Commanding 0 W on every inverter and "
|
||||
"holding it until control is started again. This survives an "
|
||||
"add-on restart AND a Home Assistant restart; restarting will "
|
||||
"not clear it.", self.circ_wd.reason)
|
||||
|
||||
stale_after = float(self.o.get("stale_input_s", 15))
|
||||
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
|
||||
# does not become a fault; a LATCHED inverter must still reach the claim
|
||||
@@ -1265,6 +1364,58 @@ class Controller:
|
||||
"sign disagreement",
|
||||
actuator=self.actuator_of("failsafe", inv)))
|
||||
|
||||
# --- 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
|
||||
# and is answered rather than ignored.
|
||||
#
|
||||
# THE OBJECTION. HW-01 established that commanding a wedged inverter
|
||||
# achieves nothing - on 2026-09-02 the comms subsystem failed and the
|
||||
# power stage went on executing its last instruction for 6 h 25 m,
|
||||
# through every command this add-on sent it. If the cause of the
|
||||
# circulation is a unit that has stopped listening, then a command is
|
||||
# not a remedy and pretending otherwise is theatre.
|
||||
#
|
||||
# THE ANSWER, AND IT IS WHY THIS IS STILL AN ACTION RATHER THAN A
|
||||
# NOTIFICATION. A circulating loop needs TWO participants. In every
|
||||
# cause on this card's list, exactly one of them is a unit that has
|
||||
# stopped listening and THE OTHER ONE IS THIS CONTROLLER - faithfully
|
||||
# commanding the sibling to a share computed to sum with a number the
|
||||
# wedged unit is not actually producing. Stopping the commanding half
|
||||
# does two things a notification cannot:
|
||||
#
|
||||
# 1. IT HALVES THE LOOP. The unit that is still listening stops being
|
||||
# driven into the one that is not, so the round-tripping stops on
|
||||
# the side this controller owns. That is real energy and real
|
||||
# cycles off a real pack, and it needs no cooperation from the
|
||||
# wedged unit to take effect.
|
||||
# 2. IT MAKES THE RESIDUE VISIBLE. This is the more important half.
|
||||
# Circulating power hides because it NETS TO ZERO AT THE METER.
|
||||
# With this controller's contribution removed, whatever the wedged
|
||||
# unit is still doing stops being cancelled and appears at P1 as a
|
||||
# plain net flow - where SAFETY-02's sign watchdog (which gates on
|
||||
# `abs(grid_w) > 300` and could never open at a netted zero), the
|
||||
# peak guard and the capacity tariff can all see it. The fault
|
||||
# stops being invisible. Measured on the TEST-19 rig: the card's
|
||||
# own scenario reads +0 W at the meter with 6000 W going round, and
|
||||
# the shipped SignWatchdog's predicate is false on every one of
|
||||
# those cycles.
|
||||
#
|
||||
# WHAT IT DOES NOT DO, SAID PLAINLY: it does not stop the wedged
|
||||
# inverter. Nothing this add-on can send will. If the loop persists at
|
||||
# the meter after this trip, the remaining actuator is a human at the
|
||||
# isolator, which is what the notification says in those words.
|
||||
#
|
||||
# ⚠️ AND IT IS A SITE CLAIM. `arbiter.CLAIM_SCOPE` carries the argument:
|
||||
# circulating power is defined over the roster and is identically zero
|
||||
# for any one unit, so there is no attribution to file this per-unit on -
|
||||
# and parking the wrong one of the two would leave the loop running with
|
||||
# the guard satisfied.
|
||||
if self.circ_wd.tripped:
|
||||
claims.append(Claim.set("circulation", P_SAFETY, 0.0,
|
||||
"circulating power",
|
||||
actuator=self.actuator_of("circulation")))
|
||||
|
||||
# SITE, both of them, and the two reasons are why. "stopped" is the
|
||||
# operator switching control off for the whole site; "inputs missing" is
|
||||
# the SITE's own telemetry gone - the meter this controller regulates
|
||||
@@ -1794,6 +1945,16 @@ class Controller:
|
||||
# is not, and test_freeze.py part 5 fails on exactly that.
|
||||
await self.check_meter_frozen()
|
||||
|
||||
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
|
||||
# Unconstrained where the two above are not: it grades
|
||||
# `self.circ_wd.tripped`, which was advanced near the top of this method
|
||||
# beside SAFETY-02 and which nothing since has touched. It is called on
|
||||
# EVERY cycle rather than only on the cycle that trips, because its
|
||||
# second job is re-raising the alarm after a restart into an existing
|
||||
# trip - see its docstring on why that shares one code path with the
|
||||
# fresh trip instead of getting its own.
|
||||
await self.check_circulating()
|
||||
|
||||
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
|
||||
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
|
||||
# method assigned above and nothing since has touched.
|
||||
@@ -2050,6 +2211,71 @@ class Controller:
|
||||
f"you a lower bill, not the pack being spent."),
|
||||
})
|
||||
|
||||
async def check_circulating(self) -> None:
|
||||
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart.
|
||||
|
||||
⚠️ THE LATCH IS NOT THE ALARM. `circ_tripped` in `/data/state.json`
|
||||
survives everything, but nothing looks at a JSON file at 3 a.m. What the
|
||||
operator sees is a `persistent_notification`, and THAT IS IN MEMORY: a
|
||||
Home Assistant restart deletes it while the fault runs on. This is not a
|
||||
hypothetical - it is precisely how the 2026-09-02 outage went unseen for
|
||||
6 h 25 m with the detector correct the whole time (SAFETY-34).
|
||||
|
||||
So the durable half and the visible half are joined HERE, and the join
|
||||
is `_circ_announced`: a per-PROCESS flag, deliberately not persisted.
|
||||
The latch says "this site is tripped"; the flag says "this process has
|
||||
put that in front of a human". A restart clears the flag and keeps the
|
||||
latch, so the notification is RE-RAISED on the first cycle after every
|
||||
add-on start - which is also the first cycle after a Home Assistant
|
||||
restart, because the add-on is restarted with it.
|
||||
|
||||
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE. A fresh trip and a restart into
|
||||
an existing trip raise the alarm through these same lines. Two paths
|
||||
would be two chances for the restart one to rot unnoticed, and the
|
||||
restart one is the case nobody exercises by hand.
|
||||
"""
|
||||
if not self.circ_wd.tripped or self._circ_announced:
|
||||
return
|
||||
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
|
||||
# every cycle for a broker that is down would be an alarm storm on the
|
||||
# event bus on top of a site that is already stopped. The latch is
|
||||
# already durable and the log line above already fired; this surface is
|
||||
# best-effort by design.
|
||||
self._circ_announced = True
|
||||
names = self.units
|
||||
pos, neg = opposing([inv.batt for inv in self.inverters],
|
||||
min_w=self.circ_wd.cfg.min_w / 2.0)
|
||||
who = ""
|
||||
if pos and neg:
|
||||
who = ("\n\nRight now " + ", ".join(names[i] for i in pos)
|
||||
+ " is discharging while " + ", ".join(names[i] for i in neg)
|
||||
+ " charges.")
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# this add-on raises - check_meter_frozen() gives the reasoning. A
|
||||
# circulating loop is not a frozen meter, is not an out-of-range
|
||||
# setpoint and is not a saturated peak guard.
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_circulating_power",
|
||||
"title": "GoodWe: the inverters are pushing against each other",
|
||||
"message": (
|
||||
f"{self.circ_wd.reason}{who}\n\nTHE METER CANNOT SEE THIS. "
|
||||
f"Circulating power nets to zero at P1, so the site reads idle "
|
||||
f"while the packs are cycled through two conversion stages "
|
||||
f"continuously. Every other guard in this add-on is a consumer "
|
||||
f"of that net figure, which is why none of them fired.\n\n"
|
||||
f"CONTROL HAS BEEN STOPPED - every inverter is commanded 0 W "
|
||||
f"and held there. That removes this controller as one of the "
|
||||
f"two participants, and it makes anything still happening "
|
||||
f"VISIBLE at the meter instead of cancelled at it.\n\n"
|
||||
f"⚠️ IF THE GRID READING DOES NOT MOVE AFTER THIS, one inverter "
|
||||
f"is no longer listening to this add-on and no command will "
|
||||
f"reach it - that has happened twice on this hardware, once for "
|
||||
f"6 h 25 m. The remaining actuator is a human at the isolator.\n"
|
||||
f"\nThis alarm survives a Home Assistant restart. Restarting "
|
||||
f"will not clear it; start control from the add-on UI once you "
|
||||
f"know why two inverters were opposing."),
|
||||
})
|
||||
|
||||
async def check_meter_frozen(self) -> None:
|
||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||
|
||||
@@ -2354,7 +2580,14 @@ class Controller:
|
||||
# additive to its vocabulary rather than a change to it.
|
||||
"status": ("failsafe" if any(i.sign_wd.tripped
|
||||
for i in self.inverters)
|
||||
or self.circ_wd.tripped
|
||||
else "running" if self.auto else "stopped"),
|
||||
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
|
||||
# `site_circulating_w`. `publish()` drops None values, so a cycle
|
||||
# with a missing per-unit reading leaves the last figure standing
|
||||
# rather than publishing a zero that would read as health.
|
||||
"circulation": (None if self.site_circulating_w is None
|
||||
else round(self.site_circulating_w)),
|
||||
}
|
||||
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
|
||||
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
|
||||
@@ -2691,6 +2924,35 @@ class Controller:
|
||||
f"{inv.sign_wd.cfg.min_w:.0f} W)"
|
||||
+ (f" - opposed for {held:.0f} s now" if held 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
|
||||
# here would invite the operator to clear "the other one".
|
||||
_circ = self.site_circulating_w
|
||||
if self.circ_wd.tripped:
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": "CIRCULATING POWER - " + self.circ_wd.reason
|
||||
+ ". EVERY inverter is held at 0 W. The meter "
|
||||
"cannot see this fault, which is why nothing "
|
||||
"else reported it. Starting control clears "
|
||||
"this; restarting the add-on does NOT, and "
|
||||
"neither does restarting Home Assistant."})
|
||||
elif len(self.inverters) <= 1:
|
||||
# AC 5. At the shipped roster the quantity is identically zero for
|
||||
# every value of every reading, so "armed" would be a claim to be
|
||||
# watching something that cannot happen. Say what is true instead.
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": "circulating power: n/a at one inverter - "
|
||||
"sum(|Bi|) - |sum(Bi)| is identically zero"})
|
||||
else:
|
||||
held = self.circ_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": "circulation watchdog: armed (trips after "
|
||||
f"{self.circ_wd.cfg.hold_s:.0f} s over "
|
||||
f"{self.circ_wd.cfg.min_w:.0f} W)"
|
||||
+ (f" - now {_circ:.0f} W" if _circ else "")
|
||||
+ (f", opposed for {held:.0f} s" 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 "
|
||||
@@ -2708,7 +2970,17 @@ 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 any(i.sign_wd.tripped for i in self.inverters):
|
||||
if self.circ_wd.tripped:
|
||||
# ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are
|
||||
# latched P_SAFETY stops, so the site is at 0 W either way and the
|
||||
# banner can only carry one sentence. This one goes first because it
|
||||
# is the fault the operator has NO OTHER WAY of learning about: a
|
||||
# sign disagreement is visible at the meter and on every dashboard
|
||||
# in the house, and circulating power is visible nowhere at all.
|
||||
level, banner = "bad", ("CIRCULATING POWER — the inverters were "
|
||||
"pushing against each other. Site 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.")
|
||||
@@ -2734,6 +3006,14 @@ class Controller:
|
||||
rows = [
|
||||
("Grid", w(self.grid)),
|
||||
("Battery", w(self.site_batt_w)),
|
||||
# SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is not
|
||||
# derivable from. "Grid 0 W, Battery 0 W, Circulating 6000 W" is the
|
||||
# whole card in three rows: both of the numbers an operator normally
|
||||
# reads say the site is idle, and the third says 6 kW is going round
|
||||
# between the packs. Only shown at N>=2 - at one inverter it is
|
||||
# identically zero and a permanent "0 W" row would be noise.
|
||||
*(() if len(self.inverters) <= 1
|
||||
else ((("Circulating", w(self.site_circulating_w)),))),
|
||||
("State of charge", "—" if self.pack_floor_soc is None
|
||||
else f"{self.pack_floor_soc:g} %"),
|
||||
("Commanded", w(self.target)),
|
||||
@@ -2808,6 +3088,22 @@ class Controller:
|
||||
# unit in particular.
|
||||
self.store.set("sign_tripped", False)
|
||||
self.store.set("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
|
||||
# oppose. Only a human who has read the banner can say the site
|
||||
# is clear.
|
||||
#
|
||||
# ⚠️ AND `_circ_announced` GOES WITH IT. It is the "has a human
|
||||
# been shown this" flag; leaving it True through a clear would
|
||||
# mean the NEXT trip in this process latched, stopped the site,
|
||||
# and raised no notification at all.
|
||||
if self.circ_wd.tripped:
|
||||
self.circ_wd.reset()
|
||||
self.store.set("circ_tripped", False)
|
||||
self.store.set("circ_reason", "")
|
||||
self.log_event("circulating-power trip cleared by operator")
|
||||
self._circ_announced = False
|
||||
if not self.auto:
|
||||
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero().
|
||||
# Zeroing `self.target` alone would leave each unit's last share
|
||||
|
||||
@@ -43,6 +43,19 @@ SENSORS = [
|
||||
("grid", "goodwe_grid_power", "Grid power", "W", "power", "measurement", None),
|
||||
("battery", "goodwe_battery_power", "Battery power", "W", "power", "measurement", None),
|
||||
("soc", "goodwe_battery_soc", "Battery SoC", "%", "battery", "measurement", None),
|
||||
# SAFETY-31. `sum(|Bi|) - |sum(Bi)|` over MEASURED per-inverter power: the
|
||||
# power going round between the inverters, which the grid sensor above
|
||||
# CANNOT SHOW because it nets to zero there. The two are published side by
|
||||
# side on purpose - "grid 0 W, circulating 6000 W" is the whole phenomenon
|
||||
# in two rows of a dashboard, and no arithmetic on the grid figure alone can
|
||||
# ever produce the second one.
|
||||
#
|
||||
# ⚠️ Published as 0 at the shipped one-inverter roster, not omitted, and
|
||||
# that is a deliberate difference from `p1_age` below. An absent entity is
|
||||
# right for a watchdog input nothing is feeding; this is a quantity whose
|
||||
# value is KNOWN at N=1 - identically zero, for every reading - and a
|
||||
# dashboard row reading 0 W is the honest report of it.
|
||||
("circulation", "goodwe_circulating_power", "Circulating power", "W", "power", "measurement", "mdi:sync-alert"),
|
||||
("phase", "goodwe_maintenance_phase", "Maintenance phase", None, None, None, "mdi:battery-sync"),
|
||||
("status", "goodwe_controller_status", "Controller status", None, None, None, "mdi:heart-pulse"),
|
||||
# ⚠️ THE LIVE ENTITY IS `sensor.goodwe_p1_sample_age`, NOT what this
|
||||
|
||||
@@ -54,6 +54,23 @@ _LOG = logging.getLogger("goodwe.store")
|
||||
# `migrate_unit_keys()` and `Controller._grade_store_migration()` carry the
|
||||
# argument; the short version is that a latch is only worth persisting if it
|
||||
# survives the version change an operator makes while staring at the banner.
|
||||
#
|
||||
# ⚠️ AND SAFETY-31's PAIR IS FLAT ON PURPOSE, WHICH IS THE OPPOSITE DECISION TO
|
||||
# THE ONE ABOVE AND NEEDS ITS OWN SENTENCE. `circ_tripped` / `circ_reason` are
|
||||
# NOT in `LEGACY_UNIT_KEYS` and must never be moved into a unit namespace.
|
||||
# Circulating power is `sum(|B_i|) - |sum(B_i)|` - a quantity DEFINED over the
|
||||
# whole roster, which is identically zero for any single unit. There is no fact
|
||||
# of the matter about which of two opposing inverters is the offender: a unit
|
||||
# ignoring its share and a sibling faithfully executing a share computed to sum
|
||||
# with it read identically. Namespacing this latch would mean inventing an
|
||||
# attribution the measurement cannot support, and would then let an operator
|
||||
# clear "inverter B's" half of a site-wide loop. One site, one latch.
|
||||
#
|
||||
# It is persisted for SAFETY-34's reason, which this add-on has already paid
|
||||
# for once: `persistent_notification` is IN MEMORY. Home Assistant restarts and
|
||||
# the card is gone while the fault runs on - exactly how the 2026-09-02 outage
|
||||
# went unseen for 6 h 25 m with the detector correct throughout. main.py
|
||||
# re-raises the notification from this key on every start.
|
||||
DEFAULTS = {
|
||||
"phase": "idle",
|
||||
"phase_started": None,
|
||||
@@ -61,6 +78,8 @@ DEFAULTS = {
|
||||
"last_start_attempt": None,
|
||||
"sign_tripped": False,
|
||||
"sign_reason": "",
|
||||
"circ_tripped": False,
|
||||
"circ_reason": "",
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user