diff --git a/goodwe_controller/app/ctl_alarms.py b/goodwe_controller/app/ctl_alarms.py new file mode 100644 index 0000000..d7eabc0 --- /dev/null +++ b/goodwe_controller/app/ctl_alarms.py @@ -0,0 +1,461 @@ +"""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()) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 6918c2e..524a3a2 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -103,6 +103,10 @@ from .watchdog import ( ) from . import setuponly from . import web +from .ctl_alarms import ( + AlarmsMixin, HANDBACK_CYCLES, REANNOUNCE_S, ROLLING_BOUND_CYCLES, + ROLLING_S, SHORT_CYCLES, SHORT_SPAN_S, +) from .ctl_cycle import CycleMixin, _refused_interval from .ctl_trade import ( TradeMixin, CHARGE_LATCH_GRACE_S, CHARGE_LATCH_KEY, EDGE_HOLD_S, @@ -132,11 +136,6 @@ __all__ = ["UNIT_A", "Controller", "amain", "main"] HAND_BACK_WORST_S = RELEASE_BUDGET_S + FALLBACK_BUDGET_S -# 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 - - def tariff_refresh_wanted(opts: dict) -> bool: """Does this install need the background tariff refresh? (TARIFF-10, TARIFF-24) Yes with a price feed, trading armed, or battery modes on. It @@ -173,20 +172,7 @@ def _load_latch(data, now=None): return until, levels, born -# 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 Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin): +class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin): def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop, peak_db_path: str = "/data/tariff.db", price_history_path: str = "/data/price_history.json", @@ -1175,418 +1161,6 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin): self._why_words = text self._why_waiting_since = None - 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()) - # -- tasks --------------------------------------------------------------- # -- OPT-04: the background tariff refresh ------------------------------- def fresh_tariff_plan(self, now):