SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist
checks / unit (pull_request) Successful in 5m54s
checks / unit (pull_request) Successful in 5m54s
A unit standing at a non-zero share and producing nothing is seen by NOTHING. SAFETY-30's per-unit sign gate needs OPPOSITION on both sides of 250 W and a unit at 0 W is not opposing, it is ABSENT. The site sum still agrees with the meter in direction. Circulating power is exactly zero when one unit does nothing. The only trace left is `reason == "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an operator learns to ignore. It is HW-01's shape, which ran 6 h 25 m with all three layers spectators. ⚠️ THE CARD'S OWN DISCRIMINATOR WAS BUILT FIRST AND FALSIFIED, AND THAT IS THE MAIN CONTENT. SAFETY-42 asks for "saturation persisting past N while |grid error| > X, both derived". Run on the two-master rig it fails in BOTH directions, and no (N, X) fixes either: FALSE POSITIVE `plantsim.Plant.limit()` returns 0 W for a discharge command into a pack at its own floor, and a wedged unit produces 0 W too. Load 2000 W, equal weights, 2000 W per-unit cap, 120 cycles: THE TWO ARMS ARE IDENTICAL ON ALL 120 in grid, site target, every share and every measured watt. Both saturated-freeze on 116 of them, both hold |grid error| over 300 W on all 120 and settle at 1200 W, both read circulating 0.0 W. The predicate's inputs do not differ, so no threshold on them can differ. And "a cold pack at its floor must not alarm" is the card's own AC 2. FALSE NEGATIVE the standing grid error is what a HEALTHY SIBLING ERASES. Load 800 W, same config, B wedged: over the last 30 of 200 cycles the loop is frozen on 30 of 30 while the worst |grid error| is 30 W, with B standing at +770 W and delivering 0 W. Any X above 30 W is silent for the whole run on the exact fault the card describes. WHAT REPLACES IT. Per unit, for a discharge share, `share - measured`; readable because of a reduction that only exists at N>=2: a unit is named while it is short by more than `saturation_w` with NO ACCOUNT for it, and the units in that state are a PROPER SUBSET of the units asked to discharge. THE ACCOUNT keeps the cold pack out and is the only thing that can: the two cases are identical in the power domain, so the discriminator has to be a non-power fact, and there is exactly one - the pack's own SoC against its own `bms_soc_floor`, asked through `peakguard.discharge_permitted()` rather than transcribed. ⚠️ `bms_soc_floor` ALONE, not `pack_floor_pct()`'s conjunction with `peak_soc_floor`: that floor bounds a money-saving discharge only and using it would explain away a wedged unit anywhere between the two. THE PROPER SUBSET keeps a whole-site cause out - a derate this add-on cannot see hits every unit; one box that stopped listening does not - and it is what makes this INERT AT N=1, by algebra rather than by a roster guard. IT REPORTS. IT DOES NOT ACT, and the strongest argument is for acting. Dropping the wedged unit from `live` genuinely recovers a site the shipped path does not: at load 1500 W, kept in the split the site settles at 800 W with a 700 W error and NEVER comes inside 100 W in 150 cycles (the freeze pins the target, so the sibling's SHARE never grows); dropped, it delivers 1500 W from cycle 14. What refuses it is that the only re-allocation this add-on can express is all-or-nothing: a unit derated to 50 % is short by more than saturation_w with nothing to explain it, so this detector names it too, and at load 3000 W dropping it leaves the site delivering 2000 W against 2400 W kept - 400 W WORSE. A partial re-allocation is what would make acting safe and the allocator cannot express it. That is a card. control.py is NOT MODIFIED. The freeze shapes the loop; this reports on it. NUMBERS, MEASURED. The watts side is `Tuning.saturation_w`, read at runtime, so there is no fourth CFG-03 copy and it cannot drift from the number the control law itself calls "not following". `delivery_hold_s: 185` comes off 2640 closed-loop two-inverter runs in which nothing is faulty (6 gains x 5 slews over the whole schema range, across load reversals, a flapping load and ASYMMETRIC SPLITS - a family SAFETY-30's campaign did not have and the one that dominates here) plus 1890 runs at a roster of ONE as the control: threshold reversals flapping weighted N=1 control * 500 W 20.0 s 0.0 s 30.0 s 0.0 s The N=1 column is 0.0 s at EVERY threshold in all 1890 runs, and the flapping column - the family that defeated SAFETY-30's site window - is zero too, because a common stimulus makes both units short at once and the subset stops being proper. ⚠️ The curve is NOT monotone (55.0 s at 250 W against 40.0 s at 100 W): lowering the threshold can turn a proper subset into the whole roster and BREAK the run. ⚠️ THE MARGIN IS MEASURED, NOT FELT, BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST. `plantsim.INVERTER_B` is A COPIED, `measured=False`, so every two-inverter figure above is two copies of A. Sweeping a different second unit: the worst legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and scales with its LAG - 45.0 s at twice A's measured 10 s, 90.0 s at four times it. 185 s is 6.2x the measured worst, 2.06x the four-times-slower case, 6.2x LINK_STALE_S and 4.9x inside the 900 s quarter. Deliberately not 180, which is SAFETY-02's site hold. SAFETY-34. `unit/<name>/delivery_tripped` in the store, one notification id per unit, re-raised on the first cycle of every process, cleared by the Start press. No flat mirror: a rollback lands on a version that drives one actuator, where this check is inert. ⚠️ SCHEMA CHANGE - `delivery_hold_s` joins options: and schema:, so this needs a version bump before it ships. Additive and defaulted, so an existing options blob stays valid. STATED GAPS: charge shares are not graded (no configured charge ceiling exists, so a constant-voltage taper cannot be explained and arming it would fire on every maintenance charge); every unit wedged at once is not caught (the subset is not proper and no instrument here separates that from a plant that cannot). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
co-authored by
Claude Opus 5
parent
c8dcb38a95
commit
ccc4bf100c
@@ -38,6 +38,10 @@ from .arbiter import (
|
||||
Claim, Resolution, actuator_for, resolve,
|
||||
)
|
||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from .delivery import (
|
||||
DELIVERY_OPTION_DEFAULTS, DeliveryConfig, UnitDelivery,
|
||||
account as delivery_account, delinquent,
|
||||
)
|
||||
from .circulation import (
|
||||
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
|
||||
circulating_w, opposing,
|
||||
@@ -216,8 +220,17 @@ class Controller:
|
||||
hold_s=float(opts.get("site_sign_disagree_s",
|
||||
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])),
|
||||
)
|
||||
# SAFETY-42. THE WINDOW A UNIT MAY BE SHORT OF ITS SHARE BEFORE IT IS
|
||||
# NAMED. There is no second key: the WATTS half of that check is
|
||||
# `Tuning.saturation_w`, read at runtime rather than copied - see
|
||||
# `delivery.MIN_W_IS_SATURATION_W`, and CFG-03 on why a fourth copy of a
|
||||
# default is worth avoiding.
|
||||
self.delivery_cfg = delivery_cfg = DeliveryConfig(
|
||||
hold_s=float(opts.get("delivery_hold_s",
|
||||
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
|
||||
)
|
||||
self.store_migration = migrate_unit_keys(store, UNIT_A)
|
||||
self.inverters = build_roster(opts, store, sign_cfg)
|
||||
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
|
||||
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
|
||||
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
|
||||
# of `soc_entity`, `batt_entity`, `batt_invert` or `max_w`, and an
|
||||
@@ -365,6 +378,32 @@ class Controller:
|
||||
"opposed the meter.", inv.name,
|
||||
inv.sign_wd.reason or "(reason not recorded)")
|
||||
|
||||
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
|
||||
# ⚠️ A LATCH THAT DOES NOT ACT, WHICH IS THE ONE ASYMMETRY WITH EVERY
|
||||
# OTHER RESTORED LATCH ON THIS OBJECT, AND IT IS DELIBERATE. SAFETY-02's
|
||||
# and SAFETY-31's latches survive a restart because they command 0 W and
|
||||
# an add-on that came back armed would drive straight into the fault
|
||||
# again. This one commands nothing at all: it is restored so that the
|
||||
# ALARM survives, which is the whole of SAFETY-34 and is exactly how the
|
||||
# 2026-09-02 outage went unseen for 6 h 25 m with the detector correct
|
||||
# the entire time. `delivery.py` carries the argument for reporting
|
||||
# rather than acting, with the rig measurements on both sides of it.
|
||||
#
|
||||
# `_delivery_announced` is the per-PROCESS half - see `_circ_announced`,
|
||||
# which is the same pattern for the same reason. The store says "this
|
||||
# unit is tripped"; the flag says "this process has put that in front of
|
||||
# a human". A restart clears the flag and keeps the latch.
|
||||
self._delivery_announced = set()
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
_LOG.error(
|
||||
"SAFETY-42 was TRIPPED on %s before this restart and stays "
|
||||
"tripped: %s. NOTHING IS BEING COMMANDED DIFFERENTLY - this "
|
||||
"guard reports and does not act - so if that inverter is "
|
||||
"still not delivering, the site is still short by its "
|
||||
"share. Restarting does not clear this.",
|
||||
inv.name, inv.delivery_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
|
||||
@@ -1738,6 +1777,79 @@ class Controller:
|
||||
"add-on restart AND a Home Assistant restart; restarting will "
|
||||
"not clear it.", self.circ_wd.reason)
|
||||
|
||||
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON
|
||||
# Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but
|
||||
# not yet stale" hold below. A run of held cycles would otherwise leave
|
||||
# each unit's `since` untouched across the gap and its window would
|
||||
# resume from a start time that predates it - the ordering bug the sign
|
||||
# watchdog's own note describes, in a third detector.
|
||||
#
|
||||
# ⚠️ THE OPERANDS ARE `standing` AND `inv.batt`, THE SAME PAIR SAFETY-30
|
||||
# GRADES, AND THE CONCLUSION IS A DIFFERENT ONE. SAFETY-30 asks whether
|
||||
# they OPPOSE; this asks whether the second one ARRIVED. A unit sitting
|
||||
# at 0 W against a +2000 W share opposes nothing - it is not pushing the
|
||||
# wrong way, it is absent - so the sign gate never opens, the site sum
|
||||
# still agrees with the meter in direction, circulating power is
|
||||
# identically zero, and the only trace left is `reason ==
|
||||
# "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an
|
||||
# operator learns to ignore. That is this card.
|
||||
#
|
||||
# ⚠️ AND `delinquent()` IS CALLED ONCE, OVER THE WHOLE ROSTER, BECAUSE
|
||||
# THE CHECK IS A REDUCTION ACROSS UNITS AND NOT A PER-UNIT PREDICATE.
|
||||
# What separates a wedged box from a legitimately saturated site is that
|
||||
# the site's shortfall is a PROPER SUBSET of the units asked to deliver.
|
||||
# Called per unit it would be a shortfall test, which is the saturation
|
||||
# signal again. delivery.py carries the measurement: a pack at its own
|
||||
# cut-out and a wedged unit produce IDENTICAL traces at the unit and at
|
||||
# the meter, so nothing computed from one unit alone can tell them
|
||||
# apart. At a roster of one the reduction is empty for every input.
|
||||
for inv in self.inverters:
|
||||
inv.delivery_wd.arm(self.delivery_cfg)
|
||||
_delinquent = delinquent(
|
||||
[UnitDelivery(
|
||||
inv.name, standing[inv.name], inv.batt,
|
||||
delivery_account(
|
||||
# ⚠️ `is_live()` AT ITS OWN DEFAULT WINDOW, which is
|
||||
# `LINK_STALE_S` - the SAME call `live_units()` makes for
|
||||
# the allocator. It has to be: what this branch means is
|
||||
# "the allocator has already dropped this unit and said so",
|
||||
# and a different window here would be this check answering
|
||||
# a question about a roster the allocator did not build.
|
||||
live=inv.is_live(now)[0],
|
||||
parked=inv.sign_wd.tripped,
|
||||
soc=inv.soc, bms_floor=inv.bms_soc_floor))
|
||||
for inv in self.inverters],
|
||||
self.tuning.saturation_w)
|
||||
for inv in self.inverters:
|
||||
was_short = inv.delivery_wd.tripped
|
||||
if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \
|
||||
and not was_short:
|
||||
# REASON FIRST, FLAG SECOND, and each `set` writes the file -
|
||||
# the same atomicity ordering the three trips above use: a crash
|
||||
# between the two must not persist a latch with no explanation
|
||||
# for the operator who has to decide about clearing it.
|
||||
#
|
||||
# ⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02
|
||||
# HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback
|
||||
# still finds its latch", and the version being rolled back TO
|
||||
# drives exactly ONE actuator - a roster at which this check is
|
||||
# inert by construction. A flat mirror would hand a downgraded
|
||||
# add-on a latch taken on evidence about a roster it cannot have.
|
||||
inv.store_set("delivery_reason", inv.delivery_wd.reason)
|
||||
inv.store_set("delivery_tripped", True)
|
||||
self._delivery_announced.discard(inv.name)
|
||||
self.log_event(f"NOT DELIVERING {inv.name}: "
|
||||
+ inv.delivery_wd.reason)
|
||||
_LOG.error(
|
||||
"SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED "
|
||||
"DIFFERENTLY: this guard reports and does not act, because "
|
||||
"commanding a unit that is not listening achieves nothing "
|
||||
"(HW-01) and the only re-allocation this add-on can express "
|
||||
"would throw away a partially-delivering unit's remaining "
|
||||
"output. The site is short by that unit's share until "
|
||||
"somebody looks at it. This survives an add-on AND a Home "
|
||||
"Assistant restart.", inv.name, inv.delivery_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
|
||||
@@ -2458,6 +2570,15 @@ class Controller:
|
||||
# fresh trip instead of getting its own.
|
||||
await self.check_circulating()
|
||||
|
||||
# --- SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's
|
||||
# It grades `inv.delivery_wd.tripped`, which was advanced near the top
|
||||
# of this method beside SAFETY-02 and SAFETY-31 and which nothing since
|
||||
# has touched. 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_not_delivering()
|
||||
|
||||
# --- 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.
|
||||
@@ -2795,6 +2916,85 @@ class Controller:
|
||||
f"know why two inverters were opposing."),
|
||||
})
|
||||
|
||||
async def check_not_delivering(self) -> None:
|
||||
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
|
||||
|
||||
⚠️ THE LATCH IS NOT THE ALARM, and this guard has nothing BUT the alarm.
|
||||
`unit/<name>/delivery_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. That is precisely how the
|
||||
2026-09-02 outage went unseen for 6 h 25 m with the detector correct the
|
||||
whole time (SAFETY-34), and it matters more here than anywhere else in
|
||||
this file, because SAFETY-02 and SAFETY-31 at least leave a stopped site
|
||||
for somebody to notice. This one leaves a site that looks like it is
|
||||
working and is quietly delivering one unit less than it has.
|
||||
|
||||
So the durable half and the visible half are joined HERE, and the join
|
||||
is `_delivery_announced`: a per-PROCESS set of unit names, deliberately
|
||||
not persisted. The latch says "this unit is tripped"; membership says
|
||||
"this process has put that in front of a human". A restart empties the
|
||||
set 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 - `check_circulating()`'s
|
||||
argument. 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.
|
||||
|
||||
⚠️ AND ONE NOTIFICATION ID PER UNIT. Every other alarm in this file has
|
||||
a single fixed id because its subject is the site; this one's subject is
|
||||
a named box, and two wedged inverters must produce two cards rather than
|
||||
one replacing the other. The id is built from the same `unit_slugs()`
|
||||
TEL-07 announces the MQTT entities under, so the card and the entity an
|
||||
operator correlates it with carry the same token.
|
||||
"""
|
||||
names = self.units
|
||||
slugs = unit_slugs(names)
|
||||
for inv, slug in zip(self.inverters, slugs):
|
||||
if not inv.delivery_wd.tripped or inv.name in self._delivery_announced:
|
||||
continue
|
||||
# Added BEFORE the await, for `check_circulating()`'s reason:
|
||||
# `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. The
|
||||
# latch is already durable and the log line already fired; this
|
||||
# surface is best-effort by design.
|
||||
self._delivery_announced.add(inv.name)
|
||||
peers = ", ".join(n for n in names if n != inv.name) or "no sibling"
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": f"goodwe_not_delivering_{slug}",
|
||||
"title": f"GoodWe: {inv.name} is not delivering its share",
|
||||
"message": (
|
||||
f"{inv.delivery_wd.reason}\n\nWHY NOTHING ELSE SAID SO. A "
|
||||
f"unit sitting at 0 W against a non-zero share is not "
|
||||
f"pushing the wrong way, it is ABSENT: the per-unit sign "
|
||||
f"check needs opposition on both sides of "
|
||||
f"{self.unit_sign_cfg.min_w:.0f} W and never opens, the "
|
||||
f"site sum still agrees with the meter in "
|
||||
f"direction, and circulating power is exactly zero when one "
|
||||
f"unit does nothing. The only other trace is a saturation "
|
||||
f"freeze, which this add-on's own code calls an alarm "
|
||||
f"operators learn to ignore.\n\nNOTHING HAS BEEN COMMANDED "
|
||||
f"DIFFERENTLY. This guard reports and does not act. "
|
||||
f"Commanding an inverter that has stopped listening "
|
||||
f"achieves nothing - that has happened on this hardware, "
|
||||
f"once for 6 h 25 m - and the only re-allocation this "
|
||||
f"add-on can express is all-or-nothing, which would throw "
|
||||
f"away the output of a unit that is delivering PART of its "
|
||||
f"share. So {peers} is still regulating and the site is "
|
||||
f"short by up to {inv.rating_w():.0f} W until somebody "
|
||||
f"looks at {inv.name}.\n\nWHAT TO CHECK, in order: is "
|
||||
f"{inv.name} answering on RS485 at all; is its pack colder "
|
||||
f"or emptier than its configured bms_soc_floor says (if it "
|
||||
f"is, that setting does not describe this battery and this "
|
||||
f"alarm is telling you so); and is anything else writing to "
|
||||
f"it.\n\nThis alarm survives a Home Assistant restart. "
|
||||
f"Restarting will not clear it; start control from the "
|
||||
f"add-on UI once you know why."),
|
||||
})
|
||||
|
||||
async def check_meter_frozen(self) -> None:
|
||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||
|
||||
@@ -3557,6 +3757,49 @@ class Controller:
|
||||
+ (f" - now {_circ:.0f} W" if _circ else "")
|
||||
+ (f", opposed for {held:.0f} s" if held else "")})
|
||||
|
||||
# --- SAFETY-42 -------------------------------------------------------
|
||||
# ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE ONE PLACE THE
|
||||
# "reports, does not act" DECISION IS VISIBLE TO AN OPERATOR. Every
|
||||
# other latched row in this list means the site is at 0 W and a red
|
||||
# cross is the truth. This one means the site is STILL RUNNING and is
|
||||
# quietly short by one unit's share; rendering it as a hard failure
|
||||
# would say the controller had stopped, which it has not. It is not "ok"
|
||||
# either - `ok: False` with `warn: True` is this list's caution, and a
|
||||
# caution is what a fault nobody is acting on actually is.
|
||||
if len(self.inverters) > 1:
|
||||
for inv in self.inverters:
|
||||
tag = f" ({inv.name})"
|
||||
if inv.delivery_wd.tripped:
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": f"NOT DELIVERING{tag} - "
|
||||
+ inv.delivery_wd.reason
|
||||
+ ". Nothing has been commanded differently and "
|
||||
"the rest of the roster is still regulating, "
|
||||
"so the site is short by this unit's share. "
|
||||
"Starting control clears this row; restarting "
|
||||
"the add-on does NOT, and neither does "
|
||||
"restarting Home Assistant."})
|
||||
else:
|
||||
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": f"delivery watchdog{tag}: armed (reports after "
|
||||
f"{inv.delivery_wd.cfg.hold_s:.0f} s short of "
|
||||
f"its share by over "
|
||||
f"{self.tuning.saturation_w:.0f} W, with a "
|
||||
f"sibling that is not)"
|
||||
+ (f" - short for {_dheld:.0f} s now"
|
||||
if _dheld else "")})
|
||||
else:
|
||||
# AC 5, and the same honesty `circulating power: n/a` uses one block
|
||||
# up. At one inverter the reduction this check is has no proper
|
||||
# subset to find, for EVERY input, so "armed" would be a claim to be
|
||||
# watching something that cannot happen.
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": "delivery watchdog: n/a at one inverter - a unit "
|
||||
"short of its share is indistinguishable from a "
|
||||
"plant that cannot deliver until there is a "
|
||||
"sibling to compare it with"})
|
||||
|
||||
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 "
|
||||
@@ -3616,6 +3859,26 @@ class Controller:
|
||||
level, banner = "bad", f"NOT READY — {bad[0].get('banner') or bad[0]['text']}"
|
||||
elif not self.auto:
|
||||
level, banner = "warn", "Stopped — inverter commanded to 0 W"
|
||||
elif any(i.delivery_wd.tripped for i in self.inverters):
|
||||
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
||||
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
|
||||
# Every `bad` banner above means the site is at 0 W; this one means
|
||||
# the site is STILL RUNNING and quietly short by one unit's share,
|
||||
# and a red headline would say the controller had stopped.
|
||||
#
|
||||
# ⚠️ BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT
|
||||
# IS NOT COSMETIC. That sentence is a claim the site cannot make
|
||||
# while a unit is absent - the loop is saturated and the meter is
|
||||
# short - and "the page reads healthy while the fault runs" is
|
||||
# precisely the shape this whole card exists to remove. It sits
|
||||
# BELOW "Stopped" because a stopped site is the more urgent fact and
|
||||
# the banner carries one sentence.
|
||||
_nd = ", ".join(i.name for i in self.inverters
|
||||
if i.delivery_wd.tripped)
|
||||
level, banner = "warn", (
|
||||
f"NOT DELIVERING — {_nd} is standing at a share it is not "
|
||||
f"producing. The site is still running and short by it; "
|
||||
f"nothing has been commanded differently.")
|
||||
elif self.maint.phase != IDLE:
|
||||
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
|
||||
# of one this is the string it has always been, character for
|
||||
@@ -3766,6 +4029,27 @@ class Controller:
|
||||
self.store.set("circ_reason", "")
|
||||
self.log_event("circulating-power trip cleared by operator")
|
||||
self._circ_announced = False
|
||||
# SAFETY-42, cleared by the SAME press, and for a reason that is
|
||||
# NOT the one above. The other three latches are cleared here
|
||||
# because their trip removed its own evidence - a site commanding
|
||||
# 0 W cannot oppose anything - so only a human can say the fault
|
||||
# is gone. This one commands nothing, so its evidence is still
|
||||
# standing and it could in principle clear itself. It does not:
|
||||
# a unit that stopped delivering for minutes and then resumed is
|
||||
# an INTERMITTENT fault, and a detector that erased its own
|
||||
# record of that would be the least useful thing in this file.
|
||||
# ⚠️ AND `_delivery_announced` GOES WITH IT, like
|
||||
# `_circ_announced`: leaving a name in it through a clear would
|
||||
# mean the NEXT trip on that unit latched and raised no
|
||||
# notification at all.
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
self.log_event(f"not-delivering report on {inv.name} "
|
||||
f"cleared by operator")
|
||||
inv.delivery_wd.reset()
|
||||
inv.store_set("delivery_tripped", False)
|
||||
inv.store_set("delivery_reason", "")
|
||||
self._delivery_announced = set()
|
||||
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