Files
goodwe-addon/goodwe_controller/app/ctl_alarms.py
T
glenn schrooyenandClaude d3504fbbe7 MAINT-21: app/ctl_alarms.py - Controller methods that raise and clear alarms
Moves the methods named in the card and the constants only they read out of app/main.py unchanged. AlarmsMixin: the check_* alarms and the loop-alarm helpers. tools/maint21_same_ast.py: OK.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:29:56 +01:00

462 lines
24 KiB
Python

"""Controller methods that raise and clear the alarms.
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
import logging
from datetime import datetime, timedelta, timezone
from . import allocator
from . import control as _ctl
from .circulation import opposing
from .clamp import (
REASON_CODE as CLAMP_REASON, delivery_short, shortfall_note,
write_clamp_note,
)
from . import messages
from .mqtt import unit_slugs
from . import peakguard
from .price import brussels
from .price_chain import fixed_tariff_beside_service
from .ctl_writes import REFUSED_SPAN_S
from .ctl_inputs import _mono
_LOG = logging.getLogger("goodwe")
# SAFETY-34: a Core-only restart wipes persistent_notification without
# restarting this add-on, so a latched alarm is re-asserted this often.
REANNOUNCE_S = 300.0
# SAFETY-85: a battery another owner set is graded against the least it was
# told over the plant's settle time, so a battery still settling is not short.
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
# SAFETY-119/121: after a hand-back the loop's bound is hidden for
# HANDBACK_CYCLES / SHORT_SPAN_S, and across hand-backs on at most
# ROLLING_BOUND_CYCLES cycles per ROLLING_S
# (DECISIONS/SAFETY-121-bound-every-quarter.md). Without a monotonic clock the
# budget never refills.
HANDBACK_CYCLES = 4
ROLLING_S = 900.0
ROLLING_BOUND_CYCLES = 11
class AlarmsMixin:
def _loop_alarm_input(self, resolution) -> None:
"""SAFETY-85. The loop's clamp counts only on a cycle the loop decided
(a rating may cut it). A battery another owner set to discharge counts
when it gave less than it was told for `saturation_cycles` cycles in a
row. DECISIONS/SAFETY-85-no-false-not-keeping-up.md"""
d = self._loop_decision
if d is None:
self._short_run = 0
self._asked_hist = {}
self._wire_lost, self._handback = False, None
return
unmet = self.allocation.unmet_w if self.allocation is not None else 0
cut = (resolution.bound_by or "").split("(")[0]
owns = (resolution.winner == "loop" and not d.held and not unmet
and (resolution.target_w == d.target_w
or cut in ("device", "supervised")))
# Graded against the least that stood on the wire (0 W in shadow
# mode) over SHORT_CYCLES cycles and SHORT_SPAN_S seconds.
now, asked = self._now_mono, {}
for inv in self.inverters:
h = self._asked_hist.setdefault(inv.name, [])
h.append((now, inv.standing_w()))
while len(h) > SHORT_CYCLES and (
now is None or h[0][0] is None
or now - h[0][0] > SHORT_SPAN_S):
h.pop(0)
asked[inv.name] = min(w for _t, w in h)
others = [inv for inv in self.inverters if not owns
or allocator.pin_w(resolution.unit_bounds.get(inv.name))
is not None]
short = [(inv, asked[inv.name], inv.batt) for inv in others
if inv.batt is not None
and delivery_short(asked[inv.name], inv.batt,
self.tuning.saturation_w)]
self._short_run = self._short_run + 1 if short else 0
settling = self._settling(owns, now)
if owns and self.loop_clamped[0] and not (
settling and not self.loop_clamped[2]):
return
engaged = self._short_run >= self.tuning.saturation_cycles
named = len(self.inverters) > 1
self.loop_clamped = (engaged, shortfall_note(
[(inv.name if named else None, a, g) for inv, a, g in short])
if engaged else "", False)
self.loop_clamp_facts = ()
def _settling(self, owns, now) -> bool:
"""SAFETY-119: does the loop's bound on this cycle count? Not within a
settle (HANDBACK_CYCLES cycles and SHORT_SPAN_S) after another owner
handed the wire back; across all hand-backs the bound is hidden on at
most ROLLING_BOUND_CYCLES cycles in any ROLLING_S.
DECISIONS/SAFETY-121-bound-every-quarter.md"""
def aged(t0, span):
return now is None or t0 is None or now - t0 >= span
if not owns:
self._wire_lost, self._handback = True, None
return False
if self._wire_lost:
self._wire_lost, self._handback = False, (now, 0)
if self._handback is None:
return False
tc, n = self._handback
n += 1
self._handback = (tc, n)
if aged(tc, SHORT_SPAN_S) and n >= HANDBACK_CYCLES:
self._handback = None
return False
if self.loop_clamped[0] and not self.loop_clamped[2]:
kept = tuple(t for t in self._hidden if now is None or (
t is not None and now - t < ROLLING_S))
if len(kept) >= ROLLING_BOUND_CYCLES:
self._hidden = kept
return False
self._hidden = kept + (now,)
return True
async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle. Runs
AFTER write_setpoint() (the clamp is arithmetic done inside the write).
⚠️ PUBLISHING IS NOT IN THE WRITE PATH: a stalled supervisor must never
delay or drop a frame. ⚠️ THIS IS NOT A TRIP: no claim, no latch, 0 W
is not commanded (§11.2: clamp and alarm); turning it into one is a
deliberate change. (DECISIONS/MAIN-orchestration-archive.md)"""
# ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT: `clamp_last` is on
# the Inverter, `ClampWatchdog` is one per controller (an anti-flood
# counter), so any unit that clamped is an engagement and the reason
# names it. (DECISIONS/MAIN-orchestration-archive.md)
clamped_units = [inv for inv in self.inverters
if inv.clamp_last is not None and inv.clamp_last.engaged]
engaged_write = bool(clamped_units)
engaged_loop, loop_note, loop_bug = self.loop_clamped
engaged = engaged_write or engaged_loop
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
bug = engaged_write or loop_bug
if engaged_write:
_named = len(self.inverters) > 1
reason = write_clamp_note([(inv.name if _named else None,
inv.clamp_last)
for inv in clamped_units])
facts = [messages.Clamp(inv.name if _named else None,
inv.clamp_last.requested_w,
inv.clamp_last.applied_w,
inv.clamp_last.bound)
for inv in clamped_units]
else:
reason = loop_note if engaged else ""
facts = self.loop_clamp_facts if engaged else ()
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc),
bug=bug, facts=facts)
# THE ACTION LOG IS COALESCED PER EPISODE: consecutive engagements
# update one row's count. ⚠️ NOT COSMETIC: the log keeps 40 rows and a
# winding loop clamps every cycle, so a row each flushed SAFETY-18's
# "METER FROZEN?" row. Here as well as in write_setpoint() because on a
# default install the actuator-side clamp is unreachable.
# (DECISIONS/MAIN-orchestration-archive.md)
if engaged and not engaged_write:
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
row = (f"{tag}: "
f"{messages.count(self.clamp_wd.engagements, 'engagement')}"
f" - {reason}")
if self.events and tag in self.events[0]:
stamp = self.events[0].split(" ", 1)[0]
self.events[0] = f"{stamp} {row}"
else:
self.log_event(row, hint=messages.hint(
"power_too_high" if bug else "not_keeping_up"))
if not fired:
return
if not self.clamp_wd.bug:
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
# it can never replace a BUG SIGNAL notice still on screen.
_LOG.warning("The battery gives less than asked: %s. Held back %s "
"so far.", messages.reason_code(self.clamp_wd.reason),
messages.times(self.clamp_wd.engagements))
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
_LOG.debug("A short hold after a large load or solar step is "
"normal. If it lasts, check that the battery follows "
"its commands.")
await self.hass.call("persistent_notification", "create",
messages.loop_bound().data())
return
_LOG.error(
"Power request too high, a software fault: %s. Cut back %s so "
"far, control runs on.", messages.reason_code(self.clamp_wd.reason),
messages.times(self.clamp_wd.engagements))
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
_LOG.debug(
"SAFETY-03: silently clipping this is how a winding integrator "
"reached 14 768 W on this project before. The clamp makes every "
"dashboard read a legal number while the thing producing it goes "
"on climbing. This is an alarm, not a trip.")
# A fixed notification_id, for the reason check_meter_frozen() gives -
# and a DIFFERENT one from every other alarm this add-on raises,
# because a clamp is not a frozen meter and neither is a saturation
# freeze. (DECISIONS/MAIN-orchestration-archive.md)
await self.hass.call("persistent_notification", "create",
messages.output_clamp(self.clamp_wd.facts,
shadow=self.shadow).data())
async def check_peak_saturated(self) -> None:
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter. ⚠️ An
engagement is not an alarm: a peak floor above the rating is a house
load bigger than the inverter, not a defect (unlike SAFETY-03's winding
integrator). SAFETY-81: `g.saturated` must agree with
`peakguard.load_too_high()` for `ALARM_HOLD_S`. Edge-triggered: one
notification per episode, at most once per quarter.
(DECISIONS/MAIN-orchestration-archive.md)"""
g = self.peak_guard
batt, prev = self.site_batt_w, self._peak_batt_prev
if batt is None:
# Unknown, not "fine": the hold below neither grows nor restarts.
return
self._peak_batt_prev = batt
if prev is not None:
batt = min(float(batt), float(prev))
p = self._peak_proj
# OPT-15 x SAFETY-81: until the new quarter's first sample the old
# projection read "no seconds left" (not high), which restarted the
# hold; a rolled one (battery modes on) is not graded then, so a
# persistent overload is still notified once per quarter.
high = bool(
g is not None and g.saturated and p is not None
and p.block_start is not None and not self._peak_rolled
and peakguard.load_too_high(
p, batt_w=batt, ceiling_w=g.ceiling_w,
achievable_w=self.site_rating_w))
# SAFETY-81: and for ALARM_HOLD_S, on the projection's clock.
saturated = False
if high:
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
if self._peak_high_since is None:
self._peak_high_since = t
saturated = ((t - self._peak_high_since).total_seconds()
>= peakguard.ALARM_HOLD_S)
else:
self._peak_high_since = None
was, self._peak_saturated = self._peak_saturated, saturated
if not saturated or was:
return
_LOG.error(
"Switch off large loads, the battery alone cannot keep this "
"15-minute period under the peak limit: %s.",
messages.reason_code(g.reason))
_LOG.debug("Evidence: %s.", g.reason)
# SAFETY-81: notified once per quarter; a later episode is logged only.
if p.block_start == self._peak_alarm_block:
return
self._peak_alarm_block = p.block_start
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
hint=messages.hint("peak_saturated"))
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
# is not a frozen meter and is not an out-of-range setpoint.
await self.hass.call("persistent_notification", "create",
messages.peak_saturated(
g.ceiling_w, shadow=self.shadow).data())
async def check_peak_floor_limited(self) -> None:
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving. ⚠️
FAILING TO SHAVE MUST NOT BE SILENT (the capacity tariff bills the
month's worst quarter). Not check_peak_saturated(): that is shed-load,
this is nothing discharged; never both. ⚠️ SAFETY-25: the message says
the floor binds this guard only; the loop has no SoC floor and goes on
discharging. Edge-triggered. (DECISIONS/MAIN-orchestration-archive.md)"""
g = self.peak_guard
limited = bool(g is not None and g.floor_limited)
if limited and (g.floor_w or 0.0) > 0.0:
limited = self._reserve_short(g)
else:
self._reserve_batt_prev = self._reserve_high_since = None
# SAFETY-126: nothing can give, so it alarms at once, but only
# when the quarter really misses the aim (`_peak_landing()`).
limited = limited and not g.within_aim
was, self._peak_floor_limited = self._peak_floor_limited, limited
if not limited or was:
return
# No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and
# a second copy is the kind of stutter that makes a 40-line action log
# read like boilerplate. (DECISIONS/MAIN-orchestration-archive.md)
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
_LOG.error(
"Switch off large loads, the peak guard may not draw the battery "
"lower: %s.", messages.reason_code(g.reason))
_LOG.debug("Evidence: %s.", g.reason)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. ⚠️ SAFETY-25's
# sentence (the reserve binds the peak guard only) is in
# messages.peak_reserve() on every branch, unreadable SoC included.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.hass.call("persistent_notification", "create",
messages.peak_reserve(
g.ceiling_w, soc_unread=g.soc is None,
floor_unknown=(g.soc is not None
and g.soc_floor is None),
packs=(g.held if len(self.inverters) > 1
else ()),
others=(g.floor_w or 0.0) > 0.0).data())
def _reserve_short(self, g) -> bool:
"""SAFETY-87: the batteries that can still give, at full power, miss
the limit (`load_too_high()` on `g.able_w`), for ALARM_HOLD_S, as
SAFETY-81 grades 'Load too high'."""
p, batt = self._peak_proj, self.site_batt_w
if batt is None:
return self._peak_floor_limited
prev, self._reserve_batt_prev = self._reserve_batt_prev, batt
if prev is not None:
batt = min(float(batt), float(prev))
if not (p is not None and p.block_start is not None
and peakguard.load_too_high(p, batt_w=batt,
ceiling_w=g.ceiling_w,
achievable_w=g.able_w)):
self._reserve_high_since = None
return False
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
if self._reserve_high_since is None:
self._reserve_high_since = t
return ((t - self._reserve_high_since).total_seconds()
>= peakguard.ALARM_HOLD_S)
async def check_circulating(self) -> None:
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
THE LATCH IS NOT THE ALARM: the notification is in memory, so
`_circ_announced` (per-PROCESS, not persisted) makes a restart re-raise
it, and REANNOUNCE_S re-asserts it after a Core-only restart. ⚠️ ONE
CODE PATH for a fresh trip and a restart into one.
(DECISIONS/MAIN-orchestration-archive.md)"""
if not self.circ_wd.tripped:
return
if self._circ_announced and not self._reannounce_due("goodwe_circulating_power"):
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.
# (DECISIONS/MAIN-orchestration-archive.md)
self._circ_announced = True
self._announced_mono["goodwe_circulating_power"] = _mono()
names = self.units
pos, neg = opposing([inv.batt for inv in self.inverters],
min_w=self.circ_wd.cfg.min_w / 2.0)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# this add-on raises - check_meter_frozen() gives the reasoning.
await self.hass.call("persistent_notification", "create",
messages.circulating(
[names[i] for i in pos] if pos and neg else (),
[names[i] for i in neg] if pos and neg else (),
).data())
def _reannounce_due(self, notification_id: str) -> bool:
"""SAFETY-34: has this latched alarm gone REANNOUNCE_S without being re-asserted?"""
t = self._announced_mono.get(notification_id)
return t is None or _mono() - t >= REANNOUNCE_S
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: the notification is in memory, so
`_delivery_announced` (a per-PROCESS set of unit names, not persisted)
makes a restart re-raise it, and REANNOUNCE_S re-asserts it. ⚠️ ONE
CODE PATH for a fresh trip and a restart into one. ⚠️ ONE ID PER UNIT
(`unit_slugs()`): two wedged inverters give two cards.
(DECISIONS/MAIN-orchestration-archive.md)"""
names = self.units
slugs = unit_slugs(names)
for inv, slug in zip(self.inverters, slugs):
nid = f"goodwe_not_delivering_{slug}"
if not inv.delivery_wd.tripped:
continue
if inv.name in self._delivery_announced and not self._reannounce_due(nid):
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.
# (DECISIONS/MAIN-orchestration-archive.md)
self._delivery_announced.add(inv.name)
self._announced_mono[nid] = _mono()
await self.hass.call("persistent_notification", "create",
messages.not_delivering(inv.name, slug).data())
async def check_price_gap(self) -> None:
"""PRICE-01. One notification when no price covers now, gone when one
does. (DECISIONS/MAIN-orchestration-archive.md)"""
if self.price_gap and not self._price_gap_announced:
self._price_gap_announced = True
await self.hass.call("persistent_notification", "create",
messages.no_prices(*self._gap_args()).data())
elif not self.price_gap and self._price_gap_announced:
self._price_gap_announced = False
await self.hass.call("persistent_notification", "dismiss",
messages.prices_back().data())
async def check_fixed_note(self) -> None:
"""PRICE-01 G1. First cycle only: one INFO line when `static_profile`
sits beside `price_service` (the plan's last fallback, not a fault, so
no notification). (DECISIONS/MAIN-orchestration-archive.md)"""
if self._fixed_note_said:
return
self._fixed_note_said = True
if fixed_tariff_beside_service(self.o):
_LOG.info(messages.FIXED_TARIFF_LOG)
try:
await self.hass.call("persistent_notification", "dismiss",
messages.fixed_tariff_card_gone().data())
except Exception: # noqa: BLE001 - housekeeping never stops a cycle
_LOG.debug("Could not take down the old fixed-tariff card.")
def _gap_args(self) -> tuple:
"""`(since, now)` in Brussels time for the gap's words. `since` is None
when no price was ever published."""
now = brussels(datetime.now(timezone.utc))
if self.price_gap_since is None:
return None, now
return brussels(self.price_gap_since), now
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
Notification only: no claim, no latch, no 0 W (freeze.py carries the
reasoning). getattr, not an attribute access: `unchanged_s` exists only
on `homewizard_local`, and `p1_source` is None until amain() builds the
transport. (DECISIONS/MAIN-orchestration-archive.md)"""
unchanged = getattr(self.p1_source, "unchanged_s", None)
# SITE, all three inputs: one meter, the SITE target the arbiter
# resolved, and the rail the SITE could stand at - which is the summed
# per-unit conjunction, so a small inverter B lowers the site rail rather
# than being graded against inverter A's.
fired = self.freeze_wd.evaluate(unchanged, self.target,
self.site_max_w,
datetime.now(timezone.utc),
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max"))
if not fired:
return
self.log_event("METER FROZEN? " + self.freeze_wd.reason,
hint=messages.hint("meter_frozen"))
_LOG.error(
"The meter shows the same value while the battery is asked for "
"full power, and control still follows it, so check the meter: "
"%s.", messages.reason_code(self.freeze_wd.reason))
_LOG.debug("Evidence: %s.", self.freeze_wd.reason)
_LOG.debug(
"SAFETY-18: nothing else stops this. The meter still answers, so "
"its age is fresh, the firmware watchdog is fed and the arbiter "
"only bounds the magnitude. Kept up for a quarter of an hour, it "
"sets the month's billed capacity peak.")
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
# rather than stacking. An operator who has to dismiss eleven identical
# notifications learns to dismiss the twelfth without reading it.
await self.hass.call("persistent_notification", "create",
messages.meter_frozen(unchanged).data())