"""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())