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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user