diff --git a/goodwe_controller/DECISIONS/MAIN-orchestration.md b/goodwe_controller/DECISIONS/MAIN-orchestration.md index d1167d0..33b716f 100644 --- a/goodwe_controller/DECISIONS/MAIN-orchestration.md +++ b/goodwe_controller/DECISIONS/MAIN-orchestration.md @@ -4,7 +4,8 @@ docstrings of `app/main.py`, so the module can keep one line and a pointer. The comments that were shortened but not rewritten here are kept word for word in [[MAIN-orchestration-archive]], by their line in `app/main.py` at release/1.0 -`d4c01da`. Related: [[SAFETY-07-peak-guard]], [[FCST-04-the-day-that-is-carried]], +`d4c01da`. Since MAINT-21 the Controller's methods are split by job into +`app/main.py` and `app/ctl_*.py`; a comment keeps its text and moves with its code. Related: [[SAFETY-07-peak-guard]], [[FCST-04-the-day-that-is-carried]], [[SAFETY-30-per-unit-sign]], [[SAFETY-73-refusal-per-inverter]], [[SAFETY-121-bound-every-quarter]], [[TARIFF-10-fetching-is-not-trading]], [[TARIFF-24-modes-need-the-tariff-task]], [[OPT-34-the-night-charge-keeps-its-level]]. diff --git a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md index d9a5dce..9e4553e 100644 --- a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md +++ b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md @@ -709,7 +709,7 @@ rather than smuggled in. **Also still not in this card, and named by the shipped code already:** the charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling` -takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's +takes one number. The `Controller.pack_floor_soc` docstring in `app/main.py` says so, and it belongs to CFG-04/SPIKE-04's per-pack bound. Nothing here touched it. ## 6. What was changed, and one thing that was found on the way 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/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py new file mode 100644 index 0000000..c242179 --- /dev/null +++ b/goodwe_controller/app/ctl_cycle.py @@ -0,0 +1,1112 @@ +"""Controller.cycle(): one control cycle, whole. + +Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" + +import asyncio +import logging +import math +from datetime import datetime, timedelta, timezone + +from . import arbitrage +from .arbiter import ( + Claim, P_LOOP, P_MAINTENANCE, P_MAINT_SHAPE, P_MODE, P_SAFETY, Resolution, + resolve, +) +from . import packmode +from . import control as _ctl +from .control import compute, maintenance_charge_floor +from .delivery import UnitDelivery, account as delivery_account, delinquent +from .clamp import loop_clamp_note +from . import messages +from .freeze import looks_frozen +from .maintenance import ( + FAIL_BACKOFF_DAYS, IDLE, MaintResult, arbitrate as maint_arbitrate, +) +from .mqtt import unit_slugs +from . import peakguard +from . import packshare +from . import shadow +from .price import Uncertainty +from .tariff import next_quarter +from .watchdog import loop_is_following +from .ctl_trade import pack_energy_of +from .rows import why_key, why_raised + +_LOG = logging.getLogger("goodwe") + + +def _refused_interval(sign: int) -> tuple: + """SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way.""" + return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf) + + +class CycleMixin: + async def cycle(self) -> None: + """Gather claims, resolve precedence, write the winner. Nothing here + decides who wins (arbiter.py does, by one rule); this method only + states what each strategy wants. + (DECISIONS/MAIN-orchestration-archive.md)""" + now = datetime.now(timezone.utc) + # BEFORE the first `return` of this method on purpose (TARIFF-11): it + # stamps that a cycle RAN, a different fact from the decisions it may + # reach. + self.cycle_at = now + + # Each pack on ITS OWN reading, not "the SoC": 12's recalibration + # drains and refills ONE pack and grades ONE coulomb counter; a minimum + # would abort on a sibling's state and an average would declare a pack + # full that is not. `arbitrate()` runs first, once, over all of them. + _off = ({inv.name for inv in self.inverters + if not self.pack_mode(inv).use} if self.modes_on else set()) + for inv, machine in zip(self.inverters, self.maints): + if inv.name in _off: + self._maint_stop_off(machine, now) + _on = [m for inv, m in zip(self.inverters, self.maints) + if inv.name not in _off] + _plan_on = iter(maint_arbitrate(_on, now) if _off + else maint_arbitrate(self.maints, now)) + maint_plan = [None if inv.name in _off else next(_plan_on) + for inv, _m in zip(self.inverters, self.maints)] + maint_results = [] + for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan): + if blocked is None: + maint_results.append(MaintResult(IDLE, False, None, False, [])) + continue + # MAINT-13: the charge timeout counts only the minutes the pack was + # allowed to charge (see `_charge_gate_open`). + res = machine.tick(now, inv.soc, blocked_by=blocked, + gate_open=self._charge_gate_open(inv)) + for msg in res.events: + self.log_event(msg, hint=messages.hint( + "maint_failed" if res.failed else "maintenance", + phase=machine.phase)) + if res.failed: + # Said once: only the tick that failed carries `res.failed`. + multi = len(self.inverters) > 1 + slug = (unit_slugs(self.units)[self.inverters.index(inv)] + if multi else "battery") + await self.hass.call( + "persistent_notification", "create", + messages.maint_failed( + res.failed, FAIL_BACKOFF_DAYS, + unit=inv.name if multi else "", slug=slug).data()) + maint_results.append(res) + # Carried to the status page, not left in the 40-line action log: the + # deferral is edge-logged, and a pack that is due, idle and WAITING + # must be able to say so when looked at (12's failure mode is a + # schedule that silently stops firing). + self._maint_deferred = [(m.name, r.deferred) + for m, r in zip(self.maints, maint_results) + if r.deferred] + + # SAFETY-02, and it runs BEFORE the early return below: evaluated every + # cycle, else a run of held cycles leaves `disagree_since` untouched + # and the timer resumes from a start time that predates the gap. One + # watchdog per inverter on that inverter's own reading; what it is + # compared against is decided by the roster (SAFETY-30). + self._arm_sign_watchdogs() + n_units = len(self.inverters) + standing = {} + for inv in self.inverters: + was_tripped = inv.sign_wd.tripped + # `Inverter.standing_w()`, not the expression it replaces: + # SAFETY-37's freshness gate arms off the same method. + stood = inv.standing_w() + standing[inv.name] = stood + against = self.grid if n_units <= 1 else inv.batt + if inv.sign_wd.evaluate(stood, against, self.following, now) \ + and not was_tripped: + # REASON first, FLAG second (each `set` writes the file): a + # crash between the two persists a reason nothing reads, not a + # latch with no explanation. Both keys are namespaced to this + # unit. + inv.store_set("sign_reason", inv.sign_wd.reason) + inv.store_set("sign_tripped", True) + # The legacy flat keys are MIRRORED, the other half of store.py + # rule 2: that rule only covered the latch existing at + # migration time, so a trip after it wrote nothing flat and a + # rollback would find no latch. + self.store.set("sign_reason", inv.sign_wd.reason) + self.store.set("sign_tripped", True) + self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason, + hint=messages.hint("direction", unit=inv.name)) + _LOG.error("%s is set to 0 W until control is started, " + "because its power went the wrong way: %s.", + inv.name, messages.reason_code(inv.sign_wd.reason)) + _LOG.debug("Evidence for %s: %s.", inv.name, + inv.sign_wd.reason) + # The site's own standing command is the SUM of the units': SAFETY-07 + # and SAFETY-18 grade a SITE quantity (the quarter average at the grid + # connection; a meter not moving while the site pushes near its rail). + # Same number at a roster of one. + site_standing = float(sum(standing.values())) + + # SAFETY-30: the site half of the sign check: the TOTAL standing on the + # wire against the meter, catching a fault no per-unit check sees + # (every unit faithfully executing the wrong total). Only at N>=2 (at + # one inverter it would duplicate `inverters[0].sign_wd`; AC 8). Flat + # keys, not namespaced. + if n_units > 1: + was_site = self.site_sign_wd.tripped + if self.site_sign_wd.evaluate(site_standing, self.grid, + self.following, now) and not was_site: + # REASON first, FLAG second, as the per-unit trip and + # SAFETY-31. The keys are flat, not namespaced, and not the + # per-unit pair (store.py). + self.store.set("site_sign_reason", self.site_sign_wd.reason) + self.store.set("site_sign_tripped", True) + self.log_event("FAILSAFE site: " + self.site_sign_wd.reason, + hint=messages.hint("site_direction")) + _LOG.error("Every inverter is set to 0 W until control is " + "started, because the total power went the wrong " + "way: %s.", + messages.reason_code(self.site_sign_wd.reason)) + _LOG.debug("Evidence for the site: %s.", + self.site_sign_wd.reason) + + # SAFETY-31, beside SAFETY-02 for the same reason: evaluated EVERY + # cycle, unconditionally, above the input hold (else `over_since` + # resumes from a start that predates the gap). The input is MEASURED + # power, never commanded: a unit ignoring or lagging its share, a stale + # share still executing. + circ_w = self.site_circulating_w + was_circ = self.circ_wd.tripped + if self.circ_wd.evaluate(circ_w, now) and not was_circ: + # REASON first, FLAG second (each `set` writes the file): a crash + # between must not persist a latch with no explanation. + self.store.set("circ_reason", self.circ_wd.reason) + self.store.set("circ_tripped", True) + self._circ_announced = False + self.log_event("CIRCULATING POWER - " + self.circ_wd.reason, + hint=messages.hint("circulating")) + _LOG.error( + "Every inverter is set to 0 W until control is started, " + "because the inverters worked against each other: %s.", + messages.reason_code(self.circ_wd.reason)) + _LOG.debug("Evidence for the site: %s.", self.circ_wd.reason) + + # SAFETY-42, for SAFETY-02's and SAFETY-31's reason: evaluated every + # cycle above the input hold. The operands are `standing` and + # `inv.batt`, the pair SAFETY-30 uses. `delinquent()` is called once + # over the whole roster, and every number is the configured one, never + # a literal that could drift (`self.delivery_cfg`, not + # `DeliveryConfig()`: part 4b grades a hold that is not the shipped 185 + # s). + for inv in self.inverters: + # `self.delivery_cfg`, not `DeliveryConfig()`: the window is + # `delivery_hold_s`, and part 4b grades a hold that is not the + # shipped 185 s. + 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. (DECISIONS/MAIN-orchestration-archive.md) + live=inv.is_live(now)[0], + # SAFETY-73: a refusing unit is asked 0 W on purpose, and + # one asked under PROBE_W that gives under 100 W is a + # refusal too small to call (its sibling covers the meter). + parked=inv.sign_wd.tripped or bool(inv.refused) or ( + n_units > 1 and inv.batt is not None + and abs(float(standing[inv.name])) <= _ctl.PROBE_W + and abs(float(inv.batt) - inv.idle_w) + < _ctl.REFUSED_BELOW_W), + # ⚠️ THIS UNIT'S OWN CUT-OUT (`bms_floor`): not the shipped + # 10 % and not the site's `peak_soc_floor`, which bounds a + # money-saving discharge only. + # (DECISIONS/MAIN-orchestration-archive.md) + 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 (as + # the three trips above): a crash between must not persist a + # latch with no explanation. ⚠️ NO FLAT MIRROR OF THIS PAIR, + # unlike SAFETY-02: store.py's rule 2 keeps flat keys so a + # rollback finds its latch, but the version rolled back TO + # drives one actuator, where this check is inert; a mirror + # would hand it a latch about a roster it cannot have. + # DECISIONS/MAIN-orchestration.md. + 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, + hint=messages.hint("not_delivering", + unit=inv.name)) + _LOG.error( + "%s is not delivering while control keeps running, and " + "only stopping and starting control clears this: %s.", + inv.name, messages.reason_code(inv.delivery_wd.reason)) + _LOG.debug("Evidence for %s: %s.", inv.name, + inv.delivery_wd.reason) + _LOG.debug( + "SAFETY-42 reports and does not act: commanding a unit " + "that is not listening achieves nothing (HW-01), and the " + "only re-allocation the add-on can express would throw " + "away a partly delivering unit's output.") + + stale_after = float(self.o.get("stale_input_s", 15)) + late_s = self._readings_late(site_standing) # SAFETY-128 + self._late_zero = late_s is not None and late_s >= stale_after + # ⚠️ `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 list below, because its `failsafe` SET is what parks it. + # SAFETY-30's site latch takes the roster past the hold too (its + # `sign_site` SET parks every inverter): the trip would stop acting the + # moment a poll was missed. (DECISIONS/MAIN-orchestration-archive.md) + if not self.inputs_ok and not any(i.sign_wd.tripped + for i in self.inverters) \ + and not (n_units > 1 and self.site_sign_wd.tripped): + bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0 + if bad_for < stale_after: + # A single missed poll is not a fault. Hold, keep the heartbeat + # going, and give it a few seconds to come back. + self.reason = f"inputs missing {bad_for:.0f}s" + # UX-10: the age is rendered when the page asks, not frozen at + # this cycle's. (DECISIONS/MAIN-orchestration-archive.md) + self.why_words = messages.why_waiting(bad_for) + self._why_waiting_since = now - timedelta(seconds=bad_for) + return + if late_s is not None and late_s < stale_after and self.inputs_ok \ + and (self.auto or self.shadow) \ + and not any(i.sign_wd.tripped for i in self.inverters) \ + and not (n_units > 1 and self.site_sign_wd.tripped): + self.reason = f"readings late {late_s:.0f}s" # SAFETY-128: hold + self.why_words = messages.why_late() + return + + claims = [] + + # safety limits: these bind EVERY strategy, always. Defence in depth: + # write_setpoint() clamps to the device range too, and the duplication + # is deliberate. ⚠️ PER-INVERTER, ONE PAIR PER UNIT from THAT unit's + # own min/max and supervised cap: there is no site rating + # (arbiter.CLAIM_SCOPE). (DECISIONS/MAIN-orchestration-archive.md) + for inv in self.inverters: + if inv.dev_min is not None and inv.dev_max is not None: + claims.append(Claim.limit("device", P_SAFETY, inv.dev_min, inv.dev_max, "rating", + actuator=self.actuator_of("device", inv))) + # `max_w` is the supervised cap on what may be WRITTEN to one + # setpoint entity (§11.3), not a bound on the grid connection. + claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w", + actuator=self.actuator_of("supervised", inv))) + # OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS. ⚠️ AN ENVELOPE, + # NOT A WEIGHT: a zero weight does not stop `allocator._fill()` + # commanding a pack (at or under its BMS cut-out `hi = 0`, at 100 % + # `lo = 0`). ⚠️ NEVER FILED AT N=1 (it would bound the site target: + # SAFETY-25 Option A), so there nothing here stops the loop + # discharging into the BMS cut-out; the BMS does. + # (DECISIONS/MAIN-orchestration-archive.md) + if n_units > 1: + _pe = packshare.envelope(pack_energy_of(inv)) + if _pe.binding: + claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi, + _pe.why, + actuator=self.actuator_of("pack", inv))) + # The SITE bound, for the site-level consumers below. It is the roster + # sum: what this site can deliver, not what one of its inverters can. + max_w = self.site_max_w + + # safety stops. ⚠️ SAFETY-02 is claimed FIRST and unconditionally while + # latched, a SET at P_SAFETY, and re-filed EVERY cycle (claims are not + # sticky). ⚠️ AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE: + # at N>=2 the evidence cannot be pinned on a sibling this controller + # never commanded (arbiter.CLAIM_SCOPE). + # (DECISIONS/MAIN-orchestration-archive.md) + for inv in self.inverters: + if inv.sign_wd.tripped: + claims.append(Claim.set("failsafe", P_SAFETY, 0.0, + "sign disagreement", + actuator=self.actuator_of("failsafe", inv))) + + # SAFETY-30: THE SITE SIGN TRIP, A SITE SET: the SUM of every standing + # command against the meter, which no single inverter owns; parking one + # unit on it would leave the rest pushing the same way. ⚠️ The `n_units + # > 1` guard is not redundant: the latch is restored from the store, + # and an install reconfigured back to one inverter must come up + # driving. (DECISIONS/MAIN-orchestration-archive.md) + if n_units > 1 and self.site_sign_wd.tripped: + claims.append(Claim.set("sign_site", P_SAFETY, 0.0, + "site sign disagreement", + actuator=self.actuator_of("sign_site"))) + + # SAFETY-31: ⚠️ A SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. A + # circulating loop needs two participants and one is this controller: + # stopping it halves the loop and makes the residue visible at P1. It + # does NOT stop a wedged inverter (HW-01); the remaining actuator is a + # human at the isolator. A site claim: circulating power is zero for + # any one unit (arbiter.CLAIM_SCOPE). + # (DECISIONS/MAIN-orchestration-archive.md) + if self.circ_wd.tripped: + claims.append(Claim.set("circulation", P_SAFETY, 0.0, + "circulating power", + actuator=self.actuator_of("circulation"))) + + # SITE, both of them: "stopped" (the operator switching control off) + # and "inputs missing" (the meter gone) are not about a pack. ⚠️ + # SAFETY-10: `and not self.shadow` is the one failsafe this card + # changes (a stop claim would record "stopped" on every shadow cycle); + # the wire gate in `_write_unit()` is stronger. + # (DECISIONS/MAIN-orchestration-archive.md) + if not self.auto and not self.shadow: + claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped", + actuator=self.actuator_of("safety"))) + elif not self.inputs_ok: + if self.target != 0.0: + self.log_event("inputs missing - commanding 0 W", + hint=messages.hint("inputs_missing")) + claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing", + actuator=self.actuator_of("safety"))) + elif late_s is not None: + claims.append(Claim.set("safety", P_SAFETY, 0.0, "readings late", + actuator=self.actuator_of("safety"))) + + # SAFETY-07: the quarter-peak projection guard: ONE `lo` limit at + # P_PEAK; peakguard.py carries the argument. ⚠️ `frozen` is evaluated + # on THIS cycle's standing command, not `self.freeze_wd.active` (a + # cycle behind). ⚠️ `achievable_w` keeps the floor from filing `lo > + # hi` (0 W). ⚠️ Filed EVERY cycle (claims are not sticky). ⚠️ NOT RUN + # WITHOUT P1: it would be blind, and blind means "no charging"; the + # report is NOT COVERED. (DECISIONS/MAIN-orchestration-archive.md) + self._peak_proj = (None if not self.p1_enabled + else self.peak.project(self.p1.averager, now, + roll=self.modes_on)) + # OPT-15 x SAFETY-81: see `check_peak_saturated()`. + self._peak_rolled = (self._peak_proj is not None + and self._peak_proj.block_start is not None + and self._peak_proj.block_start + != self.p1.averager.block_start) + _reading = self.peak.peak(now) if self.p1_enabled else None + # SAFETY-90: seconds a meter gap left uncounted are counted, so the + # guard goes on after the readings return. + self._peak_proj = self._fill_peak_gaps(self._peak_proj, now, _reading) + # SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter, + # so a HOLD's ceiling does not lapse for a cycle onto a held battery. + if self._peak_proj is not None and len(self.inverters) > 1: + self._peak_proj = next_quarter(self._peak_proj, + self.p1.averager.max_hold_s) + self.peak_guard = None if not self.p1_enabled else peakguard.decide( + self._peak_proj, + # ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is + # ALREADY removing from the meter, and the meter sees every + # inverter. (DECISIONS/MAIN-orchestration-archive.md) + batt_w=self.site_batt_w, + cap_w=float(self.o.get("peak_cap_w", 3500)), + margin_w=self.peak_margin_w, + reading=_reading, + # ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each unit + # contributes `min(its max_w, |its own device bounds|)`, so + # inverter B's rating bounds inverter B. + # (DECISIONS/MAIN-orchestration-archive.md) + achievable_w=self.site_rating_w, + # ⚠️ SAFETY-24. `pack_floor_soc` IS A MEASURED READING AND MAY BE + # None, and it is handed over as None rather than defaulted (a + # defaulted 100 % is a full battery, the value that disarms this + # floor); `decide()` treats None as floor-limited. + # (DECISIONS/MAIN-orchestration-archive.md) + soc=self.pack_floor_soc, + # ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN + # CUT-OUTS: `binding_soc_floor()` takes the MAXIMUM with each + # pack's `bms_soc_floor`, pairing with the MINIMUM in + # `pack_floor_soc` (both move towards refusing a shave). + # "Unreachable" is a claim about rule ordering, and sim/inject.py + # does not run rule 8: asserted over the whole schema range in + # test_peakguard.py part 12. + # (DECISIONS/MAIN-orchestration-archive.md) + soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get( + "peak_soc_floor", + peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))), + max_hold_s=self.p1.averager.max_hold_s, + # ⚠️ SAFETY-20. The device range goes in TOO: `standing` is + # `last_write`, the value AFTER `clamp_to_rating()`, so a rail from + # bare `max_w` is a ceiling the device cannot reach. + # (DECISIONS/MAIN-orchestration-archive.md) + frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None), + site_standing, max_w, self.freeze_wd.cfg, + dev_min=self.site_dev_bound("dev_min"), + dev_max=self.site_dev_bound("dev_max")), + ) + # SAFETY-87: at N>=2 a battery at or under its own reserve gives + # nothing for the peak, and the others carry the guard's need. + if self.peak_guard is not None: + self.peak_guard = self._peak_on_able(self.peak_guard, + maint_results) + self.peak_guard = self._peak_landing(self.peak_guard) + self.peak_guard = peakguard.cap_floor( # SAFETY-128 + self.peak_guard, self.answer.peak_cap(site_standing)) + # SITE, and peakguard.claim() says so on the object it builds: the + # quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION, + # which the capacity tariff bills (one quarter-hour peak per site, not + # per pack). ⚠️ It is also the `lo` half of SAFETY-25 §0c's table: a + # site `lo` that the deferred loop reserve's per-inverter `hi` must + # never cross. (DECISIONS/MAIN-orchestration-archive.md) + peak_claim = (peakguard.claim(self.peak_guard) + if self.peak_guard is not None else None) + if peak_claim is not None: + claims.append(peak_claim) + # ⚠️ EDGE-LOGGED, not level-logged: the action log holds 40 lines, and + # a line every 15 s for a quarter would push the cause off the end. + # (DECISIONS/MAIN-orchestration-archive.md) + engaged_now = self.peak_guard is not None and self.peak_guard.engaged + if engaged_now != self._peak_engaged: + self._peak_engaged = engaged_now + if self.peak_guard is not None: + _pg = self.peak_guard + self.log_event(_pg.explain(), hint=messages.hint( + "peak_off" if not _pg.engaged else + "peak_blind" if _pg.blind else + "peak_on" if _pg.within_aim else + "peak_reserve" if _pg.floor_limited else + "peak_saturated" if _pg.saturated else "peak_on")) + + # OPT-04: price arbitrage (§10.1 priority 3): ONE `set` claim at + # P_ARBITRAGE; arbitrage.py carries the argument. ⚠️ AFTER THE PEAK + # BLOCK BECAUSE IT READS ITS CONCLUSION (`engaged_now`). ⚠️ The + # three-state collapse lives in arbitrage.guard_state(), NOT here (None + # = the guard never ran, without P1). ⚠️ NO FETCH ON THIS PATH. ⚠️ The + # uncertainty set is NOT empty (no weather forecast is built). + # TARIFF-11: `arb_at` keeps the status row honest. + # (DECISIONS/MAIN-orchestration-archive.md) + self.arb, self.arb_at = None, now + self.arb_headroom_w = None + self.room_charge = False + # OPT-11 P3: with battery modes on, each battery's mode decides who + # trades (`_mode_roles()`, `_mode_trade()`, below, which read the + # same headroom), and this site-wide trade is not run. + site_trade = arbitrage.enabled(self.o) and not self.modes_on + if site_trade or self.modes_on: + # SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED: one evaluation + # feeds both the sizing below and `arbitrage.sized_from_meter()`, + # so the caveat on the recorded watts is never answered from a + # different cycle's guard. + # (DECISIONS/MAIN-orchestration-archive.md) + self.arb_headroom_w = peakguard.charge_room_w(self.peak_guard, + self._peak_proj) + if site_trade: + floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) + charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct) + self.arb = arbitrage.decide( + self.fresh_tariff_plan(now), now, + peak_guard_active=arbitrage.guard_state(self.peak_guard), + # SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS + # ONE: the watts the billed quarter can still afford clamp the + # buy leg (a switch alone gave 28 charge onsets an hour on + # TEST-23). ⚠️ It reads the peak block's conclusion, so it must + # stay after it. (DECISIONS/MAIN-orchestration-archive.md) + charge_headroom_w=self.arb_headroom_w, + # The emptiest pack again, and for the sell side that is the + # right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there + # is one `soc` argument - `pack_floor_soc` names that residual + # rather than hiding it; a per-pack charge ceiling is CFG-04's, + # and at the shipped roster of one the two reductions are the + # same number. + soc=self.pack_floor_soc, + min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), + uncertainty=frozenset({Uncertainty.NO_WEATHER}), + # OPT-09. PER-PACK SIZING, not `max_w`: the trade is sized to + # the packs whose own state of charge permits that direction + # (`trade_power_w()`). ⚠️ At N=1 both sides are `max_w` + # exactly. ⚠️ On the sell side this is a no-op today (`soc=` + # below is the EMPTIEST pack); test_packshare.py part 9 grades + # it. (DECISIONS/MAIN-orchestration-archive.md) + charge_w=charge_w, discharge_w=discharge_w, + soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct) + # SITE, and arbitrage.claim() says so on the object it builds. It + # buys and sells at the grid against a day-ahead price: the price is + # a property of the connection and the energy crosses the meter. + arb_claim = arbitrage.claim(self.arb) + if arb_claim is not None: + claims.append(arb_claim) + self.room_charge = self.arb.action == arbitrage.CHARGE + # Edge-logged, for the peak guard's reason: the action log holds 40 + # lines and a trade lasts a whole hour, so a line per cycle would + # flush the context that explains it. + # (DECISIONS/MAIN-orchestration-archive.md) + if self.arb.action != self._arb_action: + self._arb_action = self.arb.action + self.log_event(self.arb.explain(), hint=messages.hint( + "trade_charge" if self.arb.action == arbitrage.CHARGE else + "trade_give" if self.arb.action == arbitrage.DISCHARGE + else "trade_off")) + + # maintenance ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST + # STAYS SHORT. (DECISIONS/MAIN-orchestration-archive.md) + for inv, result in zip(self.inverters, maint_results): + if result.owns_setpoint: + # PER-INVERTER: §12's recalibration is a PACK procedure, filed + # on the pack whose state machine produced it (`inv`, + # MAINT-05). ⚠️ IT STAYS `SCOPE_UNIT`, never a site claim + # (ARCH-01: per-inverter limits filed site-wide are the + # cross-inverter `lo > hi`). + # (DECISIONS/MAIN-orchestration-archive.md) + claims.append(Claim.set("maintenance", P_MAINTENANCE, + float(result.setpoint_w or 0.0), result.phase, + actuator=self.actuator_of("maintenance", inv))) + self.sat_count = 0 + elif result.charge_only: + # Money outranks the maintenance schedule: while the projection + # is over the cap the charge-only shaping is not claimed, so + # the loop can shave. ⚠️ Shaping is `hi <= 0` and the peak + # claim `lo >= 0`; both filed give `lo > hi`, which resolves to + # 0 W in the quarter that sets the bill, so the higher claimant + # SUPPRESSES the lower. ⚠️ THE CONJUNCTION LIVES IN + # peakguard.shaping_suspended(), NOT HERE. One suspension flag + # per SITE (`arbitrate()` allows one pack in CHARGE). + # (DECISIONS/MAIN-orchestration-archive.md) + if peakguard.shaping_suspended( + self.peak_guard, self.peak_fc, self.peak_aim_w()): + # Edge-logged: level-logging here would write one line per + # cycle for as long as the event lasts and flush the 40-line + # action log of the very context that explains it. + if not self._maint_shape_suspended: + self._maint_shape_suspended = True + self.log_event("peak at risk - maintenance charge shaping " + "suspended so the peak claim is not " + "contradicted", + hint=messages.hint("maint_paused")) + else: + self._maint_shape_suspended = False + hi = 0.0 + reason = "charge-only" + if self.cheap: + # ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND + # READ OF `self.o`: a literal fallback here let the + # shaping and the state machine use different rates + # from the same install. + # (DECISIONS/MAIN-orchestration-archive.md) + floor = maintenance_charge_floor( + self.maint.cfg.charge_w, + self.peak_fc, self.peak_aim_w()) + floor = self._charge_headroom(floor, claims) + if floor > 0: + hi, reason = -floor, "cheap-window charge" + # PER-INVERTER, like the SET above and for the same reason - + # the shaping is about the pack being charged, which is `inv`. + claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, + reason=reason, + actuator=self.actuator_of("maintenance", + inv))) + + # OPT-11: each battery's mode, a soft unit limit at P_MODE. Filed only + # with battery modes on, and never on a battery in any maintenance + # phase (maintenance outranks the mode). The range always holds 0 W + # (packmode.mode_range()) and gives way to the peak floor only as far + # as needed, so the two never meet in `lo > hi` + # (arbiter._unit_modes()). (DECISIONS/MAIN-orchestration-archive.md) + if self.modes_on: + tick = self._mode_roles(now, maint_results) + for inv, result in zip(self.inverters, maint_results): + if result.phase != IDLE: + continue + pm = self.pack_mode(inv) + lo, hi = packmode.mode_range(pm, inv.max_w) + rlo, rhi = tick.ranges.get(inv.name, (lo, hi)) + lo, hi = max(lo, rlo), min(hi, rhi) + claims.append(Claim.limit("mode", P_MODE, lo, hi, pm.key, + actuator=self.actuator_of("mode", + inv))) + + # the grid-following loop. ⚠️ SAFETY-10: `or self.shadow`: the loop is + # the leg a shadow run most needs; it is safe because the loop's + # `prev_w` is `self.target`, re-seeded to what is on the wire. + # SAFETY-52: a refusal learned while control ran does not survive a + # stop or a start. (DECISIONS/MAIN-orchestration-archive.md) + if bool(self.auto) != self._refusal_auto: + self._refusal_auto = bool(self.auto) + self._refusal_end("control started" if self.auto + else "control stopped") + for inv in (self.inverters if len(self.inverters) > 1 else ()): + self._unit_refusal_end(inv, "control started" if self.auto + else "control stopped") + # SAFETY-73: each unit's own refusal, engaged and ended on its own + # reading, before the loop reads `self.target` (an end reseeds it). + _probing = {} + if len(self.inverters) > 1 and self.inputs_ok: + self._unit_refusals() + _probing = {inv.name: self._unit_probe(inv) + for inv in self.inverters if inv.refused} + # SAFETY-73: each refusing unit's own P_SAFETY claim: a hard unit limit + # that always intersects (OPT-11's P_MODE sits inside it), closed in + # the refused direction, so the allocator hands the share to the others + # within their own limits; a pin at PROBE_W during its probe. Filed + # before the loop so OPT-11's loop bound sees it. + # (DECISIONS/MAIN-orchestration-archive.md) + for inv in self.inverters: + if not inv.refused: + continue + if _probing.get(inv.name): + claims.append(Claim.set("refused", P_SAFETY, + inv.refused * _ctl.PROBE_W, + _ctl.PROBING, + actuator=self.actuator_of("refused", + inv))) + else: + claims.append(Claim.limit("refused", P_SAFETY, + *_refused_interval(inv.refused), + _ctl.REFUSED, + actuator=self.actuator_of("refused", + inv))) + # --- SAFETY-78: a pinned drain is a floor under the site target ----- + # After every unit claim, so the floor is what the pin really places. + drain = self._drain_floor(claims, maint_results) + if drain is not None: + claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain, + reason="maintenance drain", + actuator=self.actuator_of("drain"))) + self._peak_over_drain(claims, maint_results) + # --- SAFETY-86: while a HOLD gives, the site stays at the peak floor -- + # So the loop never asks for watts the hold keeps back. + hold_hi = self._peak_over_hold(claims, maint_results) + if hold_hi is not None: + claims.append(Claim.limit("hold", P_MAINTENANCE, hi=hold_hi, + reason="maintenance hold", + actuator=self.actuator_of("hold"))) + # --- SAFETY-91: a drain rises before a held battery gives ------------ + self._drain_rise(claims, maint_results) + # --- SAFETY-84: a cheap-window charge is a ceiling over the site ---- + charge = self._charge_ceiling(claims) + if charge is not None: + claims.append(Claim.limit("charge", P_MAINT_SHAPE, hi=charge, + reason="maintenance charge", + actuator=self.actuator_of("charge"))) + # --- OPT-11 P3: the site trade, after every unit claim -------------- + # Sized from what the batteries may place with their modes and hard + # limits (the loop bound), so none is asked for watts it may not give. + mode_trade, trade_was = False, self._mode_trade_was + if self.modes_on: + mode_trade = self._mode_trade(claims, now) + self.room_charge = self.room_charge or ( + mode_trade and self.arb.action == arbitrage.CHARGE) + self._mode_trade_was = mode_trade + self._charge_track(claims, maint_results) + _live96 = set(self.live_units()) + self._loop_start = {} + if (self.auto or self.shadow) and self.inputs_ok: + # ⚠️ prev is the ARBITER's last output, not the loop's own wish: if + # something outranked the loop, that is what the hardware did. The + # integrator re-seeds from the same (entering failsafe re-seeds it + # to zero). (DECISIONS/MAIN-orchestration-archive.md) + moved = self.loop_w is None or self.target != self.loop_w + if moved: + self.i_w = self.target + # OPT-11 P3: a run edge (a mode trade ended) reseeds the loop, and + # OPT-15 holds it there until the meter shows the step. + if trade_was and not mode_trade: + self._reseed_run_edge(claims, now) + moved = False + loop_grid = self._edge_grid(mode_trade or moved, now) + # SAFETY-52: another writer overrode the loop, so a refusal learned + # against the loop's own ask may no longer describe the wire. + # (DECISIONS/MAIN-orchestration-archive.md) + rejudged = 0 + if self.loop_w is not None and self.target != self.loop_w: + rejudged = self.batt_refused + # SAFETY-96: a battery that dropped out of the split leaves the + # loop's start point (SAFETY-93's own if its charge ended too). + self._reseed_drop(_live96) + # SAFETY-93: a charge that ended leaves the loop's start point. + self._reseed_charge_end(maint_results) + # SAFETY-95: then a drain that ended, last (it reads the start + # the others left). + self._reseed_drain_end(maint_results, _live96) + # No probe while it is judged afresh (the loop does not hold the wire). + probe = False if rejudged else self._refusal_probe() + if len(self.inverters) > 1: + probe = any(_probing.values()) + # ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF. + # The anti-windup test in compute() is `abs(prev_w - actual_w) > + # saturation_w` and `prev_w` is the SITE target; one inverter's + # power at N=2 would trip the saturation freeze permanently. + # (DECISIONS/MAIN-orchestration-archive.md) + site_batt = self.site_batt_w + # SAFETY-103: what each battery can be doing (its reading may be + # older than the meter). + self._track_reads() + # SAFETY-96: a battery the loop cannot move counts at its command, + # so its own power does not freeze the loop (the meter has it). + site_batt += self._held_out_w(_live96) + # TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED + # AGAINST. (DECISIONS/MAIN-orchestration-archive.md) + loop_prev = self.target + decision = compute( + prev_w=loop_prev, + # SAFETY-95: a drain's tail is not the loop's to chase. + # OPT-15: `loop_grid` is the grid, or the target while a run + # edge is held. + grid_w=(self._drain_grid_w(_live96) if self._drain_tail + else loop_grid), + actual_w=site_batt, + tuning=self.tuning, + sat_count=self.sat_count, + i_w=self.i_w, + # SAFETY-52 at one inverter only: with several, OPT-09's pack + # limit already closes an empty battery's discharge end. + # SAFETY-73: at N>1 the site law holds 0 W only while every + # unit refuses the same way. Otherwise None, the old law. + refused=(0 if rejudged else self.batt_refused) + if len(self.inverters) == 1 + else self._site_refused(), + probe=probe, + # SAFETY-94: at N=1 the engage also needs the reading to have + # given nothing for the whole span, as at N>1. + gave_nothing=(bool(self._gave_nothing(self.inverters[0])) + if len(self.inverters) == 1 else None), + # SAFETY-97: "gives nothing" is centred on the idle reading. + idle_w=self._site_idle_w(), + hold=self.answer.hold, hold_w=self.answer.hold_w, # SAFETY-128 + # OPT-11: None unless battery modes are on. SAFETY-85: or while + # maintenance holds a battery at N>=2, so the loop does not + # wind past what the others can give and jump when it ends. + bound=(self.loop_bound(claims) if self.modes_on or ( + len(self.inverters) > 1 + and any(r.phase != IDLE for r in maint_results)) + else None), + ) + if decision.frozen and self.sat_count < self.tuning.saturation_cycles: + self.log_event(f"saturation freeze ({self.target:.0f} W vs " + f"{site_batt:.0f} W)", + hint=messages.hint("not_keeping_up")) + self.sat_count = decision.sat_count + self.i_w = decision.i_w + self.loop_w = decision.target_w + self._loop_decision = decision + if len(self.inverters) == 1: + self._refusal_update(decision, loop_prev, site_batt, probe, + rejudged) + # TEL-10. ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS: `loop_i` + # is `Decision.i_w`, the value BEFORE the clamp, slew and + # quantisation, so it is NOT `setpoint` and must not be published + # from it; `loop_lead` is how far it stands ahead of the command; + # `loop_reason` is `compute()`'s own word and NOT `self.reason`. + # (DECISIONS/MAIN-orchestration-archive.md) + self.loop_i_w = decision.i_w + self.loop_i_lead = decision.i_w - loop_prev + self.loop_reason = decision.reason + # SAFETY-03. ⚠️ READ THE BOOLEANS, NOT `decision.reason`: `reason` + # is overwritten by slew-limiting or the saturation freeze after + # the clamp, so a string test is silent on the cases a winding loop + # most often presents as. SAFETY-40: every number comes from THIS + # decision, at clamp time. + # (DECISIONS/MAIN-orchestration-archive.md) + self.loop_clamped = ( + decision.i_clamped or decision.clamped, + loop_clamp_note(decision), + decision.clamped, + ) + self.loop_clamp_facts = ( + (messages.Clamp(None, decision.requested_w, decision.clamped_w, + "rating"),) if decision.clamped else ()) + # SITE. compute() regulates `grid_w` - the P1 measurement - to + # zero. Its subject is the meter, and §6.2's architecture is ONE + # integrator on P1 whose output is then SPLIT. + # (DECISIONS/MAIN-orchestration-archive.md) + claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason, + actuator=self.actuator_of("loop"))) + else: + # Stopped or blind: no accumulation may survive the outage. + self.i_w = 0.0 + self.loop_w = None + self._live_was = frozenset(_live96) + # TEL-10, and it is the same zero the line above writes rather than + # a second opinion about it. ⚠️ `not-running` rather than a stale + # `tracking`: a reason row that keeps repeating the last word the + # loop said while the loop is not running is a row that reads as + # health, which is the failure `publish()`'s own None-dropping note + # is about one field over. + self.loop_i_w = 0.0 + self.loop_i_lead = 0.0 + self.loop_reason = "not-running" + # A loop that is not running cannot be winding. Clearing this here + # rather than letting the last value stand is what makes the alarm + # de-assert when control is stopped. + self.loop_clamped = (False, "", False) + self.loop_clamp_facts = () + self._loop_decision = None + + # SAFETY-28. A new resolution is a new tick, and the previous record is + # stale from this line onwards. Incremented BEFORE resolve() so that the + # record resolve() builds is stamped with the tick it is for. + self.alloc_tick += 1 + # ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. A METHOD, NOT + # A DICT: the weighting is by ENERGY HEADROOM, so it depends on the + # direction (the sign of the site target, chosen inside `resolve()`; + # see `pack_weights()`). ⚠️ A PREFERENCE, NOT A PERMISSION: a weight + # cannot stop a unit being commanded. + # (DECISIONS/MAIN-orchestration-archive.md) + _live = self.live_units() + while True: + resolution = resolve(claims, units=self.units, live=_live, + weights=self.pack_weights, tick=self.alloc_tick, + reserve=self._held_back()) + # SAFETY-73: a probe pin stands only while the resolved site target + # asks at least PROBE_W its way; below that a sibling would carry + # the opposite direction (circulation), so the pin goes back to the + # closed limit. (DECISIONS/MAIN-orchestration-archive.md) + _void = [k for k, c in enumerate(claims) + if c.source == "refused" and c.kind == "set" + and c.value * resolution.target_w < _ctl.PROBE_W ** 2] + if not _void: + break + for k in _void: + claims[k] = Claim.limit( + "refused", P_SAFETY, + *_refused_interval(1 if claims[k].value > 0 else -1), + _ctl.REFUSED, actuator=claims[k].actuator) + self.allocation = resolution.allocation + if self.modes_on: + self._mode_fault_log(resolution) + # THE TWO REFUSALS ARE NOT THE SAME REFUSAL: both REPLACE the + # resolution with a 0 W failsafe, written like any other cycle; they + # differ in WHO refused, hence two branches and two messages. ⚠️ + # NEITHER MAY BE LEFT UNGRADED (test_peakguard.py part 10h). + # (DECISIONS/MAIN-orchestration-archive.md) + if not resolution.allocated: + self.log_event("ARBITER: " + resolution.allocation.explain(), + hint=messages.hint("split")) + resolution = Resolution(0.0, "failsafe", None, False, + resolution.considered, + unit_bounds=resolution.unit_bounds, + shares={}, allocated=False, + scope_errors=resolution.scope_errors, + allocation=resolution.allocation, + winner_priority=resolution.winner_priority) + # 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT. ⚠️ + # RE-AIMED BY DEV-02, NOT DELETED: a roster entry with no writer would + # have the site target written to the others (over-command by its + # share); "nothing" here means 0 W. Not folded into `live_units()`: a + # dead link is dropped and announced, an undeliverable roster stops the + # site. (DECISIONS/MAIN-orchestration-archive.md) + elif {u for u in resolution.shares if not self._can_write(u)}: + undeliverable = ", ".join( + sorted(u for u in resolution.shares if not self._can_write(u))) + self.log_event( + f"ARBITER: the site target was allocated across " + f"{len(resolution.shares)} inverters and this controller has no " + f"setpoint entity for {undeliverable} - refusing to command a " + f"partial site", hint=messages.hint("no_setpoint")) + # ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE + # DEV-02 THAT IS LOAD-BEARING: the write loop commands + # `shares.get(name, 0.0)`, so carrying the split through would + # write the numbers this branch declined, or nothing at all when + # they equal what stands (an unchanged value is not re-sent). + # (DECISIONS/MAIN-orchestration-archive.md) + resolution = Resolution(0.0, "failsafe", None, False, + resolution.considered, + unit_bounds=resolution.unit_bounds, + shares={}, allocated=True, + scope_errors=resolution.scope_errors, + allocation=resolution.allocation, + winner_priority=resolution.winner_priority) + # 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its + # share, a real change in what the site can deliver, and an operator + # who is not told reads a working site. Edge-triggered on the SET of + # dropped units (the action log is a log of events, §15). + # (DECISIONS/MAIN-orchestration-archive.md) + _dropped = tuple(u for u, _why in (self.allocation.dropped + if self.allocation else ())) + if _dropped != self._dropped_last: + for _u, _why in (self.allocation.dropped if self.allocation else ()): + self.log_event(f"ALLOCATOR: {_u} dropped from the split " + f"({_why}) - the remaining inverter(s) now carry " + f"its share of the site target", + hint=messages.hint("unit_dropped", unit=_u)) + if self._dropped_last and not _dropped: + self.log_event("ALLOCATOR: every inverter is back in the split", + hint=messages.hint("units_back")) + self._dropped_last = _dropped + # 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER. + # `unmet_w` is the site target the envelopes could not absorb; not a + # broken allocation (fusing it with `ok` would let one saturated + # inverter park the site), but the site failing to deliver what the + # loop asked. Edge-triggered on the VALUE. + # (DECISIONS/MAIN-orchestration-archive.md) + _unmet = int(self.allocation.unmet_w if self.allocation else 0) + if _unmet != self._unmet_last: + if _unmet: + self.log_event( + f"ALLOCATOR: {_unmet:+d} W of the site target could not be " + f"placed on any inverter - {self.allocation.explain()}", + hint=messages.hint("unmet", w=_unmet)) + else: + self.log_event("ALLOCATOR: the whole site target fits again", + hint=messages.hint("unmet_gone")) + self._unmet_last = _unmet + if resolution.scope_errors: + # A claim reached the arbiter on an actuator its source's scope + # does not permit: a BUG in the filing site, not a state to survive + # (a per-inverter constraint in the site list is the `lo > hi` this + # card removes). Loud, every cycle; the resolution is unchanged. + # (DECISIONS/MAIN-orchestration-archive.md) + _LOG.error("A limit was filed on the wrong inverter, which is a " + "software fault. %s.", " / ".join(resolution.scope_errors)) + if resolution.contradiction: + self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log", + hint=messages.hint("clash")) + # SAFETY-02's arming gate: was the number chosen by the grid-following + # loop, or by a strategy that commands against the meter on purpose? + # Only the former is expected to agree in sign with P1. ⚠️ THE + # EXPRESSION LIVES IN watchdog.py, NOT HERE: read loop_is_following() + # before touching the gate. (DECISIONS/MAIN-orchestration-archive.md) + self.following = loop_is_following(resolution, self.loop_w) + self.target = resolution.target_w + self.reason = resolution.explain() + # UX-08: the same answer in plain words, for the page's "Why" line; + # `reason` stays the technical record the log and the checks read. + # UX-10: every key `why_key()` can return has words in + # messages.WHY_WINNER. (DECISIONS/MAIN-orchestration-archive.md) + _why = why_key(resolution.winner, claims) + if _why == "inputs missing" and not resolution.contradiction: + # UX-11: past the hold, say which reading is missing. + self.why_words = messages.why_missing(self.missing_reading(), + stale_after) + else: + self.why_words = messages.why( + _why, resolution.target_w, resolution.bound_by, + contradiction=resolution.contradiction, + raised=why_raised(resolution, claims)) + # DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE, NEVER THE SITE TARGET + # (handing each unit `self.target` is the ARCH-01 fault: each supplies + # it in full and the gain at the meter becomes N times the per-unit + # gain). At N=1 this is byte-identical. ⚠️ A unit not in `shares` is + # commanded 0 W, not left standing: an inverter holds its last command + # for ever. (DECISIONS/MAIN-orchestration-archive.md) + self._loop_alarm_input(resolution) + writes = [] + for inv in self.inverters: + inv.target = float(resolution.shares.get(inv.name, 0.0)) + writes.append(self.write_setpoint(inv.target, unit=inv)) + await asyncio.gather(*writes) + + # SAFETY-03, and its boundary is the WRITE. ⚠️ NOT THE SAME BOUNDARY AS + # SAFETY-18's BELOW, THOUGH THE CALLS ABUT: this one reads + # `self.clamp_last`, None until write_setpoint() has run, so it must + # follow the write (test_clamp.py part 6). Two guards, two orderings: + # do not "tidy" them into one rule. + # (DECISIONS/MAIN-orchestration-archive.md) + await self.check_output_clamp() + + # SAFETY-18, and its boundary is the ASSIGNMENT. ⚠️ THE BOUNDARY IS + # `self.target = resolution.target_w` ABOVE, NOT write_setpoint(): both + # terms of the conjunction must describe the SAME moment, so moving it + # above the assignment grades the previous cycle's command against this + # cycle's meter (test_freeze.py part 5). + # (DECISIONS/MAIN-orchestration-archive.md) + await self.check_meter_frozen() + + # --- PRICE-01: no price for now, said once and taken down once -------- + # Not a safety check, so it is one of the cheap ones: an attribute test + # and, on a change only, one Home Assistant call. + await self.check_price_gap() + await self.check_fixed_note() + + # SAFETY-31's alarm, and its boundary is the LATCH, not a number: it + # grades `self.circ_wd.tripped`, advanced near the top of this method. + # Called on EVERY cycle, not only the one that trips, because its + # second job is re-raising the alarm after a restart. + # (DECISIONS/MAIN-orchestration-archive.md) + await self.check_circulating() + + # SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's: + # it grades `inv.delivery_wd.tripped`, and is called on EVERY cycle + # because its second job is re-raising the alarm after a restart. + # (DECISIONS/MAIN-orchestration-archive.md) + 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. + await self.check_peak_saturated() + + # SAFETY-24's alarm, and it is the OTHER way the money gets spent. Same + # boundary argument as check_peak_saturated(); kept as its own method + # because the two states are mutually exclusive and want OPPOSITE + # operator actions (saturated: shed load; this: the pack is at its + # floor and the quarter is not being shaved). + # (DECISIONS/MAIN-orchestration-archive.md) + await self.check_peak_floor_limited() + + # TARIFF-01, and it is LAST on purpose: banking a quarter is + # bookkeeping about a moment that has passed, and a slow SD card must + # never sit between the meter and the setpoint. It grades neither + # `target` nor `standing`. (DECISIONS/MAIN-orchestration-archive.md) + self.peak.sync(self.p1) + + # SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS: every + # guard above has read `self.target`, the resolved intent (SAFETY-18 + # grades exactly that); re-seeding before them would hand each a 0 W no + # decision produced. See `_shadow_settle()`. + # (DECISIONS/MAIN-orchestration-archive.md) + self._shadow_settle(resolution) + + def _shadow_settle(self, resolution) -> None: + """SAFETY-10. Record the decision, then re-seed the carried command + (`self.target`, each `inv.target`) to `shadow.WIRE_W`, in this order + because the second destroys the first's evidence. ⚠️ THE RE-SEED IS A + SAFETY PROPERTY: compute's slew base, the sign watchdogs + (`standing_w()`) and SAFETY-42 read the carried command as a statement + about the HARDWARE; a shadow decision there would latch a trip that + survives into live operation. One log line per CHANGE of decision. + (DECISIONS/MAIN-orchestration-archive.md)""" + engaged = self.shadow + if engaged != self._shadow_was: + # ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT (a + # "shadow mode off" line at every start pushed a clamp episode out + # of the 40-row log); coming up IN shadow mode always gets a line. + # (DECISIONS/MAIN-orchestration-archive.md) + first = self._shadow_was is None + self._shadow_was = engaged + if engaged: + self.log_event( + "SHADOW MODE ON - " + shadow.why( + self.o, control_started=self.auto) + + f"; inverter held at {shadow.WIRE_W:.0f} W", + hint=messages.hint("shadow_on")) + # UX-10: INFO. It follows a setting or a button press, and + # no person has to act on it. + _LOG.info( + "Shadow mode is on, so every inverter stays at %.0f W and " + "nothing the add-on decides is sent. %s.", shadow.WIRE_W, + shadow.why(self.o, control_started=self.auto)) + elif not first: + self.log_event( + "Shadow mode off: the add-on controls the battery again, " + f"starting from {shadow.WIRE_W:.0f} W", + hint=messages.hint("shadow_off")) + _LOG.info( + "Shadow mode is off, so the add-on controls the battery " + "again, starting from %.0f W.", shadow.WIRE_W) + if not engaged: + self.shadow_would_w = None + self.shadow_sized_from_meter = False + self._shadow_winner = None + return + self.shadow_would_w = float(resolution.target_w) + # SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY: all three rungs live in + # `arbitrage.sized_from_meter()`; a rung written at a call site is one + # no check can reach with the other two clear (TEST-16). + # (DECISIONS/MAIN-orchestration-archive.md) + self.shadow_sized_from_meter = arbitrage.sized_from_meter( + self.arb, self.arb_headroom_w, winner=resolution.winner) + # ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE (a loop + # target that that wanders by a watt would be the whole log). ⚠️ + # SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE: the same winner at the + # same watts with and without the caveat are two different claims. + # (DECISIONS/MAIN-orchestration-archive.md) + edge = (resolution.winner, round(float(resolution.target_w)), + self.shadow_sized_from_meter) + if edge != self._shadow_winner: + self._shadow_winner = edge + self.log_event(shadow.log_line( + would_w=resolution.target_w, reason=resolution.explain(), + sized_from_meter=self.shadow_sized_from_meter), + hint=messages.hint("shadow_decision", + sw=resolution.target_w)) + self.target = shadow.WIRE_W + for inv in self.inverters: + inv.target = shadow.WIRE_W diff --git a/goodwe_controller/app/ctl_inputs.py b/goodwe_controller/app/ctl_inputs.py new file mode 100644 index 0000000..c5af7b1 --- /dev/null +++ b/goodwe_controller/app/ctl_inputs.py @@ -0,0 +1,424 @@ +"""Controller methods that read the inputs: meter, packs, PV, forecast. + +Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" + +import asyncio +import logging +import time +from datetime import datetime, timezone + +from .arbiter import actuator_for +from . import solarfc +from . import freshness +from . import lateness +from . import messages +from . import pv as pv_read +from .price import brussels, price_option, provider_configured +from .price_chain import uses_chain +from .static_tariff import static_status +from .rows import tariff_plan_row + +_LOG = logging.getLogger("goodwe") + + +# UX-11: a quiet meter logs at most one WARNING this often (on `ha_signed` a +# healthy meter can go 42-97 s between changes). +METER_QUIET_REPEAT_S = 600.0 + + +_mono = time.monotonic # patched by the checks; asyncio keeps the real one + + +# OPT-04: the background tariff refresh. The tick NEVER awaits a fetch: it +# reads `self.tariff_plan`, which the refresh task assigns. +# DECISIONS/MAIN-orchestration.md. +TARIFF_REFRESH_S = 1800.0 + + +# The age bound covers the refresh task dying: three missed refreshes. +# DECISIONS/MAIN-orchestration.md. +TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S + + +# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an +# evening restart keeps the night plan's figure. +FC_HOLD_KEY = "solar_fc_hold" + + +FORECAST_READ_S = 60.0 + + +class InputsMixin: + def _readings_late(self, standing_w): + """SAFETY-128: update the answer gate; seconds the late verdict has + stood, or None. DECISIONS/SAFETY-128-late-readings.md.""" + a = self.answer + if self._now_mono is None: + return None + a.observe(self._now_mono, standing_w, self.site_batt_w, + grid_w=self.grid, exempt=bool(self.batt_refused) or any( + i.refused or ( + i.batt is not None + and abs(i.standing_w()) >= lateness.ANSWER_W + and abs(i.batt - i.idle_w) < lateness.ANSWER_W) + for i in self.inverters), + idle_w=self._site_idle_w()) + if a.late_seen != self._late_logged: + self._late_logged = a.late_seen + if a.late_seen: + self.log_event("readings late - holding", + hint=messages.hint("readings_late")) + _LOG.warning("Home Assistant readings arrive late, so control " + "holds the batteries: READINGS_LATE.") + _LOG.debug("READINGS_LATE: the readings show a power request " + "%.0f s after it is sent.", a.delay_s) + else: + self.log_event("readings on time", hint=messages.hint( + "readings_back")) + _LOG.info("Home Assistant readings are on time again.") + return a.late_s(self._now_mono) + + @property + def inputs_ok(self) -> bool: + """The site meter, and EVERY pack's own two readings: every pack, not + the live ones. A unit whose SoC did not answer cannot be graded for + the maintenance FSM or the pack floor, and 11.1 rule 1 for a missing + input is command zero and keep commanding.""" + if self.grid is None or not self.inverters: + return False + return all(inv.soc is not None and inv.batt is not None + for inv in self.inverters) + + def missing_reading(self) -> str: + """UX-11: the first reading `inputs_ok` finds missing, in words.""" + if self.grid is None or not self.inverters: + return "meter" + for inv in self.inverters: + of = "" if len(self.inverters) == 1 else f" from {inv.name}" + if inv.batt is None: + return f"battery power{of}" + if inv.soc is None: + return f"battery level{of}" + return "input" + + # -- io ------------------------------------------------------------------ + @property + def pv_roster(self) -> tuple: + """Every configured PV production entity, or `()` (TEL-11/TEL-12). ONE + reading of the options, `pv.entities()`, the same call + `mqtt.optional_keys()` makes, so what is ANNOUNCED, READ and CHECKED + cannot disagree; it replaced a `pv_entity` property.""" + return pv_read.entities(self.o) + + async def _read_pv(self) -> None: + """This cycle's SITE PV production, or an ABSENCE with a reason + (TEL-11/12). `pv.total()` makes "one of three absent" an absence of + the SITE figure. It assigns on EVERY call including failing ones (a + stale value must not survive a failure), and uses `hass.state()` not + `hass.number()` because the UNIT is half of the answer.""" + # TEL-12: every entity in ONE gather. Read serially, three slow sensors + # would add three 10 s timeouts to a cycle that feeds the ESP32 + # heartbeat; telemetry must have no foot in the control path. + roster = self.pv_roster + states = await asyncio.gather( + *(self.hass.state(entity) for entity in roster)) + self.pv_w, self.pv_why, self.pv_parts = pv_read.total( + list(zip(roster, states)), pv_read.inverted(self.o)) + # UX-54: kept for the Forecast tab only. It must never cost a cycle. + try: + self.solar_today.add(datetime.now(timezone.utc), self.pv_w) + except Exception: # noqa: BLE001 + _LOG.debug("measured solar not kept", exc_info=True) + + def _peak_fc_unit_log(self, fault) -> None: + """SAFETY-118. One WARNING when the peak forecast's unit cannot be read + in watts, and again only if the unit changes; the unit is on DEBUG.""" + if fault == self._peak_fc_fault: + return + self._peak_fc_fault = fault + if fault is None: + _LOG.info("The 15-minute demand forecast is used again.") + elif fault == "": + _LOG.warning("The 15-minute demand forecast has no unit, so it " + "is not used.") + else: + _LOG.warning("The 15-minute demand forecast is not in a power " + "unit, so it is not used.") + _LOG.debug("The 15-minute demand forecast reads in %r.", fault) + + async def _read_forecast(self) -> None: + """OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A + failed or stale read is no forecast, never a zero.""" + # OPT-17: every member of both days in one gather. + ents = self.fc_entities + self.fc_tomorrow_entities + states = await asyncio.gather(*(self.hass.state(e) for e in ents)) + n = len(self.fc_entities) + now = datetime.now(timezone.utc) + fc, why, gaps = solarfc.read_sum( + list(zip(self.fc_entities, states[:n])), + list(zip(self.fc_tomorrow_entities, states[n:])), now, + hold=self.fc_hold) + if self.fc_hold.changed: + self.fc_hold.changed = False + self.store.set(FC_HOLD_KEY, self.fc_hold.dump()) + # A member is named only in a sum of several; one entity reads as + # before. Logged once per episode: on a change, never every read. + gaps = tuple(g for g in gaps if len( + self.fc_entities if g[0] == "today" + else self.fc_tomorrow_entities) > 1) + was_kept = bool(self.solar_fc is not None and self.solar_fc.kept) + if (fc is None) != (self.solar_fc is None) \ + or why != self.solar_fc_why or gaps != self.solar_fc_gaps \ + or bool(fc is not None and fc.kept) != was_kept: + self.log_event( + "solar forecast: " + ( + "none (" + str(why) + ")" if fc is None else + f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept + else "read") + + "".join(f", {d} {e} not usable ({w})" + for d, _i, e, w in gaps), + hint=messages.hint( + "forecast_none" if fc is None else + "forecast_no_tomorrow" if gaps else "forecast_read")) + self.solar_fc, self.solar_fc_why = fc, why + self.solar_fc_gaps = gaps + + async def read_inputs(self, now_mono: float | None = None) -> None: + """Every input this cycle needs, and SAFETY-37's freshness verdict. + `now_mono` is defaulted rather than read inside `_read_unit()` so + ONE instant covers every unit in a gather.""" + o = self.o + now_mono = freshness.monotonic() if now_mono is None else float(now_mono) + self._now_mono = now_mono + if self.p1_enabled: + # P1 is the only authoritative measurement of what the utility sees + # (5.1); when stale this is None -> "inputs missing -> command 0 + # W". NO fallback to an inverter-side figure: its AC power + # correlates 0.998 with battery power and 0.09 with the real meter + # (a controller would regulate against its own output). + self.grid = self.p1.net_w + else: + self.grid = await self.hass.number(o.get("meter_entity", ""), + bool(o.get("meter_invert"))) + # DEV-02: each inverter's own two readings from its own entities, + # CONCURRENTLY across units and sequentially within one (a slow entity + # must not delay the heartbeat path). The PV reads ride in the same + # gather and an empty PV roster appends nothing (no coroutine). + # DECISIONS/MAIN-orchestration.md. + _reads = [self._read_unit(inv, now_mono) for inv in self.inverters] + if self.pv_roster: + _reads.append(self._read_pv()) + # OPT-11 P4: the solar forecast, in the same gather (a slow sensor must + # not delay control), at most once per FORECAST_READ_S. + if self.fc_entity is not None and ( + self._fc_read_mono is None + or now_mono - self._fc_read_mono >= FORECAST_READ_S + or now_mono < self._fc_read_mono): + self._fc_read_mono = now_mono + _reads.append(self._read_forecast()) + await asyncio.gather(*_reads) + if o.get("peak_forecast_entity"): + # SAFETY-118: read in its own unit like the PV path, one state + # read. No unit or a non-power unit: not used, as if the option + # were empty. + st = await self.hass.state(o["peak_forecast_entity"]) + self.peak_fc, _why = pv_read.watts(st) + # A missing reading leaves the unit-fault state as it was. + if pv_read.has_reading(st): + self._peak_fc_unit_log(pv_read.unit_fault(st)) + else: + self.peak_fc = None + self._peak_fc_fault = None + + now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity") + if now_e and avg_e: + now_p = await self.hass.number(now_e) + avg_p = await self.hass.number(avg_e) + self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p) + self._price_gated = True + self.tariff_words = "Prices from Home Assistant entities" + elif uses_chain(o): + # PRICE-01. A provider with fallbacks gives the maintenance gate + # its own prices: the current 15-minute price against today's + # average, as the Home Assistant pair did. No I/O here: the series + # is the one the background refresh stored. + # (DECISIONS/MAIN-orchestration-archive.md) + self._provider_gate(datetime.now(timezone.utc)) + else: + # TARIFF-03: 9.2 makes a static day/night tariff a first-class + # product and its EMS handling TIME-WINDOW SHIFTING; the one + # shiftable load is the maintenance cycle's cheap-window grid + # top-up. Synchronous and I/O-free on purpose (the provider-backed + # plan is the background task's); validate.py refuses + # `static_profile` with the price entities, so the two cannot both + # be set. + _now = datetime.now(timezone.utc) + self.cheap, self.tariff_words = static_status(o, _now) + self._price_gated = self._static_gated(o, _now) + + # TARIFF-11: the provider plan reaches the *Tariff* row here, the only + # place the two sources meet. `fresh_tariff_plan()` is an attribute + # read and an age comparison against TARIFF_MAX_AGE_S: still no fetch + # on this path. + _tick = datetime.now(timezone.utc) + _standing = self.fresh_tariff_plan(_tick) + self.tariff_words = tariff_plan_row( + _standing, self.tariff_words, + age_s=(None if _standing is None + else (_tick - self.tariff_at).total_seconds()), + horizon_h=float(price_option(o, "price_horizon_h")), + provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S) + + # UX-11: the red meter row says "the add-on log shows why", so a quiet + # meter gets one WARNING per episode (at most one per + # METER_QUIET_REPEAT_S; the rest DEBUG). Log only: no notification, + # decides nothing. + if self.p1_enabled: + if (self.p1.stale and not self._meter_quiet_logged + and self.p1.published_age_s > self.p1.max_age_s): + self._meter_quiet_logged = True + now = _mono() + self._meter_quiet_loud = ( + self._meter_quiet_warned is None + or now - self._meter_quiet_warned >= METER_QUIET_REPEAT_S) + if self._meter_quiet_loud: + self._meter_quiet_warned = now + _LOG.warning("No meter reading for over %s, so control " + "waits and then sets the battery to 0 W.", + messages.seconds(self.p1.max_age_s)) + else: + _LOG.debug("No meter reading for over %s again.", + messages.seconds(self.p1.max_age_s)) + elif not self.p1.stale and self._meter_quiet_logged: + self._meter_quiet_logged = False + if self._meter_quiet_loud: + _LOG.info("Meter readings are back.") + else: + _LOG.debug("Meter readings are back.") + + self.answer.watch(now_mono, [(inv.name, inv.standing_w(), inv.batt) + for inv in self.inverters], # SAFETY-128 + sum(inv.standing_w() for inv in self.inverters), + self.grid) + if self.inputs_ok: + if self.inputs_bad_since is not None: + self.log_event("inputs recovered", + hint=messages.hint("inputs_back")) + self.inputs_bad_since = None + elif self.inputs_bad_since is None: + self.inputs_bad_since = datetime.now(timezone.utc) + + def _driver(self, inv): + """`inv`'s driver, wired to this Controller's Home Assistant on first use + (SMA-2: bound here, so a unit added later and a swapped `self.hass` are + followed). DECISIONS/SMA-2-driver-seam.md.""" + drv = inv.driver + if not drv.bound(): + drv.bind_getter(lambda: self.hass) + return drv + + async def _read_unit(self, inv, now_mono: float) -> None: + """One inverter's own SoC and battery power, from ITS OWN entities. + SAFETY-37: the POWER reading goes through the freshness gate and the + SoC does not, deliberately: `batt` feeds SAFETY-30's per-unit sign + check and the delivery check, which a frozen register would fool. + The standing command is asked of the unit, not recomputed.""" + # SMA-2: the driver reads SoC, then power, as this method always did. + # DECISIONS/SMA-2-driver-seam.md. + inv.soc, raw, _extra = await self._driver(inv).read() + was_stale = inv.batt_fresh.stale + was_releases = inv.batt_fresh.releases + inv.batt_fresh.submit(raw, inv.standing_w(), now_mono) + # `inv.batt` is the GATED value: a stale register is a MISSING input, + # so the site holds for `stale_input_s` then commands 0 W and keeps + # commanding (11.1 rule 1). + inv.batt = inv.batt_fresh.reading() + # SAFETY-97: learn this unit's idle reading, only while control runs + # and nothing says the unit is refusing, stale or faulty. + if inv.idle.observe( + inv.batt, inv.standing_w(), now_mono, + bool(self.auto) and inv.batt is not None + and not inv.batt_fresh.stale and not inv.refused + and not self.batt_refused and not inv.sign_wd.tripped): + inv.store_set("idle_w", round(inv.idle.value, 1)) + if inv.batt_fresh.stale and not was_stale: + self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason, + hint=messages.hint("battery_stuck", unit=inv.name)) + self._refusal_from_stale(inv, now_mono) + # SAFETY-56/57: the stale-reading line names two explanations once, + # in `%s`. The escape clause "clears itself when the register + # reports a different value" was false for the state this line + # announces (the 0 W it announces is what stops the value + # changing), so SAFETY-57 rewrote it and SAFETY-56 the diagnosis. + # DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md. + _LOG.error("%s: battery power reading stopped changing, set to " + "0 W until it changes, or %.0f s after the command " + "drops below %.0f W: %s.", + inv.name, inv.batt_fresh.bound.release_s, + inv.batt_fresh.bound.move_w, + messages.reason_code(inv.batt_fresh.reason)) + _LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason) + _LOG.debug("%s: the limits are unchanged for %.0f s while asked " + "to move: a command over %.0f W that moved over %.0f W.", + inv.name, + inv.batt_fresh.bound.unchanged_s, + inv.batt_fresh.bound.move_w, + inv.batt_fresh.bound.move_w) + elif inv.batt_fresh.releases > was_releases: + # SAFETY-57: reported, never silent: a latch that lets go on a + # timer and says nothing cannot be audited, and this one withdraws + # a SITE STOP. `release_note()` says nothing has been learned about + # the register. + self.log_event(f"STALE WITHDRAWN {inv.name}", + hint=messages.hint("battery_back", unit=inv.name)) + # Not "recovered": nothing is known about the reading yet, so the + # line gives the value it holds and the window it is rechecked over + # (SAFETY-57). + _LOG.warning("%s: the command stayed below %.0f W for %.0f s, so " + "battery power reading %+.0f W is used again and " + "rechecked over %.0f s.", inv.name, + inv.batt_fresh.bound.move_w, + inv.batt_fresh.bound.release_s, inv.batt_fresh.held, + inv.batt_fresh.bound.unchanged_s) + _LOG.debug("SAFETY-57: %s", inv.batt_fresh.release_note(inv.name)) + + # -- the cycle ----------------------------------------------------------- + def actuator_of(self, source: str, inv=None) -> str: + """Which actuator a claim from `source` is filed on this cycle. `inv` + is the roster entry the claim is ABOUT; left out it is the first + inverter (what every per-inverter claim meant at N=1). The + classification is not here: `arbiter.CLAIM_SCOPE` decides.""" + return actuator_for(source, (inv or self.inverters[0]).name, self.units) + + def live_units(self) -> tuple: + """Which inverters may be commanded THIS TICK, for the allocator. The + allocator DROPS what this does not list and the survivors carry the + whole site target, so it is reported (`Allocation.dropped`). + Answered per tick, never cached (a master goes stale). It answers + ONE question: can this controller put a number on this unit's wire. + Deliberately not "its sensors are missing". At a roster of ONE, + False is a 0 W site.""" + now = datetime.now(timezone.utc) + live = [] + for inv in self.inverters: + ok, why = inv.is_live(now) + if ok: + live.append(inv.name) + else: + # Level-logged at DEBUG only: `Allocation.dropped` is the + # operator-facing surface (edge-logged in cycle()); a line + # every second would flush the 40-row action log. + _LOG.debug("%s is not commandable this tick: %s", inv.name, why) + return tuple(live) + + def liveness_notes(self) -> list: + """Why each unit is or is not commandable, for the status page. + SAFETY-37's stale-reading line rides here too: a different fault, a + different remedy (a dropped unit is carried by the survivors; a + stale power register is a missing input).""" + now = datetime.now(timezone.utc) + return [n for n in + [x for inv in self.inverters + for x in (inv.link_note(now), inv.stale_note(), + inv.refusal_note())] + if n] diff --git a/goodwe_controller/app/ctl_status.py b/goodwe_controller/app/ctl_status.py new file mode 100644 index 0000000..74ad3f3 --- /dev/null +++ b/goodwe_controller/app/ctl_status.py @@ -0,0 +1,927 @@ +"""Controller methods that build the status page, rows and MQTT output. + +Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" + +import logging +import time +from datetime import datetime, timezone + +from . import arbitrage +from . import packmode +from .clamp import is_commandable +from . import dayview, homeview +from . import messages +from .freeze import rail_unreachable, rail_w +from .hass import OUTAGE +from .maintenance import IDLE +from .mqtt import ( + announced_units, optional_keys, parse_command, unit_slugs, unit_state_key, +) +from . import peakguard +from . import pv as pv_read +from . import shadow +from .price import brussels +from .validate import FIRMWARE_INGEST_VETO_S +from .rows import arbitrage_row + +_LOG = logging.getLogger("goodwe") + + +class StatusMixin: + async def mqtt_command(self, topic, payload, retained) -> None: + """OPT-11 P2: one command from a battery's HA switch or mode list, + through handle_action(). A retained command is never applied (the store + already holds the last choice). Never raises: MQTT must not reach + control. (DECISIONS/MAIN-orchestration-archive.md)""" + try: + slug, action, value, why = parse_command( + topic, payload, retained, packmode.MODES) + if why is None: + names = [inv.name for inv in self.inverters] + unit = dict(zip(unit_slugs(names), names)).get(slug) + result = await self.handle_action(action, unit=unit, + value=value) + if not (isinstance(result, dict) and "error" in result): + self.mqtt.publish(self.mode_values()) + return + why = result["error"] + else: + why = messages.pack_refused(why) + # Printable only: a topic is sender-chosen, so no log injection. + topic_s = "".join(c if c.isprintable() else "?" + for c in str(topic)[:128]) + _LOG.debug("Home Assistant command %s %s refused: %s", topic_s, + repr(payload)[:48], why) + if (topic_s, why) in self._mqtt_refused \ + or len(self._mqtt_refused) >= 32: + # Not logged again, but counted: 1, 10, 100... stay visible. + self._mqtt_refused_more += 1 + more = self._mqtt_refused_more + if str(more).rstrip("0") == "1": + _LOG.warning("Home Assistant commands still ignored: %d " + "more so far.", more) + return + self._mqtt_refused.add((topic_s, why)) + _LOG.warning("Home Assistant command on %s ignored: %s", topic_s, + why) + except Exception: # noqa: BLE001 + _LOG.exception("Home Assistant command could not be handled") + + @property + def why_words(self) -> str: + """The page's "Why" line. While control waits for a missing reading + the age is counted when the page asks (UX-10), not frozen at the age + the last cycle saw.""" + if self._why_waiting_since is not None: + return messages.why_waiting(max(0.0, ( + datetime.now(timezone.utc) + - self._why_waiting_since).total_seconds())) + return self._why_words + + @why_words.setter + def why_words(self, text: str) -> None: + self._why_words = text + self._why_waiting_since = None + + def publish(self) -> None: + # ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE: `battery` stays the SUM and + # `soc` the EMPTIEST pack (their `unique_id`s are on live dashboards); + # per-inverter entities are TEL-07's. + # (DECISIONS/MAIN-orchestration-archive.md) + values = { + "setpoint": self.target, + "grid": self.grid, + "battery": self.site_batt_w, + "soc": self.pack_floor_soc, + "phase": self.maint.phase, + # ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing + # one: "failsafe" is additive to a sensor already on every + # dashboard, and a P_SAFETY stop that did not reach it would be a + # site at 0 W reporting "running". + # (DECISIONS/MAIN-orchestration-archive.md) + "status": ("failsafe" if any(i.sign_wd.tripped + for i in self.inverters) + or (self.site_sign_wd.tripped + and len(self.inverters) > 1) + or self.circ_wd.tripped + # SAFETY-10: "shadow" is its own value and not a flavour + # of "stopped". ⚠️ BELOW `failsafe`: a latch that is up + # is up, and shadow does not clear it. + # (DECISIONS/MAIN-orchestration-archive.md) + else "shadow" if self.shadow + else "running" if self.auto else "stopped"), + # SAFETY-31. ⚠️ MEASURED, not the allocation record - see + # `site_circulating_w`. `publish()` drops None values, so a missing + # per-unit reading leaves the last figure standing rather than + # publishing a zero that would read as health. + # (DECISIONS/MAIN-orchestration-archive.md) + "circulation": (None if self.site_circulating_w is None + else round(self.site_circulating_w)), + # TEL-10. The loop's own three, written from the fields `cycle()` + # recorded and not re-derived (the page and the publisher run on + # their own tasks, and `self.target` has moved on). + # (DECISIONS/MAIN-orchestration-archive.md) + "loop_i": round(self.loop_i_w, 1), + "loop_lead": round(self.loop_i_lead, 1), + "loop_reason": self.loop_reason, + } + # TEL-11. ⚠️ THE KEY IS PRESENT WITH `None` WHEN THERE IS NO READING + # AND ABSENT WHEN THERE IS NO SOURCE: two states + # (`mqtt.ABSENT_AS_UNKNOWN` makes the first an `unknown`, a gap in the + # recorder). TEL-12: still ONE number, the SITE total, None unless + # EVERY entity answered. ⚠️ THE GATE IS `optional_keys()`, THE SAME + # EXPRESSION THE ANNOUNCEMENT USES. + # (DECISIONS/MAIN-orchestration-archive.md) + if "pv" in optional_keys(self.o): + values["pv"] = (None if self.pv_w is None + else round(self.pv_w, 1)) + # ⚠️ ONLY when P1 ingestion is running: the ESP32's stale-input + # watchdog subscribes to sensor.goodwe_p1_sample_age and forces layer-1 + # failsafe at max_age_s, so with meter_source off a published age would + # climb unbounded and pin every existing install at 0 W within 30 s. + # Publishing nothing leaves the entity non-existent (firmware + # has_state()). (DECISIONS/MAIN-orchestration-archive.md) + if self.p1_enabled: + # Recomputed here, once a second, on purpose - see P1Ingest. + values["p1_age"] = round(self.p1.published_age_s, 1) + # TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE: ⚠️ no `if + # len(self.inverters) > 1` here, deliberately: `announced_units()` + # answers [] at N=1, so what is announced and what is published come + # from one expression. ⚠️ The site rows do not move: `battery` stays + # the SUM and `soc` the EMPTIEST pack (live dashboards use those ids). + # (DECISIONS/MAIN-orchestration-archive.md) + names = announced_units([inv.name for inv in self.inverters]) + for inv, slug in zip(self.inverters, unit_slugs(names)): + values[unit_state_key(slug, "setpoint")] = inv.target + values[unit_state_key(slug, "battery")] = inv.batt + values[unit_state_key(slug, "soc")] = inv.soc + values[unit_state_key(slug, "sign_latch")] = ( + "tripped" if inv.sign_wd.tripped else "clear") + # OPT-11 P2: each battery's mode and switch; nothing while modes are off. + values.update(self.mode_values()) + self.mqtt.publish(values) + + # -- UI ------------------------------------------------------------------ + def checks(self, when: datetime | None = None) -> list: + """The status rows. `when` is the reference time for anything billed by + the calendar - today's month when it is None, which is what the live + page means. ⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17): a + suite that banks an August quarter went red at the September boundary. + (DECISIONS/MAIN-orchestration-archive.md)""" + o = self.o + out = [] + # SAFETY-61. First, so a stalled loop is the headline: the heartbeat is + # sent from this loop, so past layer 1's veto the inverter is at 0 W. + if self.loop_pass_mono is None: + out.append({"ok": True, "warn": False, "id": "controller", + "text": messages.row_controller_starting()}) + else: + _pass_age = time.monotonic() - self.loop_pass_mono + if _pass_age > FIRMWARE_INGEST_VETO_S: + out.append({"ok": False, "warn": False, "id": "controller", + "text": messages.row_controller_stalled( + _pass_age, FIRMWARE_INGEST_VETO_S)}) + else: + out.append({"ok": True, "warn": False, "id": "controller", + "text": messages.row_controller_ran(_pass_age)}) + # SAFETY-112. The headline unless control has stalled: while the + # Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or + # write gets through, and the rows below that need it follow from it. + if OUTAGE.waiting(): + out.insert(1 if out[0]["ok"] is False else 0, + {"ok": False, "warn": False, "id": "home_assistant", + "banner": messages.banner_ha_waiting(), + "text": messages.row_ha_waiting()}) + if self.p1_enabled: + age = self.p1.published_age_s + if (self.p1.stale and self.p1.age_s() is None + and age <= self.p1.max_age_s): + # UX-10: no reading yet since the start, still inside the + # limit. Amber, not red: it is not a fault yet, but control + # waits for it. Red at 0 s contradicted the limit the row + # prints. (DECISIONS/MAIN-orchestration-archive.md) + out.append({"ok": False, "warn": True, "id": "meter", + "text": messages.row_meter_waiting( + self.p1.max_age_s)}) + elif self.p1.stale: + # The last error is on the add-on log's WARNING line (p1.py). + out.append({"ok": False, "warn": False, "id": "meter", + "text": messages.row_meter_stale( + age, self.p1.max_age_s)}) + else: + # ⚠️ unchanged_s is REPORTED, never thresholded and never + # folded into the age - see HomeWizardLocalSource.unchanged_s + # for why (at the converged -10 W this controller aims for, a 1 + # Wh register needs ~6 minutes to move, so any limit + # false-trips at the exact operating point we target). + # (DECISIONS/MAIN-orchestration-archive.md) + unchanged = getattr(self.p1_source, "unchanged_s", None) + out.append({"ok": True, "warn": False, "id": "meter", + "text": messages.row_meter( + self.p1.net_w, age, self.p1.parse_errors, + unchanged)}) + # SAFETY-18. Reported in both states (a detector nobody can see is + # armed is not trusted when it fires). ok=False/warn=False so the + # banner goes red; it changes NOTHING about control (see + # check_meter_frozen()). (DECISIONS/MAIN-orchestration-archive.md) + if self.freeze_wd.active: + # The figures are in `freeze_wd.reason`, on the log and the + # event list (check_meter_frozen()). + out.append({"ok": False, "warn": False, "id": "meter_frozen", + "text": messages.row_meter_frozen()}) + elif getattr(self.p1_source, "unchanged_s", None) is None: + # Not "armed". Every transport but homewizard_local is invisible + # to this check, and saying "armed" there would be a lie the + # status page tells on behalf of a guard that cannot run. + out.append({"ok": False, "warn": True, "id": "freeze_check", + "text": messages.row_freeze_off("source")}) + elif rail_unreachable(self.site_max_w, + dev_min=self.site_dev_bound("dev_min"), + dev_max=self.site_dev_bound("dev_max")): + # SAFETY-20: the same row as the transport case above (the + # check cannot be evaluated here); only the missing term + # differs. (DECISIONS/MAIN-orchestration-archive.md) + _no_rating = (not self.site_max_w + or not is_commandable(self.site_max_w)) + out.append({"ok": False, "warn": True, "id": "freeze_check", + "text": messages.row_freeze_off( + "rating" if _no_rating else "range")}) + else: + # ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE + # USES, device range included. + # (DECISIONS/MAIN-orchestration-archive.md) + _rail = rail_w(self.site_max_w, + dev_min=self.site_dev_bound("dev_min"), + dev_max=self.site_dev_bound("dev_max")) + # And when the device set that number, SAY SO: the operator + # configured `max_w`. ⚠️ Either bound may be None on its own + # (`hass.limits()` reads min and max independently), so render + # what is there. (DECISIONS/MAIN-orchestration-archive.md) + out.append({"ok": True, "warn": False, "id": "freeze_check", + "text": messages.row_freeze_on( + self.freeze_wd.cfg.hold_s, _rail, + own_limit=_rail < rail_w(self.site_max_w))}) + + # TARIFF-01. The month's billed peak, and a DIFFERENT row when the + # store could not answer: "no peak yet" is normal on the 1st, + # "unreadable" is a broken instrument. + # (DECISIONS/MAIN-orchestration-archive.md) + _pk = self.peak.peak(when) + if _pk.is_unreadable: + out.append({"ok": False, "warn": True, "id": "month_peak", + "text": messages.row_month_peak_unreadable()}) + else: + # §9.1's "warning entity at 80 %", as a status row rather than + # a second entity - see the constructor on not doubling up. + _frac = self.peak.warning_frac(when) or 0.0 + _ok = _frac < 0.8 + out.append({"ok": _ok, "warn": not _ok, "id": "month_peak", + "text": (messages.row_month_peak_none() + if not _pk.is_observed else + messages.row_month_peak( + _pk.peak_w, + brussels(_pk.quarter_start), + _frac))}) + out.append({"ok": True, "warn": False, "id": "periods_measured", + "text": messages.row_periods_measured( + self.peak.recorded)}) + + # SAFETY-07. Three rows, not one, because "idle" and "cannot see" + # are opposite states and a page that spelled them the same would + # show a green line over a guard that is switched off. `None` is a + # fourth state on purpose: before the first cycle nothing has been + # concluded. (DECISIONS/MAIN-orchestration-archive.md) + _pg = self.peak_guard + if _pg is None: + out.append({"ok": True, "warn": True, "id": "peak_guard", + "text": messages.row_peak_guard_waiting()}) + elif _pg.saturated and not _pg.within_aim: + out.append({"ok": False, "warn": False, "id": "peak_guard", + "runs": True, + "text": messages.row_peak_saturated(_pg.ceiling_w)}) + elif _pg.floor_limited and ( + not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim) + or self._peak_floor_limited): + # SAFETY-24. NOT ok, like saturation (the money is being spent + # and a human is the only actuator left); a separate row + # because the remedy is opposite. ⚠️ SAFETY-25: the scope + # sentence (the floor binds the guard, not the loop) is part of + # the row, which carries its own short `banner`; shortening the + # ROW would weaken AC 3. + # (DECISIONS/MAIN-orchestration-archive.md) + out.append({"ok": False, "warn": False, "id": "peak_guard", + "runs": True, + "banner": messages.banner_peak_reserve(), + "text": messages.row_peak_reserve( + _pg.ceiling_w, soc_unread=_pg.soc is None, + floor_unknown=(_pg.soc is not None + and _pg.soc_floor is None), + packs=(_pg.held if len(self.inverters) > 1 + else ()), + others=(_pg.floor_w or 0.0) > 0.0)}) + elif _pg.blind: + out.append({"ok": False, "warn": True, "id": "peak_guard", + "text": messages.row_peak_guard_blind( + peakguard.blind_kind(_pg.blind))}) + elif self.peak_unmeasured_s > 0.0: + # SAFETY-90: the start of this quarter came before the first + # reading, and the guard counts it as high use. + out.append({"ok": False, "warn": True, "id": "peak_guard", + "text": messages.row_peak_guard_unmeasured()}) + elif _pg.engaged: + out.append({"ok": False, "warn": True, "id": "peak_guard", + "text": messages.row_peak_guard_on( + _pg.floor_w or 0.0, _pg.ceiling_w, + shadow=self.shadow)}) + elif _pg.projected_w is not None: + # SAFETY-125: "under" only when it is; over the limit at this + # rate, a grid charge is cut back to the quarter's room. + out.append({"ok": True, "warn": False, "id": "peak_guard", + "text": messages.row_peak_guard_idle( + _pg.projected_w, _pg.ceiling_w, + month_peak=(_pg.ceiling_from + == peakguard.CEILING_MONTH_PEAK), + cut=self.room_charge and not self.shadow)}) + else: + out.append({"ok": True, "warn": False, "id": "peak_guard", + "text": _pg.explain()}) + else: + # ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the + # point: SAFETY-07 is NOT COVERED on the legacy single-entity path + # (no telegrams), and a row that rendered only where the guard runs + # would leave the unprotected install showing nothing. + # (DECISIONS/MAIN-orchestration-archive.md) + out.append({"ok": False, "warn": True, "id": "peak_guard", + "text": messages.row_peak_guard_off()}) + + # ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row + # on a site with two packs is a page that shows one pack and calls it + # the site. (DECISIONS/MAIN-orchestration-archive.md) + rows = [] + for inv in self.inverters: + unit = None if len(self.inverters) == 1 else inv.name + rows.append((messages.LABEL_SOC, unit, inv.soc, + inv.driver.soc_source())) + rows.append((messages.LABEL_BATTERY, unit, inv.batt, + inv.driver.batt_source())) + if not self.p1_enabled: + # In P1 mode the check above replaces this one; leaving both in + # would report "no entity configured" for a meter_entity that is + # correctly unused, i.e. a permanent false NOT READY. + rows.insert(0, (messages.LABEL_GRID, None, self.grid, + o.get("meter_entity"))) + # UX-08: the rows name the setting, never the entity id (STYLE.md). + for label, unit, value, entity in rows: + if not entity: + out.append({"ok": False, "warn": False, "id": "sensor", + "text": messages.row_sensor_unset(label, unit)}) + elif value is None: + # ⚠️ SAFETY-56. THIS ROW ONCE ASSERTED A FALSE FACT ABOUT A + # NAMED ENTITY: `Freshness.reading()` returns None BY DESIGN + # while the gate withholds a numeric reading. ⚠️ FIXED BY + # SAYING LESS: the gate has its own surface (`stale_note()` in + # `liveness_notes()`). + # (DECISIONS/MAIN-orchestration-archive.md) + out.append({"ok": False, "warn": False, "id": "sensor", + "text": messages.row_sensor_unusable(label, unit)}) + else: + out.append({"ok": True, "warn": False, "id": "sensor", + "text": messages.row_sensor(label, value, unit)}) + + for inv in self.inverters: + unit = None if len(self.inverters) == 1 else inv.name + if not inv.driver.configured(): + out.append({"ok": False, "warn": False, "id": "control", + "text": messages.row_control_unset(unit)}) + elif inv.dev_max is None: + out.append({"ok": False, "warn": False, "id": "control", + "text": messages.row_control_missing(unit)}) + else: + out.append({"ok": True, "warn": False, "id": "control", + "text": messages.row_control(inv.dev_min, + inv.dev_max, unit)}) + + # DEV-02. Whether this controller can still put a number on each + # inverter: a dropped link that shows nothing on the page reads as a + # healthy site. Red only for a unit no longer live; the rest amber. The + # same notes, in the same order, as `liveness_notes()`. + # (DECISIONS/MAIN-orchestration-archive.md) + _now = datetime.now(timezone.utc) + for inv in self.inverters: + for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]), + (inv.stale_note(), False), + (inv.refusal_note(), False)): + if note: + out.append({"ok": False, "warn": not red, "text": note}) + if self.answer.late_seen: # SAFETY-128 + out.append({"ok": False, "warn": True, "id": "late", + "text": messages.row_readings_late( + self.answer.shown_s, self._late_zero)}) + + # SAFETY-03. Red while an over-rating clamp is engaged (amber for the + # integrator bound, SAFETY-40), and the engagement COUNT stays shown + # after it clears. Each count is shown under ITS alarm's name, never + # the two summed (UX-07/UX-08). + # (DECISIONS/MAIN-orchestration-archive.md) + _cw = self.clamp_wd + if _cw.active and _cw.bug: + out.append({"ok": False, "warn": False, "id": "power_too_high", + "banner": messages.banner_power_too_high(), + "text": messages.row_power_too_high( + _cw.facts, _cw.over_rating, shadow=self.shadow)}) + elif _cw.active: + # SAFETY-40: the integrator bound alone is amber, not red. + out.append({"ok": False, "warn": True, "id": "not_keeping_up", + "text": messages.row_not_keeping_up( + _cw.bound_episodes)}) + # No over-rating in THIS episode (`bug` would be set), so any count + # is from an earlier one: keep that software-fault evidence shown. + if _cw.over_rating: + out.append({"ok": False, "warn": True, "id": "power_too_high", + "text": messages.row_before( + messages.T_OUTPUT_CLAMP, _cw.over_rating)}) + else: + if _cw.over_rating: + out.append({"ok": False, "warn": True, "id": "power_too_high", + "text": messages.row_before( + messages.T_OUTPUT_CLAMP, _cw.over_rating)}) + if _cw.bound_episodes: + out.append({"ok": False, "warn": True, "id": "not_keeping_up", + "text": messages.row_before( + messages.T_LOOP_BOUND, _cw.bound_episodes)}) + + for inv in self.inverters: + if inv.last_write_ok is False: + out.append({"ok": False, "warn": False, "id": "write", + "text": messages.row_write_refused( + None if len(self.inverters) == 1 + else inv.name)}) + + # SAFETY-02. Reported in both states. ⚠️ ONE ROW PER INVERTER: a trip + # on B leaves A commanding. Since SAFETY-30 the row says what the unit + # was compared against (`sign_wd.against`, the string the trip sentence + # uses); at N=1 the wording is unchanged. + # (DECISIONS/MAIN-orchestration-archive.md) + for inv in self.inverters: + unit = None if len(self.inverters) == 1 else inv.name + # ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE: at N=1 the + # two sentences below are the ones that have always been on the + # page, word for word (AC 8); at N>=2 the noun is load-bearing, so + # the row does not send an operator to P1 for a fault visible only + # on one inverter's own power. + # (DECISIONS/MAIN-orchestration-archive.md) + if inv.sign_wd.tripped: + out.append({"ok": False, "warn": False, "id": "direction", + "text": messages.row_direction_trip(unit)}) + else: + held = inv.sign_wd.held_s(datetime.now(timezone.utc)) + out.append({"ok": True, "warn": False, "id": "direction", + "text": messages.row_direction_check( + held, inv.sign_wd.cfg.hold_s, unit)}) + + # SAFETY-30: the SITE row. ⚠️ ONLY AT N>=2, and the absence at N=1 is + # the honest report: with one inverter the site comparison and the row + # above are the same sentence about the same actuator. + # (DECISIONS/MAIN-orchestration-archive.md) + if len(self.inverters) > 1: + if self.site_sign_wd.tripped: + out.append({"ok": False, "warn": False, "id": "site_direction", + "text": messages.row_site_direction_trip()}) + else: + _sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc)) + out.append({"ok": True, "warn": False, "id": "site_direction", + "text": messages.row_site_direction_check( + _sheld, self.site_sign_wd.cfg.hold_s)}) + + # SAFETY-31. ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and + # the row says so: there is one circulating figure for the site, and + # naming an inverter would invite clearing "the other one". + # (DECISIONS/MAIN-orchestration-archive.md) + _circ = self.site_circulating_w + if self.circ_wd.tripped: + out.append({"ok": False, "warn": False, "id": "circulating", + "text": messages.row_circulating_trip()}) + elif len(self.inverters) <= 1: + # AC 5. At the shipped roster the quantity is identically zero for + # every value of every reading, so "armed" would be a claim to be + # watching something that cannot happen. Say what is true instead. + out.append({"ok": True, "warn": False, "id": "circulating", + "text": messages.row_one_inverter( + messages.CHECK_CIRCULATING)}) + else: + held = self.circ_wd.held_s(datetime.now(timezone.utc)) + out.append({"ok": True, "warn": False, "id": "circulating", + "text": messages.row_circulating_check( + _circ, held, self.circ_wd.cfg.hold_s)}) + + # SAFETY-42. ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE + # ONE PLACE THE "reports, does not act" DECISION IS VISIBLE: every + # other latched row means the site is at 0 W, this one means it is + # STILL RUNNING and quietly short by one unit's share; `ok: False` with + # `warn: True` is this list's caution. + # (DECISIONS/MAIN-orchestration-archive.md) + if len(self.inverters) > 1: + for inv in self.inverters: + if inv.delivery_wd.tripped: + out.append({"ok": False, "warn": True, "id": "delivery", + "text": messages.row_not_delivering(inv.name)}) + else: + _dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc)) + out.append({"ok": True, "warn": False, "id": "delivery", + "text": messages.row_delivery_check( + inv.name, _dheld, + inv.delivery_wd.cfg.hold_s)}) + else: + # AC 5: at one inverter this check has nothing to find, so it is + # not called "armed" (as `circulating power: n/a` above). + # (DECISIONS/MAIN-orchestration-archive.md) + out.append({"ok": True, "warn": False, "id": "delivery", + "text": messages.row_one_inverter("Delivery check")}) + + if not o.get("estop_fitted", False): + out.append({"ok": False, "warn": True, "id": "estop", + "text": messages.row_no_estop()}) + else: + out.append({"ok": True, "warn": False, "id": "estop", + "text": messages.row_estop_fitted()}) + + if not o.get("peak_forecast_entity"): + # ok=False + warn=True renders as a caution, not a pass. A check that + # is both is a check nobody reads. + out.append({"ok": False, "warn": True, "id": "peak_forecast", + "text": messages.row_no_peak_forecast()}) + elif getattr(self, "_peak_fc_fault", None) is not None: + out.append({"ok": False, "warn": True, "id": "peak_forecast", + "text": messages.row_peak_forecast_unit( + self._peak_fc_fault)}) + # OPT-17: a forecast summed from several entities names, by position, + # each member with no usable reading. A caution: control goes on. + gaps = getattr(self, "solar_fc_gaps", ()) + for day, ents in (("today", getattr(self, "fc_entities", ())), + ("tomorrow", + getattr(self, "fc_tomorrow_entities", ()))): + gone = [i for d, i, _e, _w in gaps if d == day] + if gone: + out.append({"ok": False, "warn": True, "id": "solar_forecast", + "text": messages.row_forecast_gap( + day, gone, len(ents))}) + # TEL-11. ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY (pure + # instrumentation: no warning spam). ⚠️ WHEN IT IS SET, A FAILED READ + # IS `ok=False, warn=True`, a CAUTION never `bad`: telemetry must not + # put a battery site into NOT READY. UX-08 replaced 0.11.0's sentences + # on purpose. (DECISIONS/MAIN-orchestration-archive.md) + roster = self.pv_roster + if roster: + # UX-08: sources are numbered in the order of the settings list, + # never named by entity id (STYLE.md). The per-source figures and + # units are what `pv.total()` returns; they stay in `pv_parts`. + flip = pv_read.inverted(self.o) + n_src = len(roster) + if self.pv_w is None and n_src == 1: + out.append({"ok": False, "warn": True, "id": "pv", + "text": messages.row_pv_none(self.pv_why)}) + elif self.pv_w is None: + # ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN + # THOSE WORDS - and which sources are missing, by position. + out.append({"ok": False, "warn": True, "id": "pv", + "text": messages.row_pv_partial( + [i + 1 for i, (_e, watt, _y) + in enumerate(self.pv_parts) if watt is None], + n_src)}) + else: + out.append({"ok": True, "warn": False, "id": "pv", + "text": messages.row_pv( + self.pv_w, n_src, + n_src == 1 and roster[0] in flip)}) + # ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s + # `entities()` makes `pv_entities` REPLACE `pv_entity`, which is + # the rule an operator upgrading from 0.11.0 is most likely to trip + # over. (DECISIONS/MAIN-orchestration-archive.md) + if pv_read.entity_is_ignored(self.o): + out.append({"ok": False, "warn": True, "id": "pv", + "text": messages.row_pv_single_ignored()}) + # TEL-14: a sign that looks reversed, per entity rather than on the + # total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3. + for i, (ent, watt, _y) in enumerate(self.pv_parts): + if watt is not None and watt < pv_read.NEGATIVE_W: + out.append({"ok": False, "warn": True, "id": "pv", + "text": messages.row_pv_negative( + i + 1, n_src, watt, ent in flip)}) + _inv_list = [str(x or "").strip() + for x in (self.o.get("pv_invert_entities") or [])] + for ent in pv_read.stray_inverts(self.o): + out.append({"ok": False, "warn": True, "id": "pv", + "text": messages.row_pv_stray( + _inv_list.index(ent) + 1)}) + return out + + def status_payload(self, lang: str = "en") -> dict: + checks = self.checks() + bad = [c for c in checks if not c["ok"] and not c["warn"]] + # SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while + # control goes on, so it neither reads "Not ready" nor holds the page. + runs = [c for c in bad if c.get("runs")] + bad = [c for c in bad if not c.get("runs")] + running_alarm = waiting = False + # SAFETY-10, AND IT IS FIRST IN THE CASCADE: "The battery is NOT being + # controlled" may never be displaced by another condition. ⚠️ AND IT + # CARRIES A STANDING LATCH RATHER THAN HIDING ONE: `shadow.banner()` + # names the latch and goes red for it, so nothing the cascade would + # have said is lost. (DECISIONS/MAIN-orchestration-archive.md) + _shadow = shadow.banner(self.o, control_started=self.auto, + latched=self.shadow_latches()) + if _shadow is not None: + level, banner = _shadow + elif self.circ_wd.tripped: + # ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision: both are + # latched P_SAFETY stops and the banner carries one sentence; this + # one goes first because it is the fault the operator has NO OTHER + # WAY of learning about (a sign disagreement is visible at the + # meter, circulating power nowhere). + # (DECISIONS/MAIN-orchestration-archive.md) + level, banner = "bad", messages.banner_circulating() + elif self.site_sign_wd.tripped and len(self.inverters) > 1: + # SAFETY-30's site half, ABOVE the per-unit rows below and below + # circulating power above: the banner carries one sentence, so the + # one that says the widest true thing. A site sign trip parks EVERY + # inverter and the fault is in what the site was asking for; naming + # a unit would send an operator to swap the wrong box. + # (DECISIONS/MAIN-orchestration-archive.md) + level, banner = "bad", messages.banner_site_direction() + elif any(i.sign_wd.tripped for i in self.inverters): + # Outranks "Not ready": nothing is missing, something went wrong. + # At N>=2 the unit was graded against its own power, so the banner + # says so (see checks()). (DECISIONS/MAIN-orchestration-archive.md) + level, banner = "bad", messages.banner_direction( + len(self.inverters) > 1) + elif bad: + # ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE: a row may + # say more than fits the headline (SAFETY-25's floor row). + # (DECISIONS/MAIN-orchestration-archive.md) + level, banner = "bad", messages.banner_not_ready(bad[0]) + # SAFETY-112: nothing to do but wait, so Home says that instead + # of "Needs you now". Only when it IS the headline: a safety stop + # above outranks it and still needs a person. + waiting = bad[0].get("id") == "home_assistant" + elif not self.auto: + level, banner = "warn", messages.banner_stopped() + elif runs: + level, running_alarm = "bad", True + banner = runs[0].get("banner") or messages.headline_bi( + runs[0]["text"]) + 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 + # (the site is STILL RUNNING, one unit short). ⚠️ BUT IT MUST + # DISPLACE "Running - holding grid at zero": that sentence is a + # claim the site cannot make while a unit is absent. It sits BELOW + # "Stopped". (DECISIONS/MAIN-orchestration-archive.md) + level, banner = "warn", messages.banner_not_delivering( + [i.name for i in self.inverters if i.delivery_wd.tripped]) + elif self.maint.phase != IDLE: + # ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE: at N=1 the + # string is what it has always been, character for character. + # (DECISIONS/MAIN-orchestration-archive.md) + level, banner = "warn", messages.banner_maintenance( + self.maint.phase, + self.maint_unit.name if len(self.inverters) > 1 else "") + else: + level, banner = "ok", messages.banner_running() + + def w(v): + return "—" if v is None else f"{v:,.0f} W".replace(",", " ") + + # ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. `None` means + # "never run, due now" and has to win over any date, so it is tested + # for rather than sorted with. At N=1 this is that pack's own date. + # (DECISIONS/MAIN-orchestration-archive.md) + _now = datetime.now(timezone.utc) + _dues = [m.next_due(_now) for m in self.maints] + _waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u] + next_due = None if any(d is None for d in _dues) else min(_dues) + rows = [ + ("Grid", w(self.grid)), + ("Battery", w(self.site_batt_w)), + # SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is + # not derivable from ("Grid 0 W, Battery 0 W, Circulating 6000 W" + # is the whole card in three rows). Only shown at N>=2: at one + # inverter it is identically zero. + # (DECISIONS/MAIN-orchestration-archive.md) + *(() if len(self.inverters) <= 1 + else ((("Circulating", w(self.site_circulating_w)),))), + # TEL-11, and ONLY when a source is configured - see `checks()`. + # `w(None)` renders "-", which is how this row says "no reading" + # rather than "0 W". (DECISIONS/MAIN-orchestration-archive.md) + *(() if not self.pv_roster else ((("Solar production", + w(self.pv_w)),))), + ("Battery level", "—" if self.pack_floor_soc is None + else f"{self.pack_floor_soc:g} %"), + ("Power request", w(self.target)), + # SAFETY-10, and it sits BETWEEN the two rows it reconciles. + # "Commanded 0 W" above and a "Why" below that explains a trade is + # the exact pair an operator would otherwise have to guess at; this + # row is what makes the two of them consistent, and it is rendered + # in EVERY state - including `off`, where it says the battery IS + # being controlled - because a surface whose ABSENCE is how you + # learn something is a surface you have to already know about. + ("Shadow mode", shadow.words( + self.o, control_started=self.auto, + would_w=self.shadow_would_w, + # SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE + # - the page runs on its own task and `self.peak_guard` has + # moved on. See `Controller.__init__`. + sized_from_meter=self.shadow_sized_from_meter, + reason=self.why_words)), + ("Why", self.why_words), + ("Maintenance phase", self.maint.phase if len(self.inverters) <= 1 + else f"{self.maint.phase} ({self.maint_unit.name})"), + # ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status + # page - where `_maint_deferred` is empty on every tick - carries + # exactly the rows it carried before. + *((("Maintenance waiting", "; ".join(f"{n}: {why}" + for n, why in self._maint_deferred)),) + if self._maint_deferred else ()), + # MAINT-13: only while a failed cycle waits, so a site with nothing + # failed carries exactly the rows it carried before. + *((("Maintenance paused", messages.row_maint_waits( + min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()), + ("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")), + ("Cheap window", "yes" if self.cheap else "no"), + ("Tariff", self.tariff_words), + # PRICE-01: only while no price covers now. + *((("Prices", messages.row_no_prices(*self._gap_args())),) + if self.price_gap else ()), + # FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, the difference between a + # 48-hour plan and a 12-hour one on a weekday morning. Its own row: + # "Tariff" says what the plan IS, this says what it could still be + # filled from if the feed is late again. + # (DECISIONS/MAIN-orchestration-archive.md) + ("Price history", self.price_history.words), + # OPT-04. The strategy's own sentence, not a re-derivation: every + # refusal it can make carries a reason a human can act on. ⚠️ + # TARIFF-11: `self.arb is None` is NOT "trading is off" (cycle() + # returns at the input hold first); `arbitrage_row()` reads the + # option, and `cycle_at`/`arb_at` make "no decision this cycle" an + # observation. (DECISIONS/MAIN-orchestration-archive.md) + ("Arbitrage", arbitrage_row( + on=arbitrage.enabled(self.o) or self.modes_on, + decision=self.arb, + decided_at=self.arb_at, cycle_at=self.cycle_at, + now=datetime.now(timezone.utc), why=self.why_words)), + ] + held = level == "bad" and not running_alarm + # UX-46. The page's language is chosen once, here, for the Home + # sentences, checks, rows that carry one and the banner; a plain str + # (no Dutch twin) stays as it is. + # (DECISIONS/MAIN-orchestration-archive.md) + checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks] + rows = [(k, messages.pick(v, lang)) for k, v in rows] + banner = messages.pick(banner, lang) + out = { + "banner": banner, "level": level, "rows": rows, "checks": checks, + "hint": self.hint, + # The action log itself, newest first: not shown on the page (UX-08 + # 2d) but readable in the `status` JSON, since several events are + # written nowhere else. (DECISIONS/MAIN-orchestration-archive.md) + "events": list(self.events), + } + # UX-13 P1: the Home screen's structured half. Read-only, and it may + # never cost the page its rows: a fault here is logged and the old + # fields are served alone (the page then shows the installer details). + try: + out.update(homeview.build(self, level=level, checks=checks, + lang=lang, held=held, waiting=waiting)) + except Exception: # noqa: BLE001 + # Once per process at ERROR: the page polls every 3 s. + if not getattr(self, "_home_failed", False): + self._home_failed = True + _LOG.exception("Home view could not be built, so the page " + "shows the plain status rows only") + else: + _LOG.debug("Home view failed again", exc_info=True) + out["home_error"] = True + return out + + def forecast_payload(self, lang: str = "en") -> dict: + """UX-54: the Forecast tab's data. Built on request, from what the + control cycle already holds; no Home Assistant call. Today's blocks are + Home's own, so `status_payload()` is built first and read.""" + try: + return dayview.build(self, self.status_payload(lang=lang), + datetime.now(timezone.utc), lang) + except Exception: # noqa: BLE001 + if not getattr(self, "_forecast_failed", False): + self._forecast_failed = True + _LOG.exception("The Forecast tab could not be built") + else: + _LOG.debug("The Forecast tab failed again", exc_info=True) + return {"ok": False} + + async def handle_action(self, what: str, *, unit=None, value=None): + now = datetime.now(timezone.utc) + if what in ("pack_use", "pack_mode", "pack_sell"): + return self._pack_action(what, unit, value, now) + if what == "auto_toggle": + self.auto = not self.auto + self.store.set("auto", self.auto) + self.log_event("control started" if self.auto else "control stopped", + hint=messages.hint("control_started" if self.auto + else "control_stopped")) + if self.auto: + # ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose: + # it clears on a deliberate human start, not on the condition + # going away (the condition ALWAYS goes away, because the trip + # commands 0 W). ⚠️ IT CLEARS EVERY INVERTER'S LATCH, AND SAYS + # SO PER INVERTER, each under its OWN key. + # (DECISIONS/MAIN-orchestration-archive.md) + for inv in self.inverters: + if not inv.sign_wd.tripped: + continue + inv.sign_wd.reset() + inv.store_set("sign_tripped", False) + inv.store_set("sign_reason", "") + self.log_event(f"sign-disagreement failsafe cleared by " + f"operator on {inv.name}", + hint=messages.hint("stop_cleared", + unit=inv.name)) + # ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, + # UNCONDITIONALLY: this is what makes `flat True + namespaced + # False` MEAN something, and why `_grade_store_migration()` can + # run on every start. Left behind, a flat key is a fossil that + # re-arms the failsafe on every restart. + # (DECISIONS/MAIN-orchestration-archive.md) + self.store.set("sign_tripped", False) + self.store.set("sign_reason", "") + # SAFETY-30's SITE latch, cleared by the SAME press for the + # same reason (the trip commands 0 W, so the condition goes + # away on its own). ⚠️ UNCONDITIONAL, NOT GUARDED ON THE + # ROSTER: an install reconfigured back to one inverter would + # otherwise keep `site_sign_tripped: true` for ever. + # (DECISIONS/MAIN-orchestration-archive.md) + if self.site_sign_wd.tripped: + self.log_event("site sign-disagreement failsafe cleared " + "by operator", + hint=messages.hint("site_stop_cleared")) + self.site_sign_wd.reset() + self.store.set("site_sign_tripped", False) + self.store.set("site_sign_reason", "") + # SAFETY-31, cleared by the SAME press and for the same reason. + # ⚠️ AND `_circ_announced` GOES WITH IT: left True, the NEXT + # trip in this process would latch, stop the site and raise no + # notification. (DECISIONS/MAIN-orchestration-archive.md) + if self.circ_wd.tripped: + self.circ_wd.reset() + self.store.set("circ_tripped", False) + self.store.set("circ_reason", "") + self.log_event("circulating-power trip cleared by operator", + hint=messages.hint("site_stop_cleared")) + self._circ_announced = False + # SAFETY-42, cleared by the SAME press, for a reason that is + # NOT the one above: this latch commands nothing, so its + # evidence still stands and it could clear itself. It does not: + # a unit that stopped delivering and resumed is an INTERMITTENT + # fault. ⚠️ AND `_delivery_announced` GOES WITH IT, like + # `_circ_announced`. (DECISIONS/MAIN-orchestration-archive.md) + for inv in self.inverters: + if inv.delivery_wd.tripped: + self.log_event(f"not-delivering report on {inv.name} " + f"cleared by operator", + hint=messages.hint( + "not_delivering_cleared", + unit=inv.name)) + 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(). SMA-10: Stop gives hand-back units back. + # (DECISIONS/MAIN-orchestration-archive.md) + await self.command_site_release() + return {"auto": self.auto} + if what in ("maint_start", "maint_abort") and unit is not None: + # MAINT-11: the operator named the battery. A request with no unit + # falls through to the branches below, unchanged. + return await self._maint_unit_action(what, unit, now) + if what == "maint_start": + # ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS + # ONE LABEL: `_maint_button()` picks the pack that holds the + # maintenance slot, else the one longest without a cycle; at N=1 + # both are `maints[0]`. This is the bare request; a named battery + # is `_maint_unit_action()` (MAINT-11). + # (DECISIONS/MAIN-orchestration-archive.md) + _m = self._maint_button(now) + for msg in _m.force_start(now): + self.log_event(msg, hint=messages.hint("maintenance", + phase=_m.phase)) + return {"phase": self.maint.phase} + if what == "maint_abort": + # Aborts the pack that is actually cycling - `_maint_button()` again + # - and then zeroes the SITE, which is what it always did. + _m = self._maint_button(now) + for msg in _m.abort(now, "operator"): + self.log_event(msg, hint=messages.hint("maintenance", + phase=_m.phase)) + await self.command_site_zero() + return {"phase": self.maint.phase} + return None diff --git a/goodwe_controller/app/ctl_tasks.py b/goodwe_controller/app/ctl_tasks.py new file mode 100644 index 0000000..ea42c4d --- /dev/null +++ b/goodwe_controller/app/ctl_tasks.py @@ -0,0 +1,243 @@ +"""Controller methods that run as background tasks. + +Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" + +import asyncio +import contextlib +import logging +import time +from datetime import datetime, timedelta, timezone + +from . import messages +from .inverter import UNIT_A +from .price import brussels, price_option +from .static_tariff import resolve_tariff +from .validate import refuse, validate_startup_config, validate_unit_ranges +from .ctl_inputs import TARIFF_MAX_AGE_S, TARIFF_REFRESH_S + +_LOG = logging.getLogger("goodwe") + + +class TasksMixin: + def _grade_store_migration(self, store) -> None: + """DEV-02. Did SAFETY-02's latch actually survive the key rename? + Graded, not performed: a latch that silently fails to move is a + DISARMED SAFETY GUARD on an install that believes it is protected. The + pre-DEV-02 flat value is compared with what the roster came up with; a + disagreement re-arms the latch by hand (the fail-safe direction is ON). + It runs on EVERY start, not only the migrating one: gating on `rep.ran` + swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip + in the window, upgrade; performed on this site 2026-09-02). + DECISIONS/MAIN-orchestration.md.""" + rep = self.store_migration + if rep.ran and not rep.ok: + _LOG.error("The saved safety stops did not copy over cleanly, so " + "they are read again from the older saved state: %s.", + rep.explain()) + data = getattr(store, "data", {}) + legacy_tripped = bool(data.get("sign_tripped", False)) + if not legacy_tripped or not self.inverters: + return + # The question is "is any namespaced latch set THAT A MACHINE READS" + # (SAFETY-44), asked of the STORE's keys, not the roster's objects: + # re-arming A off a flat key that B's trip mirrored would latch a + # healthy inverter on every restart. Three cases: on the roster, stop; + # off-roster named `UNIT_A` (the broken migration's fingerprint), + # repair onto `inverters[0]`; off-roster, other name: stop unless a + # stranded `UNIT_A` latch is present. This guard is the ONLY repair for + # sites upgraded before the fix. Residual: a first unit really named + # `inverter-a`, latched and removed, is re-armed on `inverters[0]` + # (conservative). DECISIONS/MAIN-orchestration.md. + on_roster = {inv.name for inv in self.inverters} + stranded, on_roster_latched, other_off_roster = [], False, False + for k, v in data.items(): + if not (k.startswith("unit/") and k.endswith("/sign_tripped")): + continue + if not bool(v): + continue + # `unit_key()` builds `unit//`, so the unit is field 1: + # split through the writer's own spelling. + unit = k.split("/")[1] + if unit in on_roster: + on_roster_latched = True + elif unit == UNIT_A: + stranded.append(k) + else: + other_off_roster = True + if on_roster_latched or any(inv.sign_wd.tripped + for inv in self.inverters): + return + if other_off_roster and not stranded: + # A removed unit's own latch and no misdirected one: the flat key + # is that unit's mirror, so re-arming `inverters[0]` from it would + # latch a healthy pack. + return + if stranded: + _LOG.error( + "A saved safety stop belongs to %s, which is not in the " + "inverter list %s, so it is set again on the first inverter.", + ", ".join(sorted(stranded)), sorted(on_roster)) + first = self.inverters[0] + # The one state this method exists for. + reason = str(data.get("sign_reason", "") or "") + first.sign_wd.tripped = True + first.sign_wd.reason = reason or ( + "SIGN_DISAGREEMENT: latched before this version; the reason was " + "not carried across the per-inverter store migration") + first.store_set("sign_reason", first.sign_wd.reason) + first.store_set("sign_tripped", True) + _LOG.error( + "%s is set to 0 W again because an older saved safety stop was " + "found, so find out why before you start control: %s.", + first.name, messages.reason_code(first.sign_wd.reason)) + _LOG.debug("Evidence for %s: %s.", first.name, first.sign_wd.reason) + _LOG.debug( + "SAFETY-02 latch guard: the pre-DEV-02 flat key says the " + "sign-disagreement failsafe is latched and no inverter came up " + "latched. Either the per-inverter migration lost it, or an older " + "add-on wrote it during a rollback. Both would be a disarmed " + "guard, so the latch is re-armed on %s.", first.name) + self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - " + + first.sign_wd.reason, + hint=messages.hint("stop_restored", unit=first.name)) + + # -- tasks --------------------------------------------------------------- + # -- OPT-04: the background tariff refresh ------------------------------- + def fresh_tariff_plan(self, now): + """The stored plan if it is still current, else None. NEVER a stale + one. THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for + `loop_is_following()`'s reason: a check has to be able to assert on the + shipped expression rather than on a transcription that drifts. + (DECISIONS/MAIN-orchestration-archive.md)""" + if self.tariff_plan is None or self.tariff_at is None: + return None + if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S: + return None + return self.tariff_plan + + async def run_tariff_refresh(self) -> None: + """Resolve the tariff off the control path, forever. NEVER on the tick. + ⚠️ IT CATCHES EVERYTHING AND KEEPS GOING: raising out of here would + freeze the plan, looking current for ever (`fresh_tariff_plan()`'s age + bound is the backstop). ⚠️ A FAILURE DOES NOT CLEAR THE PLAN + (TARIFF-12): a degraded plan replaces a standing observation only once + it has aged out; a §11.4 fallback never replaces a fresh provider plan. + ⚠️ `tariff_at` DOES NOT MOVE ON A KEPT PLAN. + (DECISIONS/MAIN-orchestration-archive.md)""" + horizon = float(price_option(self.o, "price_horizon_h")) + while not self.stopping: + now = datetime.now(timezone.utc) + try: + plan = await resolve_tariff( + self.o, now, now + timedelta(hours=horizon), now=now, + history=self.price_history, watch=self.price_watch) + standing = self.fresh_tariff_plan(now) + # Three terms, and each of them is the only false one in some + # check: the feed answered (replace), nothing current stands + # (take the degradation - this is what makes the age bound + # govern), or what stands is itself a degradation (replace). + keep = (not plan.from_feed + and standing is not None and standing.from_feed) + if keep: + _LOG.warning( + "The price update was incomplete, so the plan from %s " + "is kept until it is %s old. The update held %s.", + messages.when(brussels(self.tariff_at)), + messages.duration(TARIFF_MAX_AGE_S), plan.words) + else: + self.tariff_plan, self.tariff_at = plan, now + _LOG.info("tariff refreshed: %s", plan.words) + except Exception as err: # noqa: BLE001 + # ⚠️ NOT THE OUTAGE PATH - see the docstring: `load_prices()` + # returns on every failure a site can have, so what reaches + # here is unexpected, and the plan is kept on the same terms. + # (DECISIONS/MAIN-orchestration-archive.md) + _LOG.warning( + "The price update failed (%s), so the plan from %s is kept " + "until it is %s old, and trading stops then.", + err, messages.when(brussels(self.tariff_at)), + messages.duration(TARIFF_MAX_AGE_S)) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self.stop_event.wait(), + timeout=TARIFF_REFRESH_S) + + async def run_control(self) -> None: + # ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4 (one + # read of one `setpoint_entity` left a second inverter's clamp, freeze + # rail and `achievable_w` on the first one's numbers). Concurrently: a + # slow ESPHome on one unit must not delay the start on the others. + # (DECISIONS/MAIN-orchestration-archive.md) + async def _limits(inv): + if inv.driver.configured(): + inv.dev_min, inv.dev_max = await self._driver(inv).limits() + await asyncio.gather(*(_limits(inv) for inv in self.inverters)) + entity = self.o.get("setpoint_entity", "") + # ⚠️ THIS USED TO WARN AND CARRY ON, the bug CFG-01 exists to remove: a + # max_w above the device's rating was silently clamped. amain() ran + # this gate already; it runs AGAIN because ESPHome is often not up at + # boot (limits None) and this is the first moment the real rating is + # known; a conflict refuses through the normal shutdown (0 W). ⚠️ THE + # `if entity:` THAT WRAPPED THIS BLOCK FAILED PERMISSIVE (CFG-04's + # hazard): a per-inverter config leaves the flat `setpoint_entity` + # blank, so the re-check was skipped; the per-unit rule now runs + # unconditionally. (DECISIONS/MAIN-orchestration-archive.md) + problems = [] + if entity: + _first = self.inverters[0] + problems += validate_startup_config(self.o, _first.dev_min, + _first.dev_max) + problems += validate_unit_ranges(self.inverters, skip_entity=entity) + if problems: + refuse(problems) + self.refused = problems + self.log_event("REFUSED: " + problems[0], + hint=messages.hint("refused")) + # Signal and return WITHOUT entering the loop: amain() runs the + # wind-down (0 W, or a hand-back: SMA-10, see + # DECISIONS/SMA-10-two-idle-states.md) and exits with code 78. + self.stop_event.set() + return + + last_grid = object() + heartbeat = float(self.o.get("heartbeat_s", 10)) + # ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not + # a second scheduler: every cycle resets it, so on a healthy meter it + # never elapses and the per-cycle gain is unchanged. It exists because + # a meter that repeats a value would run NO cycles, and the `inputs + # missing -> 0 W` claim lives inside cycle(). NOT a freeze detector. ⚠️ + # THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN: it sits under + # validate.FIRMWARE_INGEST_VETO_S (30 s) and `meter_max_age_s` and + # moves with the firmware's veto; the schema minimum of 5 s is the + # reference meter's interval and deserves a second look. + # (DECISIONS/MAIN-orchestration-archive.md) + floor = float(self.o.get("cycle_floor_s", 15)) + last_cycle = None + while not self.stopping: + await self.read_inputs() + now_mono = asyncio.get_running_loop().time() + # A cycle is one meter update, exactly as on the reference install - + # or the floor elapsing, whichever comes first. + overdue = last_cycle is None or (now_mono - last_cycle) >= floor + if self.grid != last_grid or overdue: + last_grid = self.grid + last_cycle = now_mono + # ⚠️ The FULL cycle, not a degraded one: in P1 mode past + # `meter_max_age_s` (30 s) p1.net_w is None ("inputs missing"). + # ⚠️ TRANSPORT-CONDITIONAL: with `meter_source: off`, + # read_inputs() has NO age gate, so a quiet entity reads as + # current; the floor does not make it fresh. The integrator + # advances per CYCLE, so bounding the gap bounds the + # accumulation. (DECISIONS/MAIN-orchestration-archive.md) + await self.cycle() + # ⚠️ The heartbeat is not an optimisation: the ESP32 zeroes the + # inverter after ~30 s of silence, and refreshing the SAME value + # proves we are alive. ⚠️ ONE DEADLINE PER INVERTER, refreshed + # concurrently, each bounded by WRITE_BUDGET_S (budget plus the + # largest `heartbeat_s` stays under layer 1's veto: graded); each + # unit refreshes its own `inv.target`, never the site target. + # (DECISIONS/MAIN-orchestration-archive.md) + await self.send_heartbeats(asyncio.get_running_loop().time(), + heartbeat) + self.publish() + self.loop_pass_mono = time.monotonic() + await asyncio.sleep(1) diff --git a/goodwe_controller/app/ctl_trade.py b/goodwe_controller/app/ctl_trade.py new file mode 100644 index 0000000..2d8e40a --- /dev/null +++ b/goodwe_controller/app/ctl_trade.py @@ -0,0 +1,1237 @@ +"""Controller methods for trading: mode runs, peak floors, pack actions. + +Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" + +import dataclasses +import logging +from datetime import datetime, timedelta, timezone + +from . import arbitrage +from .arbiter import ( + ACTUATOR_SITE, Claim, P_MAINTENANCE, P_MAINT_SHAPE, P_PEAK, SET, + pre_resolve, +) +from . import allocator +from . import dayplan +from . import modetrade +from . import packmode +from . import priceruns +from . import solarfc +from . import control as _ctl +from . import messages +from .maintenance import CHARGE, DRAIN, HOLD, IDLE, MaintConfig, POWER_MOVING +from .mqtt import MODE_LABELS, USE_OFF, USE_ON, unit_slugs, unit_state_key +from . import peakguard +from . import packshare +from .price import Absence +from .price import required_spread as price_required_spread +from .price import uncertainty_of as price_uncertainty_of +from .price_chain import gate_for +from .static_tariff import static_gated +from .ctl_inputs import TARIFF_MAX_AGE_S + +_LOG = logging.getLogger("goodwe") + + +# OPT-11 P3: how long the last cheap run stays the price stored energy is +# valued at: the rest of that day. +REF_CHEAP_KEEP = timedelta(hours=12) + + +# OPT-15: the longest a run edge holds the loop at E waiting for the meter to +# show the step (control.PLANT_SETTLE_S). +EDGE_HOLD_S = _ctl.PLANT_SETTLE_S + + +# OPT-11 P4: store key of the house profile, and how often the forecast sensors +# are read (hourly at most). +HOUSE_PROFILE_KEY = "house_profile" + + +# OPT-34: the level of a night grid charge that has started, so it does not +# drop when the forecast goes (`Controller._latch_charge()`). +CHARGE_LATCH_KEY = "night_charge_latch" + + +# OPT-34: a latch outlives its last cheap run by one price slot, since the next +# run is planned at the slot edge. +CHARGE_LATCH_GRACE_S = 900.0 + + +def _night_limit(born): + """OPT-34: a run that starts after this cannot carry the latch on: the + first bell start (`solarfc.BELL_FROM_H`, local) after `born`.""" + d0, d1 = solarfc._bounds(born) + bell = solarfc._bell_start(d0) + return bell if bell > born else solarfc._bell_start(d1) + + +def pack_energy_of(inv): + """OPT-09. ONE roster entry as the view `packshare` consumes. + One builder for two callers (`pack_energy` needs the roster, `cycle()` one + entry): written twice they would drift. A module function so inverter.py + need not import `packshare`.""" + return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor, + inv.capacity_kwh, inv.max_w) + + +# MAINT-13: a battery power reading at or below minus this many W is a pack +# that is taking charge. +MAINT_CHARGING_W = 50.0 + + +class TradeMixin: + def trade_power_w(self, floor_pct, ceiling_pct) -> tuple: + """OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED: + what the site can push AND STILL HAVE SOMEWHERE TO PUT. The bounds are + arbitrage's own (`arbitrage_soc_floor` / `_ceiling`), NOT the BMS + cut-out that `packshare.envelope()` uses (SAFETY-25): different + questions. It sizes the ask, it does not route it. A pack whose SoC did + not read counts on neither side. Neither side may be zero on a trading + tick (`arbitrage.decide()` refuses `IDLE_NO_SIZE`: a 0 W claim is a + park order). DECISIONS/MAIN-orchestration.md.""" + # The reduction lives in `packshare`; `test_packshare.py` part 9 calls + # the SAME function (a transcription in a check is a copy that drifts). + return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct) + + def peak_aim_w(self) -> float: + """SAFETY-79. `peak_cap_w` less `peak_margin_w`: what the forecast + checks aim at. Reports and the bill use the real cap.""" + return float(self.o.get("peak_cap_w", 3500)) - self.peak_margin_w + + @property + def pack_energy(self) -> list: + """OPT-09. Each pack as the FOUR numbers a split needs; the fourth is + `capacity_kwh`, which is why this is not `packs`. EVERY unit, live + or not, for `packs`'s reason (the allocator drops dead ones itself).""" + return [pack_energy_of(inv) for inv in self.inverters] + + def pack_weights(self, target_w): + """OPT-09. The comfort weights for a site target of `target_w`. A + method handed to `resolve()`, not a dict computed before it (energy + headroom depends on the target), and read fresh on every call, never + cached.""" + base = packshare.weights(self.pack_energy, target_w) + if not self.modes_on: + return base + # OPT-11: the solar order. While the site charges, Leftover solar + # batteries weigh 0, so Solar first fill first (comfort, like the rest + # here; permission is the mode claim). + w = packmode.charge_weights(base, self.pack_modes(), target_w) + # P3: in a price run, the batteries whose run it is carry it first. + tick = self.mode_tick + return w if tick is None else modetrade.weights(tick.roles, w, + target_w) + + def pack_mode(self, inv) -> "packmode.PackMode": + """OPT-11. This battery's on/off switch and mode, read from the store.""" + return packmode.read(getattr(self.store, "data", None) or {}, inv.name) + + def pack_modes(self) -> dict: + return {inv.name: self.pack_mode(inv) for inv in self.inverters} + + def loop_bound(self, claims): + """OPT-11. `(lo, hi)`: what the batteries may place if the loop + decides: the sum over live units of each unit's hard limits with its + mode applied (`allocator.loop_bound()`), from THIS cycle's unit + claims. None when no mode is filed (maintenance) or the range leaves + out 0 W (a fault `resolve()` reports).""" + if len(self.inverters) <= 1: + modes = [c for c in claims if c.source == "mode"] + if not modes: + return None + lo = max(c.lo for c in modes) + hi = min(c.hi for c in modes) + return (lo, hi) if lo <= 0.0 <= hi else None + bounds = {u: pre_resolve([c for c in claims if c.actuator == u]) + for u in self.units} + return allocator.loop_bound(bounds, live=self.live_units()) + + # --- OPT-11 P3: price runs, per battery ------------------------------- + def _mode_packs(self, maint_results=None) -> list: + """Each battery as `modetrade.Pack`, in roster order.""" + live = set(self.live_units()) + out = [] + for i, inv in enumerate(self.inverters): + pm = self.pack_mode(inv) + idle = (maint_results is None + or maint_results[i].phase == IDLE) + refused = (inv.refused if len(self.inverters) > 1 + else self.batt_refused) or 0 + # OPT-15: a charge refusal's probe (SAFETY-73, N>1) as it stood + # when last decided; its pin is filed after this, in cycle(). + probing = (len(self.inverters) > 1 and refused < 0 + and inv.probe_from is not None) + out.append(modetrade.Pack( + inv.name, pm.use, pm.mode, pm.sell and pm.can_sell, + float(inv.max_w), inv.soc, inv.capacity_kwh, + live=inv.name in live, idle=idle, refused=int(refused), + probing=probing)) + return out + + def _wx_track(self, now) -> None: + """TARIFF-24, once per cycle, before the plan: with a forecast read, + remember when the run now under way (cheap, give or sell) ends.""" + if self.solar_fc is None: + return + rp = self.mode_runs + ends = [] + if rp is not None and rp.on: + ends = [r.end for r in (rp.cheap, rp.sell) + if r is not None and r.holds(now)] + g = rp.give_at(now) + if g is not None: + ends.append(g.end) + self._wx_free_to = max(ends) if ends else None + + def weather_held(self, now) -> bool: + """TARIFF-24: no forecast now, but a run planned with one is under way: + it keeps its gap to its end (OPT-34: a night charge that keeps its + level too); the wider gap applies from the next run. Pure; Home asks + the same (`homeview.trade_verdict()`). + (DECISIONS/MAIN-orchestration-archive.md)""" + return self.solar_fc is None and ( + (self._wx_free_to is not None and now < self._wx_free_to) + or (self._charge_latch is not None + and now < self._charge_latch[0])) + + def _price_runs(self, now, packs, floor_pct, ceiling_pct): + """The kept run plan, rebuilt at most once per price slot (15 min) or + when the prices or a battery's choice change; a run under way is kept + to its end (`priceruns.keep_started()`). None with no fresh firm + prices: then no battery holds or trades. + (DECISIONS/MAIN-orchestration-archive.md)""" + plan = self.fresh_tariff_plan(now) + if plan is None or not plan.has_prices: + self.mode_runs, self._runs_key = None, None + self.day_plan = None + return None + slot = int(now.timestamp() // 900) + key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell) + for p in packs)) + held = self.weather_held(now) + if self.fc_entity is not None: + # P4: a forecast arriving or going rebuilds the plan at once (and + # so does the end of a run that held the gap, TARIFF-24). + key += (self.solar_fc is None, held) + if key != self._runs_key: + done_c = self._run_done.get("charge", (None, set()))[1] + done_s = self._run_done.get("sell", (None, set()))[1] + e = modetrade.energy(packs, floor_pct, ceiling_pct, done_c, + done_s) + required = price_required_spread( + arbitrage.threshold_eur_kwh(self.o), + price_uncertainty_of(plan.read.product) + | arbitrage.weather_uncertainty(True, self.solar_fc, held)) + ref = self._ref_cheap + if ref is not None and now - ref.end > REF_CHEAP_KEEP: + ref = self._ref_cheap = None + new = priceruns.plan( + plan.read.points, now, required_eur_kwh=required, + charge_kwh=e[0], charge_kw=e[1], sell_kwh=e[2], + sell_kw=e[3], + ref_eur_kwh=None if ref is None else ref.mean_eur_kwh) + if self.fc_entity is not None: + new = self._day_plan(now, packs, floor_pct, ceiling_pct, + new, plan, required, ref, done_c, e) + self.mode_runs = priceruns.keep_started(self.mode_runs, new, now) + self._runs_key = key + runs = self.mode_runs + # The cheapest cheap run under way lately is the price the stored + # energy was bought at; it stays the reference for REF_CHEAP_KEEP + # after it ends, unless a cheaper one comes. + ref = self._ref_cheap + if runs is not None and runs.cheap is not None \ + and runs.cheap.holds(now) and ( + ref is None or now - ref.end > REF_CHEAP_KEEP + or runs.cheap.mean_eur_kwh <= ref.mean_eur_kwh): + self._ref_cheap = runs.cheap + return runs + + def _day_plan(self, now, packs, floor_pct, ceiling_pct, runs, plan, + required, ref, done_c, e): + """OPT-11 P4: the day plan (`dayplan.plan()`) on the runs just made, + its levels held by the hysteresis (`dayplan.hold()`), then the runs + made again with each battery's shortfall as the charge. With no plan + (no or stale forecast, no house profile yet) the levels are dropped: P3 + option A. (DECISIONS/MAIN-orchestration-archive.md)""" + dp = dayplan.plan( + self.solar_fc, + None if self.house_profile is None + else self.house_profile.estimator(), + priceruns.keep_started(self.mode_runs, runs, now), now, packs, + trust_pct=self.solar_trust_pct, floor_pct=floor_pct, + ceiling_pct=ceiling_pct, aim_w=self.peak_aim_w(), + slots=priceruns.firm_slots(plan.read.points, now), + spread=required) + self.day_plan = dp + if dp is None: + self._plan_targets = None + return runs + self._plan_targets = dayplan.hold( + self._plan_targets, dp.targets, + {p.name: p.capacity_kwh for p in packs}) + e2 = modetrade.energy(packs, floor_pct, ceiling_pct, done_c, (), + targets=self._plan_targets) + return priceruns.plan( + plan.read.points, now, required_eur_kwh=required, + charge_kwh=e2[0], charge_kw=e2[1], sell_kwh=e[2], sell_kw=e[3], + ref_eur_kwh=None if ref is None else ref.mean_eur_kwh) + + def plan_targets(self): + """P4: each battery's planned level, or None (no day plan). Dropped + at once when the forecast goes (stale or missing), except the level + of a night charge that has started (OPT-34): it keeps that level.""" + if self.solar_fc is None: + lt = self._charge_latch + if lt is None or datetime.now(timezone.utc) >= lt[0]: + return None + return dict(lt[1]) + return self._plan_targets + + def _latch_charge(self, now, runs, targets, packs=None) -> None: + """OPT-34, once per cycle after `modetrade.decide()`: a started night + grid charge keeps its planned level when the forecast goes mid-charge + (`plan_targets()` gives the latched levels, `weather_held()` the narrow + price gap). The latch lasts through every cheap run plus one price slot + (`CHARGE_LATCH_GRACE_S`), is dropped when `now` passes its end or the + battery leaves its mode, and a charge that never started has none. + DECISIONS/OPT-34-the-night-charge-keeps-its-level.md""" + old = lt = self._charge_latch + tick = self.mode_tick + run = None if runs is None else runs.cheap + in_run = run is not None and run.holds(now) + grace = timedelta(seconds=CHARGE_LATCH_GRACE_S) + if lt is not None and now >= lt[0]: + lt = None + if lt is not None and packs is not None: + ok = {p.name for p in packs + if p.use and p.mode in packmode.PLAN_CHARGE_MODES} + lv = {n: v for n, v in lt[1].items() if n in ok} + lt = (lt[0], lv, lt[2]) if lv else None + # A run may carry the latch on only if it starts by the night's bell. + carry = in_run and (lt is None or run.start <= _night_limit(lt[2])) + if in_run and self.solar_fc is not None: + # Rebuilt from the plan: a battery already latched follows its + # level up or down whatever its role this tick (a fresh figure may + # lower it below the SoC), one that left the plan is dropped, and + # a new one comes in only while it charges from the grid. + tg = targets or {} + lv = {n: float(tg[n]) for n in (lt[1] if lt else {}) if n in tg} + if tick is not None and tick.action == modetrade.CHARGE: + lv.update({n: float(tg[n]) for n, r in tick.roles.items() + if r == modetrade.CHARGE and n in tg}) + if not lv: + lt = None + elif lt is None: + lt = (run.end + grace, lv, now) + else: + lt = (max(run.end + grace, lt[0]) if carry else lt[0], lv, + lt[2]) + elif lt is not None and carry: + lt = (max(lt[0], run.end + grace), lt[1], lt[2]) + if lt != old: + self._charge_latch = lt + self.store.set(CHARGE_LATCH_KEY, None if lt is None else { + "until": lt[0].isoformat(), "levels": lt[1], + "born": lt[2].isoformat()}) + + def _mode_roles(self, now, maint_results): + """OPT-11 P3: each battery's role and range this tick. Grid charging + and selling need a fresh plan that `TariffPlan.arbitrage()` allows with + the MEASURED peak guard (None refuses: nothing measures the quarter), + exactly the gate `arbitrage.decide()` has. + (DECISIONS/MAIN-orchestration-archive.md)""" + floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) + packs = self._mode_packs(maint_results) + self._wx_track(now) + runs = self._price_runs(now, packs, floor_pct, ceiling_pct) + guard = arbitrage.guard_state(self.peak_guard) + plan = self.fresh_tariff_plan(now) + charge_ok = sell_ok = False + trade_why = "not_covered" + + def allowed(active): + return plan.arbitrage( + peak_guard_active=active, + min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), + uncertainty=arbitrage.weather_uncertainty( + True, self.solar_fc, self.weather_held(now)), + now=now).match( + allowed=lambda _v: True, refused=lambda _v: False) + if guard is not None and plan is not None and runs is not None: + # A charge asks the guard (section 9.1 interlock); a sell only + # needs it to have measured the quarter, since discharge is never + # capped by it (a big discharge alone engages it on export). + sell_ok = allowed(False) + charge_ok = sell_ok and allowed(guard) + trade_why = (None if charge_ok else "peak" if sell_ok + else "not_worth") + self.mode_gate = None if charge_ok else trade_why + price_now = None + if plan is not None: + try: + pt = plan.read.match(series=lambda r: r.at(now), + no_data=lambda _r: None) + price_now = (None if pt is None or pt.provisional + else float(pt.eur_per_kwh)) + except Absence: + price_now = None + house = (None if self.grid is None or self.site_batt_w is None + else float(self.grid) + float(self.site_batt_w)) + if self.house_profile is not None and \ + self.house_profile.add(now, house): + self.store.set(HOUSE_PROFILE_KEY, self.house_profile.dump()) + targets = self.plan_targets() + if targets is None: + done_c = self._run_mark("charge", runs and runs.cheap, packs, + lambda p: p.soc is not None + and p.soc >= ceiling_pct) + else: + # P4: out of the run at its planned level. A level the + # hysteresis moved opens the run to it again (a new mark key). + def full(p): + to = modetrade.charge_to(p, ceiling_pct, targets) + return p.soc is not None and (to is None or p.soc >= to) + done_c = self._run_mark("charge", runs and runs.cheap, packs, + full, tuple(sorted(targets.items()))) + done_s = self._run_mark("sell", runs and runs.sell, packs, + lambda p: p.soc is not None + and p.soc <= floor_pct) + quarter = int(now.timestamp() // 900) + was = self.mode_tick is not None and \ + self.mode_tick.action == modetrade.CHARGE + self.mode_tick = modetrade.decide( + runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok, + trade_why=trade_why, price_now=price_now, + headroom_w=self.arb_headroom_w, floor_pct=floor_pct, + ceiling_pct=ceiling_pct, house_w=house, charging_now=was, + blocked=self._charge_blocked_q == quarter, + done_charge=done_c, done_sell=done_s, targets=targets, + draining=any(r.phase == DRAIN for r in maint_results or ())) + self._latch_charge(now, runs, targets, packs) + # A charge the guard or the headroom stopped stays off this period. + # OPT-15: only a MEASURED stop latches; the first unmeasured headroom + # in a quarter stops one tick, a second latches. + # (DECISIONS/MAIN-orchestration-archive.md) + if was and self.mode_tick.action != modetrade.CHARGE: + why = self.mode_tick.why + gap = why == "headroom" and self.arb_headroom_w is None + if why == "peak" or (why == "headroom" and not gap) or ( + gap and self._charge_gap_q == quarter): + self._charge_blocked_q = quarter + elif gap: + self._charge_gap_q = quarter + return self.mode_tick + + def _run_mark(self, kind, run, packs, out, extra=None) -> set: + """The batteries that left run `run` (full, or at the floor); they + stay out until it ends, so a level edge does not re-arm them. + `extra` (P4: the planned levels) is part of the run's key.""" + key = None if not run else (run.start, run.end) + if key is not None and extra is not None: + key += (extra,) + was_key, done = self._run_done.get(kind, (None, set())) + if key != was_key: + done = set() + if key is not None: + done |= {p.name for p in packs if out(p)} + self._run_done[kind] = (key, done) + return done + + def _mode_trade(self, claims, now) -> bool: + """OPT-11 P3: file the site trade the roles ask for, sized inside the + modes and the hard limits and, for a charge, the billed quarter's + headroom (SAFETY-45, aim cap - margin, SAFETY-79), so no battery that + its mode or a refusal holds is asked for watts it may not give. + (DECISIONS/MAIN-orchestration-archive.md)""" + tick = self.mode_tick + if tick is None: + return False + if tick.action == modetrade.CHARGE: + # OPT-20: the ask carries PROBE_W for exactly the probes whose pin + # is filed this tick (`decide()` ran before the pin, so they can + # differ by one cycle; left alone the allocator moves the pin's + # watts to another battery). + # (DECISIONS/MAIN-orchestration-archive.md) + pins = _ctl.PROBE_W * sum( + 1 for c in claims if c.source == "refused" + and c.kind == "set" and c.value < 0) + if pins != tick.probe_w: + tick = self.mode_tick = dataclasses.replace( + tick, ask_w=tick.ask_w + tick.probe_w - pins, + probe_w=pins) + size = modetrade.size(tick, self.loop_bound(claims) + if tick.action else None, self.arb_headroom_w) + runs = self.mode_runs + if tick.action and (size == 0.0 or runs is None): + # Nothing the batteries may place (a refusal, a hard limit): no + # trade, and the roles say so rather than "charging". + back = {modetrade.CHARGE: modetrade.HOLD, + modetrade.REST: modetrade.HOUSE, + modetrade.SELL: modetrade.GIVE} + tick = self.mode_tick = modetrade.Tick( + tick.ranges, {n: back.get(r, r) for n, r in tick.roles.items()}, + None, 0.0, "size") + if size == 0.0 or runs is None: + reason = ("battery modes: no price run now" if tick.why is None + else f"battery modes: no trade ({tick.why})") + self.arb = arbitrage.ArbitrageDecision( + action=None, power_w=None, reason=reason, + spread_eur_kwh=None, required_eur_kwh=None, + price_now_eur_kwh=None) + else: + charge = tick.action == modetrade.CHARGE + run = runs.cheap if charge else runs.sell + who = ", ".join(n for n, r in sorted(tick.roles.items()) + if r == tick.action) + act = arbitrage.CHARGE if charge else arbitrage.DISCHARGE + self.arb = arbitrage.ArbitrageDecision( + action=act, power_w=size, + reason=(f"{act}: battery modes, " + f"{'cheap' if charge else 'sell'} run " + f"{run.start.isoformat()} to {run.end.isoformat()} " + f"at {run.mean_eur_kwh:.4f} EUR/kWh; " + f"{abs(size):.0f} W on {who}"), + spread_eur_kwh=None, required_eur_kwh=runs.level_eur_kwh, + price_now_eur_kwh=None) + claims.append(arbitrage.claim(self.arb)) + if self.arb.action != self._arb_action: + self._arb_action = self.arb.action + self.log_event(self.arb.explain(), hint=messages.hint( + "trade_charge" if self.arb.action == arbitrage.CHARGE else + "trade_give" if self.arb.action == arbitrage.DISCHARGE + else "trade_off")) + return self.arb.action is not None + + def _reseed_run_edge(self, claims, now) -> None: + """OPT-11 P3: a mode trade just ended, so the loop takes over from a + command no loop chose: it starts at the house's own point E, not at the + trade's watts, and OPT-15 HOLDS it at E (`_edge_grid()`) until the + meter shows the step or `EDGE_HOLD_S` passes. + DECISIONS/OPT-11-battery-modes.md""" + self._edge = None + batt = self.site_batt_w + if batt is None or self.grid is None: + return + want = float(batt) + float(self.grid) - self.tuning.target_grid_w + bound = self.loop_bound(claims) + if bound is not None: + want = max(bound[0], min(bound[1], want)) + want = float(round(max(-self.site_max_w, min(self.site_max_w, want)))) + self.target = self.i_w = want + if want != float(batt): + self._edge = (want, 1.0 if want > float(batt) else -1.0, + now + timedelta(seconds=EDGE_HOLD_S), float(batt)) + + def _edge_grid(self, end, now): + """OPT-15: the grid reading the loop acts on this tick. While a run + edge is held, the target itself (no error, the command stays at E); the + hold ends when the meter reaches the target, on `end`, or after + `EDGE_HOLD_S`. (DECISIONS/MAIN-orchestration-archive.md)""" + e = self._edge + if e is None: + return self.grid + held_w, sign, until, last_b = e + tg, db = self.tuning.target_grid_w, self.tuning.deadband_w + b = self.site_batt_w + moving = (b is not None and sign * (float(b) - last_b) >= db + and sign * (held_w - float(b)) >= db + and now < until + timedelta(seconds=EDGE_HOLD_S)) + if (end or (now >= until and not moving) + or sign * (float(self.grid) - tg) < db): + self._edge = None + return self.grid + self._edge = (held_w, sign, until, last_b if b is None else float(b)) + self.i_w = held_w + return tg + + def _charge_track(self, claims, maint_results) -> None: + """SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can + no longer be placed (stale link, refusal). Kept until the loop runs. + SAFETY-95: a drain likewise; its refusal (SAFETY-73) is read from the + battery (+1 = will not give). (DECISIONS/MAIN-orchestration-archive.md)""" + if len(self.inverters) < 2: + return + live = set(self.live_units()) + now = {CHARGE: set(), DRAIN: set()} + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if res.phase in now and u in live and not pre_resolve( + [c for c in claims if c.actuator == u]).contradiction \ + and not (res.phase == DRAIN and (inv.refused or 0) > 0): + now[res.phase].add(inv.name) + self._charge_ended |= self._charging_was - now[CHARGE] + self._charging_was = frozenset(now[CHARGE]) + self._drain_ended |= self._draining_was - now[DRAIN] + self._draining_was = frozenset(now[DRAIN]) + + def _reseed_charge_end(self, maint_results) -> None: + """SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop + starts from what the others stood at plus that battery's new command + (its HOLD pin, or its last discharge if it is free; a charge stops); + else the charge's watts land on the others (B 0 -> -1600 W in one + cycle). DECISIONS/SAFETY-93-charge-end.md""" + ended, self._charge_ended = self._charge_ended, frozenset() + if len(self.inverters) < 2 or not ended: + return + for inv, res in zip(self.inverters, maint_results): + if inv.name not in ended: + continue + now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint + else max(float(inv.target), 0.0)) + if not inv.is_live(datetime.now(timezone.utc))[0]: + now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W + self.target += now_w - float(inv.target) + # SAFETY-96: it settles from its charge to that command. + self._loop_start[inv.name] = now_w + self._settle[inv.name] = self._now_mono + self.i_w = self.target + + def _reseed_drain_end(self, maint_results, live) -> None: + """SAFETY-95, N>=2: when a battery's maintenance drain ends, its drain + leaves the loop's start point, as a charge does (SAFETY-93): the start + is what the others stood at plus its new command (its pin, else 0 W), + never above last cycle's start; else the drain's floor lands on the + others (B 0 -> +1600 W, exporting). DECISIONS/SAFETY-95-drain-end.md""" + ended, self._drain_ended = self._drain_ended, frozenset() + if len(self.inverters) < 2 or not ended: + return + hi = self.target + for inv, res in zip(self.inverters, maint_results): + if inv.name not in ended: + continue + now_w = (float(res.setpoint_w or 0.0) + if res.owns_setpoint and res.phase != DRAIN + else min(float(inv.target), 0.0)) + if inv.name not in live: + now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W + else: + self._drain_tail.add(inv.name) + self.target += now_w - float(inv.target) + self._loop_start[inv.name] = now_w + self._settle[inv.name] = self._now_mono + lo, hi = sorted((self.target, hi)) + # A dropped battery's power is the meter's, like the house's. + here = float(self.grid) - self.tuning.target_grid_w + sum( + float(inv.batt) for inv in self.inverters if inv.name in live) + self.target = self.i_w = min(hi, max(lo, here)) + + def _drain_grid_w(self, live) -> float: + """SAFETY-95: the grid the loop regulates. While a battery settles from + its ended drain (`_held_out_w`, at most PLANT_SETTLE_DOWN_S) the + batteries count at the site command, so the loop does not chase the + drain's tail. SAFETY-103: a reading can be up to a register period (10 + s) older than the meter, so the house point is held and moved only as + far as the meter proves. + DECISIONS/SAFETY-103-old-readings-after-a-drain.md""" + grid = float(self.grid) + self._drain_tail &= set(self._settle) & set(live) + if not self._drain_tail: + self._drain_house = None + return grid + aim = self.tuning.target_grid_w + lo = hi = grid - aim + for inv in self.inverters: + if inv.name not in live: + continue + r = float(inv.batt) + span = self._read_span.get(inv.name) + if (span is None or span[2] != inv.batt_fresh.changed_mono + or span[2] == self._now_mono): + span = (r, r) + # A battery that refuses one way (SAFETY-73) is not heading + # that way, whatever it was asked: that side is its reading. + if (inv.refused or 0) > 0: + span = (min(span[0], r), r) + elif (inv.refused or 0) < 0: + span = (r, max(span[1], r)) + lo, hi = lo + span[0], hi + span[1] + held = (float(self.target) if self._drain_house is None + else self._drain_house) + self._drain_house = min(hi, max(lo, held)) + return self._drain_house - float(self.target) + aim + + def _track_reads(self) -> None: + """SAFETY-103, N>=2: per battery, the range its power can be in now: + its last changed reading and every command on its wire since (a + lagging battery moves from one toward the others, never beyond).""" + if len(self.inverters) < 2: + return + for inv in self.inverters: + if inv.batt is None: + continue + cmd, since = inv.standing_w(), inv.batt_fresh.changed_mono + lo, hi, was = self._read_span.get(inv.name, (0.0, 0.0, None)) + if was is None or since != was: + lo = hi = float(inv.batt) + self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since) + + def _reseed_drop(self, live) -> None: + """SAFETY-96, N>=2: a battery that drops out of the split (stale link) + leaves the loop's start point, so the others take over at one slew step + a cycle instead of taking its share at once. + DECISIONS/SAFETY-96-dropped-battery.md""" + was, self._live_was = self._live_was, frozenset(live) + if len(self.inverters) < 2 or was is None or was == live: + return + for inv in self.inverters: + if inv.name in self._charge_ended | self._drain_ended: + continue # SAFETY-93/95's edge: `_reseed_*_end()` + if inv.name in was and inv.name not in live: + now_w = 0.0 + elif inv.name in live and inv.name not in was: + now_w = float(inv.batt) + # The split is made again, so every battery's command steps. + self._settle.update( + {u: self._now_mono for u in live}) + else: + continue + self.target += now_w - float(inv.target) + self._loop_start[inv.name] = now_w + self.i_w = self.target + + def _held_out_w(self, live) -> float: + """SAFETY-96, N>=2: what to add to the site sum so that a battery the + loop cannot move right now counts at its command, not at its reading. + Its real power still reaches the loop through the meter: neither lost + nor counted twice. DECISIONS/SAFETY-96-dropped-battery.md""" + if len(self.inverters) < 2: + return 0.0 + out = 0.0 + for inv in self.inverters: + cmd = self._loop_start.get(inv.name, float(inv.target)) + since = self._settle.get(inv.name) + age = (None if since is None or self._now_mono is None + else self._now_mono - since) + if since is not None and ( + age is None or age >= _ctl.PLANT_SETTLE_DOWN_S + or (age > 0 and abs(float(inv.batt) - cmd) + < self.tuning.deadband_w)): + del self._settle[inv.name] + if (inv.name not in live or inv.rejected_since is not None + or inv.name in self._settle): + out += cmd - float(inv.batt) + return out + + def _mode_fault_log(self, resolution) -> None: + """OPT-11: say once, per battery, that its mode was dropped as a fault. + A mode range without 0 W would force a direction, which no mode may do, + so it is never applied (allocator.unit_interval()); main.py never + builds one. (DECISIONS/MAIN-orchestration-archive.md)""" + alloc = resolution.allocation + now = {u for u, _ in getattr(alloc, "mode_faults", ())} + if resolution.mode_fault is not None and self.inverters: + now.add(self.inverters[0].name) + for inv in self.inverters: + if inv.name in now and inv.name not in self._mode_faults: + _LOG.error("Battery mode ignored for %s. Software fault " + "MODE_RANGE.", inv.name) + self.log_event(f"battery mode ignored on {inv.name}, a " + f"software fault", hint=messages.hint( + "mode_fault", unit=inv.name)) + self._mode_faults = now + + def _pack_action(self, what, unit, value, now) -> dict: + """OPT-11: switch one battery on or off, set its mode, or (P3) turn its + selling on or off. Store state, not an option: it applies next cycle + with no restart. Switching a battery off also stops its maintenance + cycle. (DECISIONS/MAIN-orchestration-archive.md)""" + if not self.modes_on: + return {"error": messages.pack_refused("off")} + inv = next((i for i in self.inverters if i.name == unit), None) + if inv is None: + return {"error": messages.pack_refused("unit")} + if what == "pack_use": + if not isinstance(value, bool): + return {"error": messages.pack_refused("value")} + inv.store_set(packmode.USE_KEY, value) + self.log_event(f"{inv.name} switched {'on' if value else 'off'} " + f"by operator", + hint=messages.hint("pack_on" if value + else "pack_off", unit=inv.name)) + if not value: + self._maint_stop_off(self.maints[self.inverters.index(inv)], + now) + elif what == "pack_sell": + if not packmode.SELLING_ENABLED: + return {"error": messages.pack_refused("sell_off")} + if not isinstance(value, bool): + return {"error": messages.pack_refused("value")} + inv.store_set(packmode.SELL_KEY, value) + self.log_event(f"{inv.name} selling switched " + f"{'on' if value else 'off'} by operator", + hint=messages.hint("pack_sell", unit=inv.name)) + else: + if value not in packmode.MODES: + return {"error": messages.pack_refused("value")} + inv.store_set(packmode.MODE_KEY, value) + self.log_event(f"{inv.name} mode set to {value.replace('_', ' ')} " + f"by operator", + hint=messages.hint("pack_mode", unit=inv.name)) + pm = self.pack_mode(inv) + out = {"unit": inv.name, "use": pm.use, "mode": pm.mode} + if what == "pack_sell": + out["sell"] = pm.sell + return out + + async def _maint_unit_action(self, what, unit, now) -> dict: + """MAINT-11: start or abort the maintenance cycle of ONE named battery. + The site keeps ONE maintenance slot: a start while any battery holds it + is refused (no queue). (DECISIONS/MAIN-orchestration-archive.md)""" + inv = next((i for i in self.inverters if i.name == unit), None) + if inv is None: + return self._maint_refusal("unit") + machine = self.maints[self.inverters.index(inv)] + if what == "maint_start": + holder = next((i for i, m in zip(self.inverters, self.maints) + if m.phase in POWER_MOVING), None) + if holder is not None: + return self._maint_refusal("busy", holder.name) + if self.modes_on and not self.pack_mode(inv).use: + return self._maint_refusal("off", inv.name) + for msg in machine.force_start(now): + self.log_event(msg, hint=messages.hint( + "maintenance", phase=machine.phase)) + return {"unit": inv.name, "phase": machine.phase} + if machine.phase not in POWER_MOVING: + return self._maint_refusal("idle", inv.name) + for msg in machine.abort(now, "operator"): + self.log_event(msg, hint=messages.hint("maintenance", + phase=machine.phase)) + await self.command_site_zero() + return {"unit": inv.name, "phase": machine.phase} + + @staticmethod + def _maint_refusal(code, unit="") -> dict: + return {"error": messages.maint_refused(code, unit), "code": code, + "unit": unit} + + def mode_values(self) -> dict: + """OPT-11 P2: each battery's mode and switch, for their HA state + topics. Read from the store, so they are right after a restart. Empty + while battery modes are off.""" + if not self.modes_on: + return {} + out = {} + names = [inv.name for inv in self.inverters] + for inv, slug in zip(self.inverters, unit_slugs(names)): + pm = self.pack_mode(inv) + out[unit_state_key(slug, "mode")] = MODE_LABELS.get(pm.mode, + pm.mode) + out[unit_state_key(slug, "use")] = USE_ON if pm.use else USE_OFF + return out + + def _drain_floor(self, claims, maint_results): + """SAFETY-78: the site `lo` for a pinned maintenance drain, or None. At + N>=2 the drain pins its pack and the site target is split around the + pin; below the pin a sibling would have to charge (opposite + directions, refused, 0 W, the drain never moves), so the site target + may not go below what the pin places. At N=1 the drain is the site + SET itself: nothing filed. A draining pack with nothing to give adds + no floor (logged once).""" + if len(self.inverters) < 2: + return None + live = set(self.live_units()) + floor, waits = 0.0, set() + for inv, res in zip(self.inverters, maint_results): + if res.phase != DRAIN or not res.owns_setpoint \ + or not (res.setpoint_w or 0.0) > 0.0: + continue + u = self.actuator_of("maintenance", inv) + b = pre_resolve([c for c in claims if c.actuator == u]) + p = (None if u not in live or b.contradiction + or b.pinned_by != "maintenance" else allocator.pin_w(b)) + if p is not None and p > 0.0: + floor += p + else: + waits.add(inv.name) + for name in sorted(waits - self._drain_waits): + self.log_event(f"maintenance: {name} cannot give power now, so " + f"its monthly cycle waits", + hint=messages.hint("maint_waits", unit=name)) + self._drain_waits = waits + return floor if floor > 0.0 else None + + def _peak_on_able(self, decision, maint_results): + """SAFETY-87: at N>=2, `decision` placed on the batteries that can + still give: live, not refusing, above their own reserve. `able_w` is + their rating plus the drain a held battery gives anyway. + DECISIONS/SAFETY-87-guard-uses-able-batteries.md""" + if len(self.inverters) < 2: + return decision + floor = float(self.o.get( + "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) + ok, held = peakguard.reserve_split(self.packs, floor) + if not held: + return decision + live = set(self.live_units()) + able = [p for p, inv in zip(self.packs, self.inverters) + if p in ok and inv.name in live and not (inv.refused or 0) > 0] + if not able: + return dataclasses.replace(decision, held=held) + drain = sum(min(float(r.setpoint_w), i.rating_w()) + for i, r in zip(self.inverters, maint_results) + if i.name in held and r.phase == DRAIN + and r.owns_setpoint and (r.setpoint_w or 0.0) > 0.0) + names = {p.name for p in able} + return peakguard.place_on_able( + decision, held, + able_w=drain + sum(i.rating_w() for i in self.inverters + if i.name in names), + soc=min(float(p.soc) for p in able), + soc_floor=peakguard.binding_soc_floor(able, floor)) + + def _peak_landing(self, decision): + """SAFETY-126: `decision` marked `within_aim` while the guard wants + power but the batteries at 0 W would end the quarter within + `ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). The claim and the + commands are unchanged; only the alarms and the "Why" read it. + DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md""" + batt, prev = self.site_batt_w, self._land_batt_prev + self._land_batt_prev = batt + if not (decision.engaged and decision.blind is None + and (decision.wanted_w or 0.0) > 0.0): + self._peak_needed = False + return decision + if batt is not None and prev is not None: + batt = min(float(batt), float(prev)) + self._peak_needed = self._peak_needed or ( + self._peak_proj is not None and peakguard.misses_aim( + self._peak_proj, batt_w=batt, target_w=decision.target_w)) + return (decision if self._peak_needed + else dataclasses.replace(decision, within_aim=True)) + + def _peak_reserve(self) -> frozenset: + """SAFETY-87: the batteries that give nothing for the peak this + cycle: under their reserve while the others carry a peak floor.""" + g = self.peak_guard + if g is None or g.able_w is None or not (g.floor_w or 0.0) > 0.0: + return frozenset() + return frozenset(g.held) + + def _held_back(self) -> frozenset: + """SAFETY-91, N>=2: the batteries at or under their own peak reserve + while another can still give (live, not refusing). One held back stays + so until its SoC reads more than 1 point above its reserve. + DECISIONS/SAFETY-91-held-battery-last.md""" + if len(self.inverters) < 2: + return frozenset() + floor = float(self.o.get( + "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) + ok, held = peakguard.reserve_split(self.packs, floor) + held = set(held) | { + p.name for p in ok if p.name in self._held_was + and float(p.soc) <= peakguard.pack_floor_pct(p, floor) + 1.0} + live = set(self.live_units()) + if not held or not any( + p.name not in held and inv.name in live + and not (inv.refused or 0) > 0 + for p, inv in zip(self.packs, self.inverters)): + self._held_was = frozenset() + else: + self._held_was = frozenset(held) + return self._held_was + + def _drain_rise(self, claims, maint_results) -> None: + """SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a + draining battery's pin becomes a floor at its drain rate, so it rises + towards its rating before the held one gives. A site stop still decides + alone. DECISIONS/SAFETY-91-held-battery-last.md""" + held = self._held_back() + if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_PEAK for c in claims): + return + live = set(self.live_units()) + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if res.phase != DRAIN or not res.owns_setpoint \ + or inv.name in held or u not in live: + continue + b = pre_resolve([c for c in claims if c.actuator == u]) + p = (None if b.contradiction or b.pinned_by != "maintenance" + else allocator.pin_w(b)) + if p is None or not p > 0.0: + continue + for i, c in enumerate(claims): + if c.source == "maintenance" and c.kind == SET \ + and c.actuator == u: + claims[i] = Claim.limit("maintenance", P_MAINTENANCE, + lo=p, reason=c.reason, + actuator=u) + + def _fill_peak_gaps(self, proj, now, reading): + """SAFETY-90: `proj` with a meter gap's uncounted seconds counted at + `peakguard.gap_w()` of the imports seen just before and just after it, + and those before this run's first reading likewise at the import seen + first (shown as not measured). + DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.""" + self.peak_gap_s = self.peak_unmeasured_s = 0.0 + avg = self.p1.averager + since = avg.held_since + if proj is None or proj.block_start is None or since is None: + return proj + if self._unseen is None and self.p1.last is not None: + self._unseen = [proj.block_start, max(0.0, ( + since - proj.block_start).total_seconds() - avg.elapsed_s), + since, None] + seen = (None if self.grid is None or self.site_batt_w is None + else float(self.grid) + max(0.0, float(self.site_batt_w))) + if proj.block_start != self._gap_block: + self._gap_block, self._gap_w = proj.block_start, None + self._gap_filled_s = self._gap_held_ws = 0.0 + cap = float(self.o.get("peak_cap_w", 3500)) + ceiling = peakguard.quarter_ceiling_w(cap, reading)[0] + unseen_on = (self._unseen is not None + and self._unseen[0] == proj.block_start) + if unseen_on and self._unseen[3] is None and seen is not None: + # While the add-on was down the batteries were at 0 W, so the + # seconds before its first reading count like a gap, at the + # import seen first; a restored reading's held part is topped up. + self._unseen[3] = peakguard.gap_w(ceiling, seen) + if self._run_pre is not None: + s0, g0 = self._run_pre + h = (min(s0 + timedelta(seconds=avg.max_hold_s), + self._unseen[2]) + - max(s0, proj.block_start)).total_seconds() + self._unseen_held_ws = max(0.0, h) * max( + 0.0, self._unseen[3] - max(0.0, g0)) + kw = dict(tail_s=(now - since).total_seconds(), + max_hold_s=avg.max_hold_s, + unseen_s=self._unseen[1] if unseen_on else 0.0, + unseen_w=(self._unseen[3] if unseen_on + and self._unseen[3] is not None else ceiling)) + _p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw) + if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S: + self._gap_w = peakguard.gap_w(ceiling, self._gap_w, + self._gap_seen_w, seen) + if self._gap_pre is not None: + # The averager credited the gap's first max_hold_s at the + # reading before it; top that part up to gap_w as well. + s0, g0 = self._gap_pre + h = (min(s0 + timedelta(seconds=avg.max_hold_s), since) + - max(s0, proj.block_start)).total_seconds() + self._gap_held_ws += max(0.0, h) * max( + 0.0, self._gap_w - max(0.0, g0)) + self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s " + f"of this 15-minute period, counted at " + f"{self._gap_w:.0f} W", + hint=messages.hint("peak_gap")) + self._gap_filled_s = max(self._gap_filled_s, gap_s) + if seen is not None: + self._gap_seen_w = seen + if self.grid is not None: + self._gap_pre = (since, float(self.grid)) + out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps( + proj, gap_w=self._gap_w if self._gap_w is not None else ceiling, + held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on + else 0.0), **kw) + return out + + def _peak_over_drain(self, claims, maint_results) -> None: + """SAFETY-83: at N=1 a peak floor above the drain rate outranks it: the + drain is the site SET at P_MAINTENANCE, above P_PEAK, so the floor + did not bind. Refiled at P_MAINTENANCE where it asks for more; + P_SAFETY still outranks. N>=2: the `drain` floor and + `_drain_over_reserve()`.""" + if len(self.inverters) != 1: + self._drain_over_reserve(claims, maint_results) + return + res = maint_results[0] + if res.phase != DRAIN or not res.owns_setpoint: + return + rate = float(res.setpoint_w or 0.0) + for i, c in enumerate(claims): + if c.source == peakguard.SOURCE and c.lo > rate: + claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE) + + def _held_pin(self, claims, u): + """SAFETY-87: what held unit `u` places anyway (its own pin), or None + when that pin charges, so the site is never split both ways.""" + b = pre_resolve([c for c in claims if c.actuator == u]) + p = None if b.contradiction else allocator.pin_w(b) + return None if p is not None and p < 0.0 else max(p or 0.0, 0.0) + + def _drain_over_reserve(self, claims, maint_results) -> None: + """SAFETY-87, N>=2: while a battery is held at its peak reserve, a + draining battery that may still give is raised from its drain rate + towards its rating for the peak floor, after the others. A site stop + still decides alone. DECISIONS/SAFETY-87-guard-uses-able-batteries.md""" + reserve = self._peak_reserve() + floor = max((c.lo for c in claims if c.source == peakguard.SOURCE), + default=0.0) + if not reserve or not floor > 0.0 or any( + c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_PEAK for c in claims): + return + live = set(self.live_units()) + drains, others = [], 0.0 + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if u not in live: + continue + if inv.name in reserve: + p = self._held_pin(claims, u) + if p is None: + return + others += p + continue + b = pre_resolve([c for c in claims if c.actuator == u]) + if b.contradiction: + continue + p = allocator.pin_w(b) + if p is not None and p < 0.0: + return # a pack pinned to charge: never split both ways + if res.phase == DRAIN and b.pinned_by == "maintenance" \ + and p is not None and p > 0.0: + drains.append((u, p, allocator.hi_w(b))) + others += allocator.hi_w(b) if p is None else p + give = floor - others + for u, p, hi in drains: + if give <= 0.0: + break + up = max(p, min(hi, p + give)) + give -= up - p + for i, c in enumerate(claims): + if c.source == "maintenance" and c.kind == SET \ + and c.actuator == u: + claims[i] = dataclasses.replace(c, value=up) + + def _peak_over_hold(self, claims, maint_results): + """SAFETY-86: a peak floor outranks a maintenance HOLD (0 W). N=1: + refiled at P_MAINTENANCE as for a drain. N>=2: the holding pack's + pin is raised to the floor minus what the others may place, and the + floor is returned as the site `hold` ceiling. A site stop still + decides alone. DECISIONS/SAFETY-86-peak-over-hold.md.""" + hold = {self.actuator_of("maintenance", inv) + for inv, res in zip(self.inverters, maint_results) + if res.phase == HOLD and res.owns_setpoint} + peak = [i for i, c in enumerate(claims) + if c.source == peakguard.SOURCE and c.lo > 0.0] + if not hold or not peak: + return + if len(self.inverters) == 1: + for i in peak: + claims[i] = dataclasses.replace(claims[i], + priority=P_MAINTENANCE) + return + if any(c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_PEAK for c in claims): + return + live = set(self.live_units()) + reserve = self._peak_reserve() # SAFETY-87: they give nothing + others = 0.0 + for inv in self.inverters: + u = self.actuator_of("maintenance", inv) + if u in hold or u not in live: + continue + if inv.name in reserve: # only its own pin, never both ways + p = self._held_pin(claims, u) + if p is None: + return + others += p + continue + b = pre_resolve([c for c in claims if c.actuator == u]) + if b.contradiction: + continue + p = allocator.pin_w(b) + if p is not None and p < 0.0: + return # a pack pinned to charge: never split both ways + others += allocator.hi_w(b) if p is None else p + floor = max(claims[i].lo for i in peak) + give = floor - others + if give <= 0.0: + return + for i, c in enumerate(claims): + if c.source == "maintenance" and c.kind == SET \ + and c.actuator in hold: + claims[i] = dataclasses.replace(c, value=give) + return floor + + def _charge_headroom(self, floor: float, claims) -> float: + """SAFETY-84: a cheap-window charge pulls no more than the quarter's + room under the peak aim (`charge_room_w()`), so it yields just enough + to the peak guard; at N>=2 nothing under a site stop (its 0 W would + need the other pack to give). SAFETY-125: at N=1 too; the stop rule + stays N>=2 only. + DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md""" + if len(self.inverters) > 1 and any( + c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_MAINT_SHAPE for c in claims): + return 0.0 + room = peakguard.charge_room_w(self.peak_guard, self._peak_proj) + floor = floor if room is None else min(floor, room) + self.room_charge = self.room_charge or floor > 0.0 + return floor + + def _charge_ceiling(self, claims): + """SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None: + the charging pack's own `hi` (P_MAINT_SHAPE) makes it charge; above + that the other pack would have to give (opposite directions, + refused, 0 W). Not filed at N=1.""" + if len(self.inverters) < 2: + return None + live = set(self.live_units()) + ceiling = 0.0 + for inv in self.inverters: + u = self.actuator_of("maintenance", inv) + b = pre_resolve([c for c in claims if c.actuator == u]) + if u in live and not b.contradiction and b.hi < 0.0: + ceiling += allocator.hi_w(b) + return ceiling if ceiling < 0.0 else None + + def _maint_stop_off(self, machine, now) -> None: + """OPT-11: stop this battery's maintenance cycle because it is off.""" + if machine.phase == IDLE: + return + for msg in machine.abort(now, "battery switched off"): + self.log_event(msg, hint=messages.hint("maint_off", + unit=machine.name)) + + def _provider_gate(self, now: datetime) -> None: + """PRICE-01: `cheap`, the gate and the gap from the standing plan. No + price for now is a closed gate, so the charge waits rather than run + unpriced; the gap flag stays False until the first refresh has + finished. (DECISIONS/MAIN-orchestration-archive.md)""" + plan = self.fresh_tariff_plan(now) + published = None if plan is None else plan.published + if published is not None: + self._last_published = published + gate = gate_for(published, now) + self.cheap, self._price_gated = gate.cheap, True + self.tariff_words = gate.words + # Owner, 2026-10-14: the words say 'maintenance waits', so they show + # only while the maintenance charge is on. + self.price_gap = ((not gate.covered) and self.tariff_at is not None + and MaintConfig.from_options(self.o).enabled) + last = self._last_published + if gate.covered or last is None: + self.price_gap_since = None + elif last.covers(now): + # The last series still covers now, but no refresh has made it + # current for TARIFF_MAX_AGE_S: the refresh task has stopped. The + # gap began when the plan was retired. + self.price_gap_since = ( + None if self.tariff_at is None + else self.tariff_at + timedelta(seconds=TARIFF_MAX_AGE_S)) + else: + self.price_gap_since = last.last_end_before(now) + + def _static_gated(self, o: dict, now: datetime) -> bool: + """`static_gated()`, worked out once an hour: it builds the same + month-long plan as `static_status()` (about 20 ms with a profile), and + whether a fixed tariff is set up does not change from tick to tick + (options change only with a restart).""" + key = (id(o), now.strftime("%Y-%m-%d %H")) + if key != self._gated_key: + self._gated_key, self._gated = key, static_gated(o, now) + return self._gated + + def _charge_gate_open(self, inv) -> bool: + """MAINT-13: may this pack charge now, for the maintenance timeout? + Closed only when a price gate exists, it is shut, and the pack is not + really taking charge. (DECISIONS/MAIN-orchestration-archive.md)""" + if not self._price_gated or self.cheap: + return True + batt = inv.batt + return batt is not None and float(batt) <= -MAINT_CHARGING_W diff --git a/goodwe_controller/app/ctl_writes.py b/goodwe_controller/app/ctl_writes.py new file mode 100644 index 0000000..637496c --- /dev/null +++ b/goodwe_controller/app/ctl_writes.py @@ -0,0 +1,613 @@ +"""Controller methods that write to, refuse and hand back the inverters. + +Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" + +import asyncio +import logging +import time +from datetime import datetime, timezone + +from . import control as _ctl +from .clamp import clamp_to_rating, is_commandable +from . import messages +from . import shadow + +_LOG = logging.getLogger("goodwe") + + +# DEV-02: the per-write budget, DERIVED: `heartbeat_s` max 25 s + 4 < +# `validate.FIRMWARE_INGEST_VETO_S` (30 s), graded by test_inverter.py. +# DECISIONS/MAIN-orchestration.md. +WRITE_BUDGET_S = 4.0 + + +# SMA-10: the bound for handing a unit back (`driver.release()`); units are +# released concurrently and one still running at the bound is abandoned. +# DECISIONS/SMA-10-two-idle-states.md. +RELEASE_BUDGET_S = WRITE_BUDGET_S + + +# SMA-10: the one fallback write after a failed release (default 0 W). Worst +# case RELEASE_BUDGET_S + FALLBACK_BUDGET_S = 6 s, inside the Supervisor's 10 s. +# DECISIONS/SMA-10-two-idle-states.md. +FALLBACK_BUDGET_S = 2.0 + + +# SAFETY-137: how long a LAST write waits for a write already on its way to the +# same unit; half the release budget, so a GoodWe unit's worst case is +# HAND_BACK_WORST_S. DECISIONS/SAFETY-137-last-write-is-zero.md. +SETTLE_BUDGET_S = RELEASE_BUDGET_S / 2 + + +# SAFETY-73: the evidence must also span this long, not only SETTLE_CYCLES +# cycles: a 1 s meter makes 12 cycles only 11 s, shorter than a healthy battery +# reading's lag, and both inverters were refused after ordinary load steps. +REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S + + +# SAFETY-94: evidence restarts when the reading has moved this far toward the +# ask. A refusing battery reads flat; live readings wander under 25 W at rest; +# a first-order lag gains at most 0.4 tau x 100 W across the band, so the +# evidence cannot span REFUSED_SPAN_S unless the lag is over 54 s. +REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2 + + +async def _bounded(awaitable, budget_s: float): + """SMA-10. `await awaitable`, abandoning it after `budget_s` without waiting + for it to wind down (`wait_for` would). Raises `asyncio.TimeoutError`.""" + task = asyncio.ensure_future(awaitable) + done, _ = await asyncio.wait({task}, timeout=budget_s) + if not done: + task.cancel() + # a result or error that arrives later has nobody to read it + task.add_done_callback(lambda t: t.cancelled() or t.exception()) + raise asyncio.TimeoutError + return task.result() + + +class WritesMixin: + # helpers; SAFETY-52: a battery that refuses is asked for 0 W + def _refusal_probe(self) -> bool: + """True while a refused direction may try `PROBE_W` (every 15 min).""" + if (not self.batt_refused or self.batt_refused_at is None + or self._now_mono is None): + return False + held = self._now_mono - self.batt_refused_at + if held >= _ctl.PROBE_EVERY_S + _ctl.PROBE_S: + self.batt_refused_at = self._now_mono + return False + return held >= _ctl.PROBE_EVERY_S + + def _refusal_start(self, sign: int, evidence: str) -> None: + self.batt_refused = sign + self.batt_refused_at = self._now_mono + self.log_event(f"BATTERY REFUSES {evidence}", + hint=messages.hint("battery_refuses")) + _LOG.info("The battery refuses to give power, so the add-on asks 0 W " + "and tries %.0f W every %.0f minutes: BATTERY_REFUSES.", + _ctl.PROBE_W, _ctl.PROBE_EVERY_S / 60) + _LOG.debug("Evidence: BATTERY_REFUSES: %s.", evidence) + + def _refusal_end(self, why: str) -> None: + if not self.batt_refused: + return + self.batt_refused, self.batt_refused_at = 0, None + self.log_event(f"BATTERY REFUSES ENDED: {why}", + hint=messages.hint("battery_gives")) + _LOG.info("The battery refusal ends, control asks for power again: %s.", + why) + + def _refusal_update(self, decision, asked_w, batt_w, probe, + rejudged: int = 0) -> None: + """Follow `compute()`'s carried refused direction, and say so. + `rejudged`: the refusal held while another writer had the wire, so + `compute()` judged it afresh against that writer's ask (SAFETY-107).""" + now = decision.refused or 0 + if rejudged: + if now == rejudged: + # Still refused against the ask on the wire: the same episode. + # The probe clock restarts: a probe is the loop's ask and is + # not on the wire while another writer is. + self.batt_refused_at = self._now_mono + return + gave = (rejudged * (batt_w - self._site_idle_w()) + >= _ctl.PROBE_ANSWER_W) + self._refusal_end("the battery gives power" if gave + else "another writer") + if now and not self.batt_refused: + self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery " + f"gave {batt_w:+.0f} W for " + f"{decision.sat_count} cycles") + elif self.batt_refused and not now: + gave = (self.batt_refused * (batt_w - self._site_idle_w()) + >= _ctl.PROBE_ANSWER_W) + self._refusal_end("the probe was answered" if gave and probe + else "the battery gives power" if gave + else "the grid now asks the other direction") + + def _refusal_from_stale(self, inv, now_mono) -> None: + """A stale-reading stop while the loop is frozen on ~0 W is a refusal. + Only at one inverter: with several, one empty battery must not stop + the site (OPT-09 moves its share).""" + t = self.tuning + standing = inv.standing_w() + if len(self.inverters) > 1: + # SAFETY-73: at N>1 the same trip refuses THIS unit only. + if ((self.auto or self.shadow) and not inv.refused + and inv.ref_count >= t.saturation_cycles + and inv.batt_fresh.held is not None + and abs(inv.batt_fresh.held - inv.idle_w) + < _ctl.REFUSED_BELOW_W + and abs(standing) > _ctl.PROBE_W): + self._now_mono = now_mono + self._unit_refusal_start( + inv, 1 if standing > 0 else -1, + f"stale reading {inv.batt_fresh.held:+.0f} W while asked " + f"{standing:+.0f} W") + return + if (len(self.inverters) == 1 and (self.auto or self.shadow) + and not self.batt_refused + and self.sat_count >= t.saturation_cycles + and inv.batt_fresh.held is not None + and abs(inv.batt_fresh.held - inv.idle_w) + < _ctl.REFUSED_BELOW_W + and abs(standing) > t.saturation_w): + self._now_mono = now_mono + self._refusal_start(1 if standing > 0 else -1, + f"stale reading {inv.batt_fresh.held:+.0f} W " + f"while asked {standing:+.0f} W") + + # SAFETY-73: SAFETY-52's decisions per inverter at N>1: a unit giving < + # REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in that + # direction by a P_SAFETY unit limit, so the allocator hands its share on; + # probed at PROBE_W every PROBE_EVERY_S, one unit at a time. The ask is + # PROBE_W, not `saturation_w`: a unit's share is often small and SAFETY-43 + # would name the empty battery as not delivering. + # DECISIONS/SAFETY-73-refusal-per-inverter.md. + def _unit_refusal_start(self, inv, sign: int, evidence: str) -> None: + inv.refused, inv.refused_at = sign, self._now_mono + inv.probe_from, inv.ref_count = None, 0 + self.log_event(f"BATTERY REFUSES {inv.name}: {evidence}", + hint=messages.hint("unit_refuses", unit=inv.name)) + _LOG.info("%s: the battery refuses power, so it is asked 0 W and the " + "other inverters take over: BATTERY_REFUSES.", inv.name) + _LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence) + # Only a refusal to GIVE: a full battery that stops taking charge is + # above its cut-out level by definition. + if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None + and float(inv.soc) > float(inv.bms_soc_floor)): + # Owner decision 3: a refusal above the configured cut-out level + # may mean that level is set wrong. One line, no safety stop. + _LOG.warning("%s refuses power above its battery cut-out level, " + "check its cut-out level: REFUSES_ABOVE_CUT_OUT.", + inv.name) + _LOG.debug("Evidence for %s: REFUSES_ABOVE_CUT_OUT: battery level " + "%.0f %%, cut-out level %.0f %%.", inv.name, + float(inv.soc), float(inv.bms_soc_floor)) + + def _unit_refusal_end(self, inv, why: str) -> None: + if not inv.refused: + return + inv.refused, inv.refused_at, inv.probe_from = 0, None, None + inv.ref_count = 0 + self.log_event(f"BATTERY REFUSES ENDED {inv.name}: {why}", + hint=messages.hint("unit_gives", unit=inv.name)) + _LOG.info("%s: the battery refusal ends, it is asked for power again: " + "%s.", inv.name, why) + # The site target may stand above what was placed (the refused share + # was unmet). Restart it from the watts on the wire, or the reopened + # unit gets the parked remainder at once (phase A: -1059 W). + placed = float(sum(i.standing_w() for i in self.inverters)) + self.target = self.i_w = placed + self.sat_count = 0 + + def _unit_probe(self, inv) -> bool: + """True while `inv` may try PROBE_W. One unit probes at a time.""" + now = self._now_mono + if not inv.refused or inv.refused_at is None or now is None: + return False + if inv.probe_from is not None: + if now - inv.probe_from < _ctl.PROBE_S: + return True + inv.probe_from, inv.refused_at = None, now + return False + if now - inv.refused_at < _ctl.PROBE_EVERY_S or any( + i.probe_from is not None for i in self.inverters): + return False + inv.probe_from = now + return True + + def _unit_refusals(self) -> None: + """Engage and end each unit's refusal from its own reading.""" + for inv in self.inverters: + b = inv.batt + if inv.refused: + # SAFETY-97: given and taken are counted from the idle reading. + if (b is not None and inv.refused * (b - inv.idle_w) + >= _ctl.PROBE_ANSWER_W): + self._unit_refusal_end( + inv, "the probe was answered" + if inv.probe_from is not None + else "the battery gives power") + continue + # The other direction: the battery takes PROBE_ANSWER_W or more + # the other way for SETTLE_CYCLES, counted on its own reading + # so one overshoot through zero does not hand its share back. + inv.ref_count = (inv.ref_count + 1 if b is not None and + -inv.refused * (b - inv.idle_w) + >= _ctl.PROBE_ANSWER_W else 0) + if inv.ref_count >= _ctl.SETTLE_CYCLES: + self._unit_refusal_end( + inv, "the battery takes power the other way") + continue + if inv.rejected_since is not None: + # SAFETY-96: its writes are refused, so its board may have gone + # to 0 W on its own; that is no refusal by the battery. The + # count is kept, as across a gap. + continue + sign = self._gave_nothing(inv) + if sign: + self._unit_refusal_start( + inv, sign, + f"asked {inv.standing_w():+.0f} W, the battery gave " + f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles") + + def _gave_nothing(self, inv) -> int: + """The refusal evidence, one cycle of it: the sign `inv` has given + under REFUSED_BELOW_W while asked over PROBE_W, one way, for + SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1; + SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the + band is centred on the unit's idle reading.""" + b = inv.batt + if b is None: + return 0 # a gap keeps the count: the gate's trip seeds + standing = inv.standing_w() + sign = 1 if standing > 0 else -1 + # One direction throughout: a command that swaps sign every cycle reads + # ~0 through the plant's lag, which is a battery following, not + # refusing (TEST-19). + if (abs(standing) > _ctl.PROBE_W + and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W): + # SAFETY-94: a reading still moving towards the ask is a battery + # catching up through its lag: start again. + if (sign != inv.ref_sign or not inv.ref_count + or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W): + inv.ref_count, inv.ref_since, inv.ref_from = ( + 0, self._now_mono, b) + inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES) + else: + inv.ref_count = 0 + inv.ref_sign = sign + if inv.ref_count >= _ctl.SETTLE_CYCLES and ( + inv.ref_since is None or self._now_mono is None + or self._now_mono - inv.ref_since >= REFUSED_SPAN_S): + return sign + return 0 + + def _site_idle_w(self) -> float: + """SAFETY-97: the site reading at idle, the sum of the units'.""" + return float(sum(inv.idle_w for inv in self.inverters)) + + def _site_refused(self): + """Owner decision 4: every unit refuses the same way, so the site law + holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law.""" + signs = {inv.refused for inv in self.inverters} + return signs.pop() if len(signs) == 1 and 0 not in signs else None + + @property + def shadow(self) -> bool: + """SAFETY-10. Is the battery being left alone right now? A property, + not a field (as `self.units`): the cycle, the write and the status + page ask at three moments, and the write gate asks THIS, not a + parameter passed down.""" + return shadow.engaged(self.o, control_started=self.auto) + + def shadow_latches(self) -> tuple: + """The failsafe latches that are UP, named, for the shadow banner: a + standing latch is standing when the controller stops being inert, so + an operator who turns shadow off is not handed a battery that will + not move with no reason shown.""" + out = [] + if self.circ_wd.tripped: + out.append("circulating power") + if self.site_sign_wd.tripped and len(self.inverters) > 1: + out.append("site sign watchdog") + for inv in self.inverters: + if inv.sign_wd.tripped: + out.append(f"{inv.name} sign watchdog") + return tuple(out) + + def write_refusal(self, value: float, inv=None) -> str | None: + """Why `value` may NOT be written to `inv` right now, or None if it + may. SAFETY-28: a number reaches the wire only if THE SINGLE ALLOCATOR + placed it, on this tick, for THIS unit (`Allocation.authorises()`); a + second writer must file a Claim. 0 W is ALWAYS authorised and tested + first, reading nothing: the failsafe writes run while the allocator has + REFUSED, so a gate asking permission to command zero would deadlock the + failsafe. It tests `value == 0.0`, not a flag the caller sets. DEV-02: + the unit is an ARGUMENT (the record holds one share per unit); the + default is for N=1 callers and REFUSES at N>=2. + DECISIONS/MAIN-orchestration.md.""" + if value == 0.0: + return None + inv = self._only() if inv is None else inv + rec = self.allocation + if rec is None: + return ("no allocation record exists yet - nothing has authorised " + f"{value:+.0f} W on {inv.name}") + return rec.authorises(inv.name, value, self.alloc_tick) + + async def write_setpoint(self, value: float, *, force: bool = False, + unit=None, last: bool = False) -> None: + """Put `value` on ONE inverter, or with `unit=None` on all. `unit=None` + is for a number that is not a share (stop button, `shutdown()`, + `maint_abort()`: 0 W means the same to every inverter). Writes are + concurrent, each under its own budget (DEV-02). `last`: + DECISIONS/SAFETY-137-last-write-is-zero.md.""" + if unit is None: + targets = [i for i in self.inverters] + if len(targets) > 1 and is_commandable(value) and float(value) != 0.0: + # Loud, and it commands zero rather than the value asked for: + # fail toward inaction, as every other refusal here. + self.log_event( + f"refusing to broadcast {float(value):+.0f} W to " + f"{len(targets)} inverters - that is the ARCH-01 fault; " + f"commanding 0 W", hint=messages.hint("all_blocked")) + _LOG.error("Refused to send %+.0f W to all %s at once, so " + "every inverter is set to 0 W. This is a software " + "fault.", float(value), + messages.count(len(targets), "inverter")) + _LOG.debug("ARCH-01: a site target handed to each unit is " + "supplied by each of them, so the gain at the meter " + "becomes N times the per-unit gain. Only 0 W may be " + "written site-wide, and shares come from the " + "allocator.") + value = 0.0 + else: + targets = [unit] + if not targets: + return + await asyncio.gather(*(self._write_unit(inv, value, force=force, + last=last) + for inv in targets)) + + async def _await_inflight(self, inv, budget_s: float) -> None: + """Wait, at most `budget_s`, for writes already on their way to `inv`; one + still running is left to its own WRITE_BUDGET_S. + DECISIONS/SAFETY-137-last-write-is-zero.md.""" + pending = [t for t in inv.inflight if not t.done()] + if pending: + await asyncio.wait(pending, timeout=budget_s) + + async def _release_last(self, inv) -> bool: + """`release()` once any write in flight to `inv` has finished (at most + SETTLE_BUDGET_S): the release is the LAST thing the unit hears. + DECISIONS/SMA-10-two-idle-states.md.""" + await self._await_inflight(inv, SETTLE_BUDGET_S) + return await self._driver(inv).release() + + async def _hand_back(self, inv, *, force: bool = False) -> bool: + """SMA-10. Give ONE unit back: exactly one `release()`, then nothing. + True when handed back. Never raises; a failure is retried on the next + idle write. ⚠️ `released` is set before the first await (one release per + unit, not two). ⚠️ It is not a write: no `note_write`, no `last_write`. + DECISIONS/SMA-10-two-idle-states.md.""" + if inv.released: + return True + if not inv.driver.configured(): + return False + # After a failure the next try waits one heartbeat; Stop and shutdown + # (`force`) always try. DECISIONS/SMA-10-two-idle-states.md. + now_m = time.monotonic() + if not force and now_m < inv.release_retry_at: + return False + inv.released = True + why = "" + try: + ok = bool(await _bounded(self._release_last(inv), + RELEASE_BUDGET_S)) + except (asyncio.TimeoutError, TimeoutError): + ok, why = False, f"took over {RELEASE_BUDGET_S:.0f} s" + except Exception as err: # noqa: BLE001 - one unit never stops the rest + ok, why = False, f"{type(err).__name__}: {err}" + if ok: + inv.last_write = None + if inv.release_fails: + _LOG.info("Handing back %s worked again after %d failed " + "tries.", inv.name, inv.release_fails) + inv.release_fails = 0 + inv.release_retry_at = 0.0 + inv.release_zero_sent = False + self.log_event(f"{inv.name}: handed back", + hint=messages.hint("handed_back", unit=inv.name)) + return True + # SMA-10: a failed hand-back is tried again, and sent 0 W once below. + inv.released = False + inv.release_fails += 1 + inv.release_retry_at = now_m + float(self.o.get("heartbeat_s", 10)) + _LOG.debug("Handing back %s failed (try %d): %s", inv.name, + inv.release_fails, why) + if inv.release_fails == 1: # one line per failure streak + _LOG.error("Handing back %s failed, so it is sent 0 W once and " + "handing back is tried again.", inv.name) + self.log_event(f"{inv.name}: hand-back failed", + hint=messages.hint("hand_back_failed", unit=inv.name)) + if not inv.release_zero_sent: + # ⚠️ ONCE: a 0 W re-sent on every try would reset the inverter's own + # fallback timer for ever. DECISIONS/SMA-10-two-idle-states.md. + inv.release_zero_sent = True + try: + if await _bounded(self._driver(inv).release_fallback(), + FALLBACK_BUDGET_S): + inv.last_write = 0.0 + except Exception as err: # noqa: BLE001 + _LOG.debug("The fallback write to %s failed: %r", inv.name, err) + return False + + async def _write_unit(self, inv, value: float, *, force: bool = False, + last: bool = False) -> None: + if not inv.driver.configured(): + return + # SMA-10: a unit that hands back is released once and not written while + # the add-on is idle (shutdown, stopped, shadow). ⚠️ A safety stop is + # NOT idle: it falls through to the 0 W write. Control starting again + # re-arms the unit; `stopping` never does. + # DECISIONS/SMA-10-two-idle-states.md. + if inv.driver.hands_back: + if self.stopping or self.shadow or not self.auto: + await self._hand_back(inv) + return + if inv.released or inv.release_fails or inv.release_zero_sent: + # Control has the unit again: forget the hand-back and any + # failed streak. + inv.released = False + inv.last_write = None + inv.release_fails = 0 + inv.release_retry_at = 0.0 + inv.release_zero_sent = False + entity = inv.driver.actuator_id() # for the log line below + + # SAFETY-03: the clamp is on the ONLY path to the wire (cycle, + # heartbeat, stop button, shutdown(); no optimizer, maintenance or + # manual path routes around it). A non-finite value is loss of input, + # not an out-of-range one (11.1): coerced to 0 W. + if not is_commandable(value): + self.log_event(f"non-finite setpoint {value!r} - commanding 0 W", + hint=messages.hint("bad_value")) + _LOG.error("refusing to write a non-finite setpoint %r - commanding " + "0 W and continuing to command. This is a bug upstream, " + "not a limit.", value) + value = 0.0 + + # SAFETY-28: the refusal is HERE, above the clamp and below the + # non-finite coercion (a nan is loss of input, not an unauthorised + # number), and asked about `inv`, not the site. + # DECISIONS/MAIN-orchestration.md. + refusal = self.write_refusal(value, inv) + if refusal is not None: + self.log_event( + f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W", + hint=messages.hint("write_blocked", unit=inv.name)) + _LOG.error("Refused to send %+.0f W to %s, so it is set to 0 W " + "instead, which is a software fault. The reason: %s.", + value, inv.name, refusal) + value = 0.0 + + # This unit's own three numbers: `inv.max_w` (supervised cap on THIS + # setpoint entity) and `inv.dev_min/dev_max` (what THIS device + # reported). Read off `self.o` they were one inverter's rating applied + # to every inverter. + result = clamp_to_rating(value, inv.max_w, inv.dev_min, inv.dev_max) + inv.clamp_last = result + if result.engaged: + # EVERY engagement is logged, never deduplicated: the action log + # (15) is a different surface from the deduplicated notification. + # The count says how hard the loop pushed. + self.log_event(f"{inv.name}: {result.describe()}", + hint=messages.hint("write_cut", unit=inv.name)) + value = result.applied_w + + # SAFETY-10: shadow mode runs the whole decision path and commands + # nothing, and the suppression is HERE, the last thing before the wire, + # in the one method every write reaches (so no call site routes round + # it). A substitution, not a return, like the two refusals above; below + # SAFETY-28's gate and SAFETY-03's clamp; not a second writer (no + # claim, no transport call). + if self.shadow and value != shadow.WIRE_W: + # UX-10: DEBUG, because it repeats every cycle in shadow mode; the + # INFO line when shadow comes on says it once. + _LOG.debug("Shadow mode: would send %+.0f W to %s, and holds " + "%+.0f W. Nothing was sent to the inverter.", + value, inv.name, shadow.WIRE_W) + value = shadow.WIRE_W + + # SAFETY-137: `last` (the Stop and shutdown 0 W) waits, bounded, for a + # write already on its way; once `stopping`, every other write is dropped + # here with no await before the write is made. + # DECISIONS/SAFETY-137-last-write-is-zero.md. + if last: + await self._await_inflight(inv, SETTLE_BUDGET_S) + elif self.stopping: + _LOG.debug("Shutting down, so %+.0f W to %s is not sent.", + value, inv.name) + return + changed = (inv.last_write is None or value != inv.last_write) + if not (changed or force): + return + # BOUNDED, and the budget is AC 6: one hung transport would hold the + # coroutine, the whole loop and every OTHER inverter's heartbeat. An + # abandoned write is re-issued next tick (`last_write` moves only on an + # ACCEPTED write). + try: + _w = self._driver(inv).write(value) + # SMA-10 / SAFETY-137: kept so a release or last 0 W can wait for + # it. DECISIONS/SAFETY-137-last-write-is-zero.md. + _w = asyncio.ensure_future(_w) + inv.inflight.add(_w) + _w.add_done_callback(inv.inflight.discard) + ok = await asyncio.wait_for(_w, timeout=WRITE_BUDGET_S) + except (asyncio.TimeoutError, TimeoutError): + ok = False + _LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was " + "dropped, so it is sent again next cycle.", + value, inv.name, entity, WRITE_BUDGET_S) + self.log_event(f"{inv.name}: write timed out after " + f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick", + hint=messages.hint("write_slow", unit=inv.name)) + now = datetime.now(timezone.utc) + inv.note_write(bool(ok), now) + if ok: + inv.last_write = value + inv.last_write_at = now + + async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None: + """Refresh every inverter whose own deadline has come. AC 6 lives here. + ⚠️ A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` so a check + can inject a hang: "a hung write to A cannot consume B's layer-1 + budget"; inline, the mutation to a serial `for` loop passed the whole + suite. (DECISIONS/MAIN-orchestration-archive.md)""" + due = [inv for inv in self.inverters + if loop_now - inv.last_beat >= heartbeat_s] + if not due: + return + # ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT: after, a + # hanging write drifts into being re-sent every tick and a raising one + # never advances. (DECISIONS/MAIN-orchestration-archive.md) + for inv in due: + inv.last_beat = loop_now + # ⚠️ CONCURRENTLY: in series, every inverter behind a hung one waits + # out its whole budget, pushing a healthy sibling towards layer 1's ~30 + # s veto. (DECISIONS/MAIN-orchestration-archive.md) + await asyncio.gather(*( + self.write_setpoint(inv.target, force=True, unit=inv) + for inv in due)) + + async def command_site_release(self) -> None: + """SMA-10. The add-on is going away (Stop, shutdown, restart, task death, + config refusal): units that hand back are given back, the rest held at + 0 W as `command_site_zero()` does. ⚠️ Safety stops and refusals do NOT + come here: they hold 0 W for EVERY unit. A roster with no hand-back unit + is `command_site_zero()` line for line. + DECISIONS/SMA-10-two-idle-states.md.""" + if not any(inv.driver.hands_back for inv in self.inverters): + await self.command_site_zero(last=True) + return + self.target = 0.0 + for inv in self.inverters: + inv.target = 0.0 + await asyncio.gather(*( + self._hand_back(inv, force=True) if inv.driver.hands_back + else self._write_unit(inv, 0.0, force=True, last=True) + for inv in self.inverters)) + + async def command_site_zero(self, *, last: bool = False) -> None: + """Every inverter to 0 W, and every inverter's TARGET with it. ⚠️ NOT + BOOKKEEPING (DEV-02): the heartbeat re-emits `inv.target`, so zeroing + only `self.target` lets the next heartbeat put each unit's last share + back on the wire. ⚠️ 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE + (`write_setpoint()` refuses others: ARCH-01). + (DECISIONS/MAIN-orchestration-archive.md)""" + self.target = 0.0 + for inv in self.inverters: + inv.target = 0.0 + await self.write_setpoint(0.0, force=True, last=last) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index b5b7460..5c440c4 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -16,7 +16,10 @@ takes the battery to zero. Never "hold the last value to be safe". layer 3 the optional RS485 e-stop: 0 W after 30 s of bus silence; the only cover for this add-on's host dying. -Per-section reasoning: DECISIONS/MAIN-orchestration.md.""" +Per-section reasoning: DECISIONS/MAIN-orchestration.md. The Controller keeps +__init__, its roster views and shutdown here; its other methods are in +ctl_inputs, ctl_writes, ctl_trade, ctl_cycle, ctl_alarms, ctl_status and +ctl_tasks (MAINT-21), the status-row functions in rows.""" import asyncio import contextlib @@ -103,6 +106,29 @@ from .watchdog import ( ) from . import setuponly from . import web +# MAINT-21: the Controller's parts and the names they now define; every one +# stays importable from `app.main`. DECISIONS/MAIN-orchestration.md. +from .ctl_tasks import TasksMixin +from .ctl_status import StatusMixin +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, + HOUSE_PROFILE_KEY, MAINT_CHARGING_W, REF_CHEAP_KEEP, _night_limit, + pack_energy_of, +) +from .ctl_writes import ( + WritesMixin, FALLBACK_BUDGET_S, REFUSED_PROGRESS_W, REFUSED_SPAN_S, + RELEASE_BUDGET_S, SETTLE_BUDGET_S, WRITE_BUDGET_S, _bounded, +) +from .ctl_inputs import ( + InputsMixin, FC_HOLD_KEY, FORECAST_READ_S, METER_QUIET_REPEAT_S, + TARIFF_MAX_AGE_S, TARIFF_REFRESH_S, _mono, +) +from .rows import arbitrage_row, tariff_plan_row, why_key, why_raised OPTIONS_PATH = "/data/options.json" INGRESS_PORT = 8099 @@ -113,52 +139,9 @@ _LOG = logging.getLogger("goodwe") # function CFG-04 changes. __all__ = ["UNIT_A", "Controller", "amain", "main"] -# DEV-02: the per-write budget: every write is dispatched concurrently, each -# bounded by this, so one hung `hass.call()` cannot eat a sibling's layer-1 -# budget. DERIVED: `heartbeat_s` max 25 s + 4 < -# `validate.FIRMWARE_INGEST_VETO_S` (30 s), graded by test_inverter.py; below -# hass.py's 10 s HTTP timeout on purpose (abandon, re-issue next tick; -# `last_write` moves only on an ACCEPTED write). -# DECISIONS/MAIN-orchestration.md. -WRITE_BUDGET_S = 4.0 -# SMA-10: the bound for handing a unit back (`driver.release()`); units are -# released concurrently and one still running at the bound is abandoned. -# DECISIONS/SMA-10-two-idle-states.md. -RELEASE_BUDGET_S = WRITE_BUDGET_S - -# SMA-10: the one fallback write after a failed release (default 0 W). Worst -# case RELEASE_BUDGET_S + FALLBACK_BUDGET_S = 6 s, inside the Supervisor's 10 s. -FALLBACK_BUDGET_S = 2.0 HAND_BACK_WORST_S = RELEASE_BUDGET_S + FALLBACK_BUDGET_S -# SAFETY-137: how long a LAST write waits for a write already on its way to the -# same unit; half the release budget, so a GoodWe unit's worst case is -# HAND_BACK_WORST_S. DECISIONS/SAFETY-137-last-write-is-zero.md. -SETTLE_BUDGET_S = RELEASE_BUDGET_S / 2 - -# 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 -# UX-11: a quiet meter logs at most one WARNING this often (on `ha_signed` a -# healthy meter can go 42-97 s between changes). -METER_QUIET_REPEAT_S = 600.0 -_mono = time.monotonic # patched by the checks; asyncio keeps the real one - -# OPT-04: the background tariff refresh. `static_tariff.resolve_tariff()` may -# do an HTTPS fetch, and a supplier's TLS timeout on the control tick could -# starve the ESP32 heartbeat into layer 1. So the tick NEVER awaits a fetch: it -# reads `self.tariff_plan`, which this task assigns. -# The cadence is not about how often prices change (day-ahead publishes once, -# 14-16 h CET; 30 min picks it up inside the hour with 48 fetches a day). -# DECISIONS/MAIN-orchestration.md. -TARIFF_REFRESH_S = 1800.0 -# The age bound is a different question from the refresh: a failed refresh -# still stores a plan (the 11.4 static fallback, provisional). The bound covers -# the refresh task dying and a stale plan standing as current. Three missed -# refreshes, for `p1.max_age_s`'s reason. -TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S - def tariff_refresh_wanted(opts: dict) -> bool: """Does this install need the background tariff refresh? (TARIFF-10, @@ -179,29 +162,6 @@ def load_options() -> dict: return {} - -# OPT-11 P3: how long the last cheap run stays the price stored energy is -# valued at: the rest of that day. -REF_CHEAP_KEEP = timedelta(hours=12) -# OPT-15: the longest a run edge holds the loop at E waiting for the meter to -# show the step (control.PLANT_SETTLE_S). -EDGE_HOLD_S = _ctl.PLANT_SETTLE_S - -# OPT-11 P4: store key of the house profile, and how often the forecast sensors -# are read (hourly at most). -HOUSE_PROFILE_KEY = "house_profile" -# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an -# evening restart keeps the night plan's figure. -FC_HOLD_KEY = "solar_fc_hold" -# OPT-34: the level of a night grid charge that has started, so it does not -# drop when the forecast goes (`Controller._latch_charge()`). -CHARGE_LATCH_KEY = "night_charge_latch" -# OPT-34: a latch outlives its last cheap run by one price slot, since the next -# run is planned at the slot edge. -CHARGE_LATCH_GRACE_S = 900.0 -FORECAST_READ_S = 60.0 - - def _load_latch(data, now=None): """`(until, {name: level %}, born)` from the store, or None if it is not a well-formed latch or has ended (OPT-34).""" @@ -219,129 +179,8 @@ def _load_latch(data, now=None): return until, levels, born -def _night_limit(born): - """OPT-34: a run that starts after this cannot carry the latch on: the - first bell start (`solarfc.BELL_FROM_H`, local) after `born`.""" - d0, d1 = solarfc._bounds(born) - bell = solarfc._bell_start(d0) - return bell if bell > born else solarfc._bell_start(d1) - - -def why_raised(resolution, claims) -> bool: - """F2 (OPT-11 P1 test): did the limit that bound the site target push it - ABOVE what the winner asked (the peak floor asking for more power)? Then - the Why line must not say "held back". False when nothing bound it.""" - if not resolution.bound_by or resolution.contradiction: - return False - asked = next((c.value for c in sorted( - (c for c in claims if c.kind == SET - and c.actuator == ACTUATOR_SITE and c.source == resolution.winner), - key=lambda c: c.priority, reverse=True)), None) - return asked is not None and resolution.target_w > float(asked) - - -def pack_energy_of(inv): - """OPT-09. ONE roster entry as the view `packshare` consumes. - One builder for two callers (`pack_energy` needs the roster, `cycle()` one - entry): written twice they would drift. A module function so inverter.py - need not import `packshare`.""" - return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor, - inv.capacity_kwh, inv.max_w) - - -# SAFETY-73: the evidence must also span this long, not only SETTLE_CYCLES -# cycles: a 1 s meter makes 12 cycles only 11 s, shorter than a healthy battery -# reading's lag, and both inverters were refused after ordinary load steps. -REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S - -# SAFETY-94: evidence restarts when the reading has moved this far toward the -# ask. A refusing battery reads flat; live readings wander under 25 W at rest; -# a first-order lag gains at most 0.4 tau x 100 W across the band, so the -# evidence cannot span REFUSED_SPAN_S unless the lag is over 54 s. -REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2 - -# 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 - - -def _refused_interval(sign: int) -> tuple: - """SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way.""" - return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf) - - -def why_key(winner: str, claims) -> str: - """The key of `messages.WHY_WINNER` for a resolution's winner. "safety" and - "failsafe" stand for several causes, so their own claim reason is the - key ("stopped", "inputs missing", "sign disagreement", or "unsplit" when - this module refuses to split the power).""" - if winner == "loop": - # SAFETY-52: a loop holding a refused direction says so. - return next((c.reason for c in claims if c.source == "loop" - and c.reason in (_ctl.REFUSED, _ctl.PROBING)), winner) - if winner in ("safety", "failsafe"): - return next((c.reason for c in claims - if c.source == winner and c.kind == "set"), - "no claim was made" if not any( - c.kind == "set" for c in claims) else "unsplit") - return winner - - -def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float, - provider: bool, max_age_s: float) -> str: - """The *Tariff* status row: the 30 s tick's sentence AND the provider plan - (TARIFF-11): two sources, two clocks, and the row says so. The plan is - stamped with its own age from `tariff_at` (not moved on a kept plan, - TARIFF-12), up to TARIFF_MAX_AGE_S (5400 s); `age_s` is the caller's - measurement. "No plan" is said aloud on a site that configured a provider. - DECISIONS/MAIN-orchestration.md.""" - # UX-08: plain words, every figure kept. The tick's sentence is "now"; the - # plan carries its own age. - if plan is None: - if not provider: - return tick_words - return (f"{tick_words}. No price plan: none yet, or the last one is " - f"over {max_age_s:.0f} s old") - return (f"{tick_words}. Price plan from {age_s:.0f} s ago, for " - f"{horizon_h:.0f} hours ahead: {plan.words}") - - -def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now, - why: str) -> str: - """The *Arbitrage* status row: three states (TARIFF-11): off (not armed), - armed with no decision this cycle (`why` is the loop's reason), or a - decision with its age. - `on` is read from the options, never inferred from `decision is None`: - `cycle()` returns at the input hold before `self.arb = None`, so the old - row named an option that was ON. The age is always printed, even 0. - DECISIONS/MAIN-orchestration.md.""" - # UX-08: plain words; the setting is named by its label. - if not on: - return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not " - f"switched on") - if decided_at is None: - return (f"On, but no decision yet, because control has not got that " - f"far. Control says: {why or 'no reason recorded'}") - age = (now - decided_at).total_seconds() - stale = cycle_at is not None and decided_at < cycle_at - head = (f"On, no decision this cycle ({why or 'no reason recorded'}), " - f"the last one {age:.0f} s ago: " if stale else - f"On, decided {age:.0f} s ago: ") - if decision is None: - return (f"On, no decision this cycle ({why or 'no reason recorded'}) " - f"and none before") - return head + decision.explain() - - -class Controller: +class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, + StatusMixin, TasksMixin): 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", @@ -356,42 +195,32 @@ class Controller: self.store = store self.mqtt = mqtt_pub # REQUIRED and keyword-only: a default `stop=None` made a Controller - # that cannot stop itself (inert but looks healthy on the late-refusal - # path in run_control()). DECISIONS/MAIN-orchestration.md. + # that cannot stop itself. DECISIONS/MAIN-orchestration.md. self.stop_event = stop self.refused: list[str] | None = None - # First, because the store migration below can log to it: 15's action - # log is how an operator learns a latch was re-armed, and an - # AttributeError here would stop the add-on booting. + # First: the store migration below can log to it. + # DECISIONS/MAIN-orchestration.md. self.events: list[str] = [] # UX-08: the last event in plain words, for the page. See log_event(). self.hint = "" - # DEV-02: the roster, built before anything reads a rating; every - # per-device value lives on these objects (app/inverter.py), but a - # `Tuning` and the integrator are deliberately NOT per device (6.2: one - # integrator on P1 above the split). The store migration runs here, - # BEFORE the first `Inverter` reads its SAFETY-02 latch, else a tripped - # install comes up ARMED with a green banner. - # DECISIONS/MAIN-orchestration.md. + # DEV-02: the roster, built before anything reads a rating. The store + # migration runs here, BEFORE the first `Inverter` reads its SAFETY-02 + # latch. DECISIONS/MAIN-orchestration.md. self.sign_cfg = sign_cfg = SignWatchdogConfig( min_w=float(opts.get("sign_disagree_w", 300)), hold_s=float(opts.get("sign_disagree_s", 180)), ) - # SAFETY-30: the per-unit comparison's own pair, re-derived at two - # inverters, not carried across (a command against one unit's own power - # is a different quantity). DECISIONS/SAFETY-30-per-unit-sign.md; - # watchdog.SIGN_OPTION_DEFAULTS. + # SAFETY-30: the per-unit pair, re-derived at two inverters, not + # carried. DECISIONS/SAFETY-30-per-unit-sign.md. self.unit_sign_cfg = SignWatchdogConfig( min_w=float(opts.get("unit_sign_disagree_w", SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"])), hold_s=float(opts.get("unit_sign_disagree_s", SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"])), ) - # SAFETY-30: the site comparison's own pair, used only at N>=2. The 300 - # W threshold is carried (meter noise and deadband); the window is not - # (110 s at one inverter, 215 s at two, against a 180 s hold). See - # watchdog.SIGN_OPTION_DEFAULTS. + # SAFETY-30: the site pair, used only at N>=2. + # DECISIONS/MAIN-orchestration.md. self.site_sign_cfg = SignWatchdogConfig( min_w=float(opts.get("site_sign_disagree_w", SIGN_OPTION_DEFAULTS["site_sign_disagree_w"])), @@ -405,11 +234,8 @@ class Controller: hold_s=float(opts.get("delivery_hold_s", DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])), ) - # SAFETY-44: hand the migration the names the machines read, - # `roster_names(opts)[0]`, never `UNIT_A` (on a declared roster the - # latch landed under `unit/inverter-a/`, on no roster, and stamped the - # marker). `roster_names()` is `[UNIT_A]` on the flat shape, so today's - # path is byte-identical. DECISIONS/MAIN-orchestration.md. + # SAFETY-44: the migration gets `roster_names(opts)[0]`, never + # `UNIT_A`. DECISIONS/MAIN-orchestration.md. _roster_names = roster_names(opts) self.store_migration = migrate_unit_keys( store, _roster_names[0], _roster_names) @@ -418,10 +244,8 @@ class Controller: # restart and shadow mode (DECISIONS/SMA-10-two-idle-states.md). handback.set_roster(handback.units_of(self.inverters)) uitext.set_idle(handback.overlay(handback.current())) - # CFG-04: say which shape is in force. A declared `inverters:` roster - # ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which - # the UI still shows; validate.py rule 9(e) refuses only the dangerous - # one, so the rest are DISCLOSED in one action-log line at every start. + # CFG-04: a declared roster ignores flat keys the UI still shows; say + # so once at every start. DECISIONS/MAIN-orchestration.md. if roster_entries(opts): self.log_event( "roster: %d inverters from `inverters:` - %s. The flat " @@ -433,14 +257,11 @@ class Controller: self.tuning = Tuning( gain=float(opts.get("gain", 0.6)), - # The SITE bound is summed from the roster, not `opts["max_w"]`: it - # is the integrator bound and rail of the site loop, split across - # inverters. Identical to `max_w` at N=1; a per-unit `max_w` still - # binds each share through its `supervised` claim. + # The SITE bound is summed from the roster, not `opts["max_w"]`. + # DECISIONS/MAIN-orchestration.md. max_w=self.site_max_w, - # SAFETY-39: the fallback is 0, meaning "follow the rail" - # (config.yaml and `Tuning`). It was 1000, a stale third copy. - # `control.slew_limit_w()` is the one reader. + # SAFETY-39: 0 means "follow the rail"; `control.slew_limit_w()` + # is the one reader. DECISIONS/MAIN-orchestration.md. slew_w=float(opts.get("slew_w", 0) or 0), deadband_w=float(opts.get("deadband_w", 15)), target_grid_w=float(opts.get("target_grid_w", 0)), @@ -453,14 +274,9 @@ class Controller: integrator_max_w=(float(opts["integrator_max_w"]) if opts.get("integrator_max_w") else None), ) - # CFG-03: no `opts.get(key, )` for maintenance here; - # `MaintConfig.from_options()` reads them next to - # `maintenance.MAINT_OPTION_DEFAULTS`; a new key goes in config.yaml - # `options:`/`schema:` and that dict only. MAINT-05: one `Maintenance` - # per roster entry; the history migration runs first (else a pack - # believes it was never recalibrated) and targets - # `self.inverters[0].name`, NOT `UNIT_A`; the roster is passed so - # `migrate_maint_keys()` grades the namespace it wrote. + # CFG-03: `MaintConfig.from_options()` reads the options. MAINT-05: + # one `Maintenance` per roster entry; the history migration runs first, + # onto `self.inverters[0].name`, NOT `UNIT_A`. # DECISIONS/MAIN-orchestration.md. self.maint_migration = migrate_maint_keys( store, self.inverters[0].name, [inv.name for inv in self.inverters]) @@ -950,234 +766,6 @@ class Controller: # SAFETY-61. time.monotonic() at the end of the last run_control() pass. self.loop_pass_mono = None - # helpers; SAFETY-52: a battery that refuses is asked for 0 W - def _refusal_probe(self) -> bool: - """True while a refused direction may try `PROBE_W` (every 15 min).""" - if (not self.batt_refused or self.batt_refused_at is None - or self._now_mono is None): - return False - held = self._now_mono - self.batt_refused_at - if held >= _ctl.PROBE_EVERY_S + _ctl.PROBE_S: - self.batt_refused_at = self._now_mono - return False - return held >= _ctl.PROBE_EVERY_S - - def _refusal_start(self, sign: int, evidence: str) -> None: - self.batt_refused = sign - self.batt_refused_at = self._now_mono - self.log_event(f"BATTERY REFUSES {evidence}", - hint=messages.hint("battery_refuses")) - _LOG.info("The battery refuses to give power, so the add-on asks 0 W " - "and tries %.0f W every %.0f minutes: BATTERY_REFUSES.", - _ctl.PROBE_W, _ctl.PROBE_EVERY_S / 60) - _LOG.debug("Evidence: BATTERY_REFUSES: %s.", evidence) - - def _refusal_end(self, why: str) -> None: - if not self.batt_refused: - return - self.batt_refused, self.batt_refused_at = 0, None - self.log_event(f"BATTERY REFUSES ENDED: {why}", - hint=messages.hint("battery_gives")) - _LOG.info("The battery refusal ends, control asks for power again: %s.", - why) - - def _refusal_update(self, decision, asked_w, batt_w, probe, - rejudged: int = 0) -> None: - """Follow `compute()`'s carried refused direction, and say so. - `rejudged`: the refusal held while another writer had the wire, so - `compute()` judged it afresh against that writer's ask (SAFETY-107).""" - now = decision.refused or 0 - if rejudged: - if now == rejudged: - # Still refused against the ask on the wire: the same episode. - # The probe clock restarts: a probe is the loop's ask and is - # not on the wire while another writer is. - self.batt_refused_at = self._now_mono - return - gave = (rejudged * (batt_w - self._site_idle_w()) - >= _ctl.PROBE_ANSWER_W) - self._refusal_end("the battery gives power" if gave - else "another writer") - if now and not self.batt_refused: - self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery " - f"gave {batt_w:+.0f} W for " - f"{decision.sat_count} cycles") - elif self.batt_refused and not now: - gave = (self.batt_refused * (batt_w - self._site_idle_w()) - >= _ctl.PROBE_ANSWER_W) - self._refusal_end("the probe was answered" if gave and probe - else "the battery gives power" if gave - else "the grid now asks the other direction") - - def _refusal_from_stale(self, inv, now_mono) -> None: - """A stale-reading stop while the loop is frozen on ~0 W is a refusal. - Only at one inverter: with several, one empty battery must not stop - the site (OPT-09 moves its share).""" - t = self.tuning - standing = inv.standing_w() - if len(self.inverters) > 1: - # SAFETY-73: at N>1 the same trip refuses THIS unit only. - if ((self.auto or self.shadow) and not inv.refused - and inv.ref_count >= t.saturation_cycles - and inv.batt_fresh.held is not None - and abs(inv.batt_fresh.held - inv.idle_w) - < _ctl.REFUSED_BELOW_W - and abs(standing) > _ctl.PROBE_W): - self._now_mono = now_mono - self._unit_refusal_start( - inv, 1 if standing > 0 else -1, - f"stale reading {inv.batt_fresh.held:+.0f} W while asked " - f"{standing:+.0f} W") - return - if (len(self.inverters) == 1 and (self.auto or self.shadow) - and not self.batt_refused - and self.sat_count >= t.saturation_cycles - and inv.batt_fresh.held is not None - and abs(inv.batt_fresh.held - inv.idle_w) - < _ctl.REFUSED_BELOW_W - and abs(standing) > t.saturation_w): - self._now_mono = now_mono - self._refusal_start(1 if standing > 0 else -1, - f"stale reading {inv.batt_fresh.held:+.0f} W " - f"while asked {standing:+.0f} W") - - # SAFETY-73: SAFETY-52's decisions per inverter at N>1: a unit giving < - # REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in that - # direction by a P_SAFETY unit limit, so the allocator hands its share on; - # probed at PROBE_W every PROBE_EVERY_S, one unit at a time. The ask is - # PROBE_W, not `saturation_w`: a unit's share is often small and SAFETY-43 - # would name the empty battery as not delivering. - # DECISIONS/SAFETY-73-refusal-per-inverter.md. - def _unit_refusal_start(self, inv, sign: int, evidence: str) -> None: - inv.refused, inv.refused_at = sign, self._now_mono - inv.probe_from, inv.ref_count = None, 0 - self.log_event(f"BATTERY REFUSES {inv.name}: {evidence}", - hint=messages.hint("unit_refuses", unit=inv.name)) - _LOG.info("%s: the battery refuses power, so it is asked 0 W and the " - "other inverters take over: BATTERY_REFUSES.", inv.name) - _LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence) - # Only a refusal to GIVE: a full battery that stops taking charge is - # above its cut-out level by definition. - if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None - and float(inv.soc) > float(inv.bms_soc_floor)): - # Owner decision 3: a refusal above the configured cut-out level - # may mean that level is set wrong. One line, no safety stop. - _LOG.warning("%s refuses power above its battery cut-out level, " - "check its cut-out level: REFUSES_ABOVE_CUT_OUT.", - inv.name) - _LOG.debug("Evidence for %s: REFUSES_ABOVE_CUT_OUT: battery level " - "%.0f %%, cut-out level %.0f %%.", inv.name, - float(inv.soc), float(inv.bms_soc_floor)) - - def _unit_refusal_end(self, inv, why: str) -> None: - if not inv.refused: - return - inv.refused, inv.refused_at, inv.probe_from = 0, None, None - inv.ref_count = 0 - self.log_event(f"BATTERY REFUSES ENDED {inv.name}: {why}", - hint=messages.hint("unit_gives", unit=inv.name)) - _LOG.info("%s: the battery refusal ends, it is asked for power again: " - "%s.", inv.name, why) - # The site target may stand above what was placed (the refused share - # was unmet). Restart it from the watts on the wire, or the reopened - # unit gets the parked remainder at once (phase A: -1059 W). - placed = float(sum(i.standing_w() for i in self.inverters)) - self.target = self.i_w = placed - self.sat_count = 0 - - def _unit_probe(self, inv) -> bool: - """True while `inv` may try PROBE_W. One unit probes at a time.""" - now = self._now_mono - if not inv.refused or inv.refused_at is None or now is None: - return False - if inv.probe_from is not None: - if now - inv.probe_from < _ctl.PROBE_S: - return True - inv.probe_from, inv.refused_at = None, now - return False - if now - inv.refused_at < _ctl.PROBE_EVERY_S or any( - i.probe_from is not None for i in self.inverters): - return False - inv.probe_from = now - return True - - def _unit_refusals(self) -> None: - """Engage and end each unit's refusal from its own reading.""" - for inv in self.inverters: - b = inv.batt - if inv.refused: - # SAFETY-97: given and taken are counted from the idle reading. - if (b is not None and inv.refused * (b - inv.idle_w) - >= _ctl.PROBE_ANSWER_W): - self._unit_refusal_end( - inv, "the probe was answered" - if inv.probe_from is not None - else "the battery gives power") - continue - # The other direction: the battery takes PROBE_ANSWER_W or more - # the other way for SETTLE_CYCLES, counted on its own reading - # so one overshoot through zero does not hand its share back. - inv.ref_count = (inv.ref_count + 1 if b is not None and - -inv.refused * (b - inv.idle_w) - >= _ctl.PROBE_ANSWER_W else 0) - if inv.ref_count >= _ctl.SETTLE_CYCLES: - self._unit_refusal_end( - inv, "the battery takes power the other way") - continue - if inv.rejected_since is not None: - # SAFETY-96: its writes are refused, so its board may have gone - # to 0 W on its own; that is no refusal by the battery. The - # count is kept, as across a gap. - continue - sign = self._gave_nothing(inv) - if sign: - self._unit_refusal_start( - inv, sign, - f"asked {inv.standing_w():+.0f} W, the battery gave " - f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles") - - def _gave_nothing(self, inv) -> int: - """The refusal evidence, one cycle of it: the sign `inv` has given - under REFUSED_BELOW_W while asked over PROBE_W, one way, for - SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1; - SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the - band is centred on the unit's idle reading.""" - b = inv.batt - if b is None: - return 0 # a gap keeps the count: the gate's trip seeds - standing = inv.standing_w() - sign = 1 if standing > 0 else -1 - # One direction throughout: a command that swaps sign every cycle reads - # ~0 through the plant's lag, which is a battery following, not - # refusing (TEST-19). - if (abs(standing) > _ctl.PROBE_W - and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W): - # SAFETY-94: a reading still moving towards the ask is a battery - # catching up through its lag: start again. - if (sign != inv.ref_sign or not inv.ref_count - or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W): - inv.ref_count, inv.ref_since, inv.ref_from = ( - 0, self._now_mono, b) - inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES) - else: - inv.ref_count = 0 - inv.ref_sign = sign - if inv.ref_count >= _ctl.SETTLE_CYCLES and ( - inv.ref_since is None or self._now_mono is None - or self._now_mono - inv.ref_since >= REFUSED_SPAN_S): - return sign - return 0 - - def _site_idle_w(self) -> float: - """SAFETY-97: the site reading at idle, the sum of the units'.""" - return float(sum(inv.idle_w for inv in self.inverters)) - - def _site_refused(self): - """Owner decision 4: every unit refuses the same way, so the site law - holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law.""" - signs = {inv.refused for inv in self.inverters} - return signs.pop() if len(signs) == 1 and 0 not in signs else None - def log_event(self, msg: str, hint: str | None = None) -> None: """`msg` goes on the action log, the add-on's own technical record. `hint` is `messages.hint()`'s plain words for the same event, and is @@ -1188,88 +776,6 @@ class Controller: if hint is not None: self.hint = f"{datetime.now().astimezone():%H:%M} {hint}" - def _grade_store_migration(self, store) -> None: - """DEV-02. Did SAFETY-02's latch actually survive the key rename? - Graded, not performed: a latch that silently fails to move is a - DISARMED SAFETY GUARD on an install that believes it is protected. The - pre-DEV-02 flat value is compared with what the roster came up with; a - disagreement re-arms the latch by hand (the fail-safe direction is ON). - It runs on EVERY start, not only the migrating one: gating on `rep.ran` - swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip - in the window, upgrade; performed on this site 2026-09-02). - DECISIONS/MAIN-orchestration.md.""" - rep = self.store_migration - if rep.ran and not rep.ok: - _LOG.error("The saved safety stops did not copy over cleanly, so " - "they are read again from the older saved state: %s.", - rep.explain()) - data = getattr(store, "data", {}) - legacy_tripped = bool(data.get("sign_tripped", False)) - if not legacy_tripped or not self.inverters: - return - # The question is "is any namespaced latch set THAT A MACHINE READS" - # (SAFETY-44), asked of the STORE's keys, not the roster's objects: - # re-arming A off a flat key that B's trip mirrored would latch a - # healthy inverter on every restart. Three cases: on the roster, stop; - # off-roster named `UNIT_A` (the broken migration's fingerprint), - # repair onto `inverters[0]`; off-roster, other name: stop unless a - # stranded `UNIT_A` latch is present. This guard is the ONLY repair for - # sites upgraded before the fix. Residual: a first unit really named - # `inverter-a`, latched and removed, is re-armed on `inverters[0]` - # (conservative). DECISIONS/MAIN-orchestration.md. - on_roster = {inv.name for inv in self.inverters} - stranded, on_roster_latched, other_off_roster = [], False, False - for k, v in data.items(): - if not (k.startswith("unit/") and k.endswith("/sign_tripped")): - continue - if not bool(v): - continue - # `unit_key()` builds `unit//`, so the unit is field 1: - # split through the writer's own spelling. - unit = k.split("/")[1] - if unit in on_roster: - on_roster_latched = True - elif unit == UNIT_A: - stranded.append(k) - else: - other_off_roster = True - if on_roster_latched or any(inv.sign_wd.tripped - for inv in self.inverters): - return - if other_off_roster and not stranded: - # A removed unit's own latch and no misdirected one: the flat key - # is that unit's mirror, so re-arming `inverters[0]` from it would - # latch a healthy pack. - return - if stranded: - _LOG.error( - "A saved safety stop belongs to %s, which is not in the " - "inverter list %s, so it is set again on the first inverter.", - ", ".join(sorted(stranded)), sorted(on_roster)) - first = self.inverters[0] - # The one state this method exists for. - reason = str(data.get("sign_reason", "") or "") - first.sign_wd.tripped = True - first.sign_wd.reason = reason or ( - "SIGN_DISAGREEMENT: latched before this version; the reason was " - "not carried across the per-inverter store migration") - first.store_set("sign_reason", first.sign_wd.reason) - first.store_set("sign_tripped", True) - _LOG.error( - "%s is set to 0 W again because an older saved safety stop was " - "found, so find out why before you start control: %s.", - first.name, messages.reason_code(first.sign_wd.reason)) - _LOG.debug("Evidence for %s: %s.", first.name, first.sign_wd.reason) - _LOG.debug( - "SAFETY-02 latch guard: the pre-DEV-02 flat key says the " - "sign-disagreement failsafe is latched and no inverter came up " - "latched. Either the per-inverter migration lost it, or an older " - "add-on wrote it during a rollback. Both would be a disarmed " - "guard, so the latch is re-armed on %s.", first.name) - self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - " - + first.sign_wd.reason, - hint=messages.hint("stop_restored", unit=first.name)) - # the roster, and the N=1 views of it @property def units(self) -> tuple: @@ -1437,19 +943,6 @@ class Controller: the second inverter. `max_w` exactly at N=1.""" return float(sum(inv.max_w for inv in self.inverters)) - def trade_power_w(self, floor_pct, ceiling_pct) -> tuple: - """OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED: - what the site can push AND STILL HAVE SOMEWHERE TO PUT. The bounds are - arbitrage's own (`arbitrage_soc_floor` / `_ceiling`), NOT the BMS - cut-out that `packshare.envelope()` uses (SAFETY-25): different - questions. It sizes the ask, it does not route it. A pack whose SoC did - not read counts on neither side. Neither side may be zero on a trading - tick (`arbitrage.decide()` refuses `IDLE_NO_SIZE`: a 0 W claim is a - park order). DECISIONS/MAIN-orchestration.md.""" - # The reduction lives in `packshare`; `test_packshare.py` part 9 calls - # the SAME function (a transcription in a check is a copy that drifts). - return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct) - @property def site_rating_w(self) -> float: """The largest magnitude the SITE can be commanded, in W: SUM of each @@ -1459,11 +952,6 @@ class Controller: contributes `max_w`.""" return float(sum(inv.rating_w() for inv in self.inverters)) - def peak_aim_w(self) -> float: - """SAFETY-79. `peak_cap_w` less `peak_margin_w`: what the forecast - checks aim at. Reports and the bill use the real cap.""" - return float(self.o.get("peak_cap_w", 3500)) - self.peak_margin_w - def site_dev_bound(self, which: str): """The roster's summed device floor / ceiling, or None if unknown. `freeze.looks_frozen()` and `rail_unreachable()` SKIP a bound handed @@ -1521,3554 +1009,6 @@ class Controller: return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor) for inv in self.inverters] - @property - def pack_energy(self) -> list: - """OPT-09. Each pack as the FOUR numbers a split needs; the fourth is - `capacity_kwh`, which is why this is not `packs`. EVERY unit, live - or not, for `packs`'s reason (the allocator drops dead ones itself).""" - return [pack_energy_of(inv) for inv in self.inverters] - - def pack_weights(self, target_w): - """OPT-09. The comfort weights for a site target of `target_w`. A - method handed to `resolve()`, not a dict computed before it (energy - headroom depends on the target), and read fresh on every call, never - cached.""" - base = packshare.weights(self.pack_energy, target_w) - if not self.modes_on: - return base - # OPT-11: the solar order. While the site charges, Leftover solar - # batteries weigh 0, so Solar first fill first (comfort, like the rest - # here; permission is the mode claim). - w = packmode.charge_weights(base, self.pack_modes(), target_w) - # P3: in a price run, the batteries whose run it is carry it first. - tick = self.mode_tick - return w if tick is None else modetrade.weights(tick.roles, w, - target_w) - - def pack_mode(self, inv) -> "packmode.PackMode": - """OPT-11. This battery's on/off switch and mode, read from the store.""" - return packmode.read(getattr(self.store, "data", None) or {}, inv.name) - - def pack_modes(self) -> dict: - return {inv.name: self.pack_mode(inv) for inv in self.inverters} - - def loop_bound(self, claims): - """OPT-11. `(lo, hi)`: what the batteries may place if the loop - decides: the sum over live units of each unit's hard limits with its - mode applied (`allocator.loop_bound()`), from THIS cycle's unit - claims. None when no mode is filed (maintenance) or the range leaves - out 0 W (a fault `resolve()` reports).""" - if len(self.inverters) <= 1: - modes = [c for c in claims if c.source == "mode"] - if not modes: - return None - lo = max(c.lo for c in modes) - hi = min(c.hi for c in modes) - return (lo, hi) if lo <= 0.0 <= hi else None - bounds = {u: pre_resolve([c for c in claims if c.actuator == u]) - for u in self.units} - return allocator.loop_bound(bounds, live=self.live_units()) - - # --- OPT-11 P3: price runs, per battery ------------------------------- - def _mode_packs(self, maint_results=None) -> list: - """Each battery as `modetrade.Pack`, in roster order.""" - live = set(self.live_units()) - out = [] - for i, inv in enumerate(self.inverters): - pm = self.pack_mode(inv) - idle = (maint_results is None - or maint_results[i].phase == IDLE) - refused = (inv.refused if len(self.inverters) > 1 - else self.batt_refused) or 0 - # OPT-15: a charge refusal's probe (SAFETY-73, N>1) as it stood - # when last decided; its pin is filed after this, in cycle(). - probing = (len(self.inverters) > 1 and refused < 0 - and inv.probe_from is not None) - out.append(modetrade.Pack( - inv.name, pm.use, pm.mode, pm.sell and pm.can_sell, - float(inv.max_w), inv.soc, inv.capacity_kwh, - live=inv.name in live, idle=idle, refused=int(refused), - probing=probing)) - return out - - def _wx_track(self, now) -> None: - """TARIFF-24, once per cycle, before the plan: with a forecast read, - remember when the run now under way (cheap, give or sell) ends.""" - if self.solar_fc is None: - return - rp = self.mode_runs - ends = [] - if rp is not None and rp.on: - ends = [r.end for r in (rp.cheap, rp.sell) - if r is not None and r.holds(now)] - g = rp.give_at(now) - if g is not None: - ends.append(g.end) - self._wx_free_to = max(ends) if ends else None - - def weather_held(self, now) -> bool: - """TARIFF-24: no forecast now, but a run planned with one is under way: - it keeps its gap to its end (OPT-34: a night charge that keeps its - level too); the wider gap applies from the next run. Pure; Home asks - the same (`homeview.trade_verdict()`). - (DECISIONS/MAIN-orchestration-archive.md)""" - return self.solar_fc is None and ( - (self._wx_free_to is not None and now < self._wx_free_to) - or (self._charge_latch is not None - and now < self._charge_latch[0])) - - def _price_runs(self, now, packs, floor_pct, ceiling_pct): - """The kept run plan, rebuilt at most once per price slot (15 min) or - when the prices or a battery's choice change; a run under way is kept - to its end (`priceruns.keep_started()`). None with no fresh firm - prices: then no battery holds or trades. - (DECISIONS/MAIN-orchestration-archive.md)""" - plan = self.fresh_tariff_plan(now) - if plan is None or not plan.has_prices: - self.mode_runs, self._runs_key = None, None - self.day_plan = None - return None - slot = int(now.timestamp() // 900) - key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell) - for p in packs)) - held = self.weather_held(now) - if self.fc_entity is not None: - # P4: a forecast arriving or going rebuilds the plan at once (and - # so does the end of a run that held the gap, TARIFF-24). - key += (self.solar_fc is None, held) - if key != self._runs_key: - done_c = self._run_done.get("charge", (None, set()))[1] - done_s = self._run_done.get("sell", (None, set()))[1] - e = modetrade.energy(packs, floor_pct, ceiling_pct, done_c, - done_s) - required = price_required_spread( - arbitrage.threshold_eur_kwh(self.o), - price_uncertainty_of(plan.read.product) - | arbitrage.weather_uncertainty(True, self.solar_fc, held)) - ref = self._ref_cheap - if ref is not None and now - ref.end > REF_CHEAP_KEEP: - ref = self._ref_cheap = None - new = priceruns.plan( - plan.read.points, now, required_eur_kwh=required, - charge_kwh=e[0], charge_kw=e[1], sell_kwh=e[2], - sell_kw=e[3], - ref_eur_kwh=None if ref is None else ref.mean_eur_kwh) - if self.fc_entity is not None: - new = self._day_plan(now, packs, floor_pct, ceiling_pct, - new, plan, required, ref, done_c, e) - self.mode_runs = priceruns.keep_started(self.mode_runs, new, now) - self._runs_key = key - runs = self.mode_runs - # The cheapest cheap run under way lately is the price the stored - # energy was bought at; it stays the reference for REF_CHEAP_KEEP - # after it ends, unless a cheaper one comes. - ref = self._ref_cheap - if runs is not None and runs.cheap is not None \ - and runs.cheap.holds(now) and ( - ref is None or now - ref.end > REF_CHEAP_KEEP - or runs.cheap.mean_eur_kwh <= ref.mean_eur_kwh): - self._ref_cheap = runs.cheap - return runs - - def _day_plan(self, now, packs, floor_pct, ceiling_pct, runs, plan, - required, ref, done_c, e): - """OPT-11 P4: the day plan (`dayplan.plan()`) on the runs just made, - its levels held by the hysteresis (`dayplan.hold()`), then the runs - made again with each battery's shortfall as the charge. With no plan - (no or stale forecast, no house profile yet) the levels are dropped: P3 - option A. (DECISIONS/MAIN-orchestration-archive.md)""" - dp = dayplan.plan( - self.solar_fc, - None if self.house_profile is None - else self.house_profile.estimator(), - priceruns.keep_started(self.mode_runs, runs, now), now, packs, - trust_pct=self.solar_trust_pct, floor_pct=floor_pct, - ceiling_pct=ceiling_pct, aim_w=self.peak_aim_w(), - slots=priceruns.firm_slots(plan.read.points, now), - spread=required) - self.day_plan = dp - if dp is None: - self._plan_targets = None - return runs - self._plan_targets = dayplan.hold( - self._plan_targets, dp.targets, - {p.name: p.capacity_kwh for p in packs}) - e2 = modetrade.energy(packs, floor_pct, ceiling_pct, done_c, (), - targets=self._plan_targets) - return priceruns.plan( - plan.read.points, now, required_eur_kwh=required, - charge_kwh=e2[0], charge_kw=e2[1], sell_kwh=e[2], sell_kw=e[3], - ref_eur_kwh=None if ref is None else ref.mean_eur_kwh) - - def plan_targets(self): - """P4: each battery's planned level, or None (no day plan). Dropped - at once when the forecast goes (stale or missing), except the level - of a night charge that has started (OPT-34): it keeps that level.""" - if self.solar_fc is None: - lt = self._charge_latch - if lt is None or datetime.now(timezone.utc) >= lt[0]: - return None - return dict(lt[1]) - return self._plan_targets - - def _latch_charge(self, now, runs, targets, packs=None) -> None: - """OPT-34, once per cycle after `modetrade.decide()`: a started night - grid charge keeps its planned level when the forecast goes mid-charge - (`plan_targets()` gives the latched levels, `weather_held()` the narrow - price gap). The latch lasts through every cheap run plus one price slot - (`CHARGE_LATCH_GRACE_S`), is dropped when `now` passes its end or the - battery leaves its mode, and a charge that never started has none. - DECISIONS/OPT-34-the-night-charge-keeps-its-level.md""" - old = lt = self._charge_latch - tick = self.mode_tick - run = None if runs is None else runs.cheap - in_run = run is not None and run.holds(now) - grace = timedelta(seconds=CHARGE_LATCH_GRACE_S) - if lt is not None and now >= lt[0]: - lt = None - if lt is not None and packs is not None: - ok = {p.name for p in packs - if p.use and p.mode in packmode.PLAN_CHARGE_MODES} - lv = {n: v for n, v in lt[1].items() if n in ok} - lt = (lt[0], lv, lt[2]) if lv else None - # A run may carry the latch on only if it starts by the night's bell. - carry = in_run and (lt is None or run.start <= _night_limit(lt[2])) - if in_run and self.solar_fc is not None: - # Rebuilt from the plan: a battery already latched follows its - # level up or down whatever its role this tick (a fresh figure may - # lower it below the SoC), one that left the plan is dropped, and - # a new one comes in only while it charges from the grid. - tg = targets or {} - lv = {n: float(tg[n]) for n in (lt[1] if lt else {}) if n in tg} - if tick is not None and tick.action == modetrade.CHARGE: - lv.update({n: float(tg[n]) for n, r in tick.roles.items() - if r == modetrade.CHARGE and n in tg}) - if not lv: - lt = None - elif lt is None: - lt = (run.end + grace, lv, now) - else: - lt = (max(run.end + grace, lt[0]) if carry else lt[0], lv, - lt[2]) - elif lt is not None and carry: - lt = (max(lt[0], run.end + grace), lt[1], lt[2]) - if lt != old: - self._charge_latch = lt - self.store.set(CHARGE_LATCH_KEY, None if lt is None else { - "until": lt[0].isoformat(), "levels": lt[1], - "born": lt[2].isoformat()}) - - def _mode_roles(self, now, maint_results): - """OPT-11 P3: each battery's role and range this tick. Grid charging - and selling need a fresh plan that `TariffPlan.arbitrage()` allows with - the MEASURED peak guard (None refuses: nothing measures the quarter), - exactly the gate `arbitrage.decide()` has. - (DECISIONS/MAIN-orchestration-archive.md)""" - floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) - packs = self._mode_packs(maint_results) - self._wx_track(now) - runs = self._price_runs(now, packs, floor_pct, ceiling_pct) - guard = arbitrage.guard_state(self.peak_guard) - plan = self.fresh_tariff_plan(now) - charge_ok = sell_ok = False - trade_why = "not_covered" - - def allowed(active): - return plan.arbitrage( - peak_guard_active=active, - min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), - uncertainty=arbitrage.weather_uncertainty( - True, self.solar_fc, self.weather_held(now)), - now=now).match( - allowed=lambda _v: True, refused=lambda _v: False) - if guard is not None and plan is not None and runs is not None: - # A charge asks the guard (section 9.1 interlock); a sell only - # needs it to have measured the quarter, since discharge is never - # capped by it (a big discharge alone engages it on export). - sell_ok = allowed(False) - charge_ok = sell_ok and allowed(guard) - trade_why = (None if charge_ok else "peak" if sell_ok - else "not_worth") - self.mode_gate = None if charge_ok else trade_why - price_now = None - if plan is not None: - try: - pt = plan.read.match(series=lambda r: r.at(now), - no_data=lambda _r: None) - price_now = (None if pt is None or pt.provisional - else float(pt.eur_per_kwh)) - except Absence: - price_now = None - house = (None if self.grid is None or self.site_batt_w is None - else float(self.grid) + float(self.site_batt_w)) - if self.house_profile is not None and \ - self.house_profile.add(now, house): - self.store.set(HOUSE_PROFILE_KEY, self.house_profile.dump()) - targets = self.plan_targets() - if targets is None: - done_c = self._run_mark("charge", runs and runs.cheap, packs, - lambda p: p.soc is not None - and p.soc >= ceiling_pct) - else: - # P4: out of the run at its planned level. A level the - # hysteresis moved opens the run to it again (a new mark key). - def full(p): - to = modetrade.charge_to(p, ceiling_pct, targets) - return p.soc is not None and (to is None or p.soc >= to) - done_c = self._run_mark("charge", runs and runs.cheap, packs, - full, tuple(sorted(targets.items()))) - done_s = self._run_mark("sell", runs and runs.sell, packs, - lambda p: p.soc is not None - and p.soc <= floor_pct) - quarter = int(now.timestamp() // 900) - was = self.mode_tick is not None and \ - self.mode_tick.action == modetrade.CHARGE - self.mode_tick = modetrade.decide( - runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok, - trade_why=trade_why, price_now=price_now, - headroom_w=self.arb_headroom_w, floor_pct=floor_pct, - ceiling_pct=ceiling_pct, house_w=house, charging_now=was, - blocked=self._charge_blocked_q == quarter, - done_charge=done_c, done_sell=done_s, targets=targets, - draining=any(r.phase == DRAIN for r in maint_results or ())) - self._latch_charge(now, runs, targets, packs) - # A charge the guard or the headroom stopped stays off this period. - # OPT-15: only a MEASURED stop latches; the first unmeasured headroom - # in a quarter stops one tick, a second latches. - # (DECISIONS/MAIN-orchestration-archive.md) - if was and self.mode_tick.action != modetrade.CHARGE: - why = self.mode_tick.why - gap = why == "headroom" and self.arb_headroom_w is None - if why == "peak" or (why == "headroom" and not gap) or ( - gap and self._charge_gap_q == quarter): - self._charge_blocked_q = quarter - elif gap: - self._charge_gap_q = quarter - return self.mode_tick - - def _run_mark(self, kind, run, packs, out, extra=None) -> set: - """The batteries that left run `run` (full, or at the floor); they - stay out until it ends, so a level edge does not re-arm them. - `extra` (P4: the planned levels) is part of the run's key.""" - key = None if not run else (run.start, run.end) - if key is not None and extra is not None: - key += (extra,) - was_key, done = self._run_done.get(kind, (None, set())) - if key != was_key: - done = set() - if key is not None: - done |= {p.name for p in packs if out(p)} - self._run_done[kind] = (key, done) - return done - - def _mode_trade(self, claims, now) -> bool: - """OPT-11 P3: file the site trade the roles ask for, sized inside the - modes and the hard limits and, for a charge, the billed quarter's - headroom (SAFETY-45, aim cap - margin, SAFETY-79), so no battery that - its mode or a refusal holds is asked for watts it may not give. - (DECISIONS/MAIN-orchestration-archive.md)""" - tick = self.mode_tick - if tick is None: - return False - if tick.action == modetrade.CHARGE: - # OPT-20: the ask carries PROBE_W for exactly the probes whose pin - # is filed this tick (`decide()` ran before the pin, so they can - # differ by one cycle; left alone the allocator moves the pin's - # watts to another battery). - # (DECISIONS/MAIN-orchestration-archive.md) - pins = _ctl.PROBE_W * sum( - 1 for c in claims if c.source == "refused" - and c.kind == "set" and c.value < 0) - if pins != tick.probe_w: - tick = self.mode_tick = dataclasses.replace( - tick, ask_w=tick.ask_w + tick.probe_w - pins, - probe_w=pins) - size = modetrade.size(tick, self.loop_bound(claims) - if tick.action else None, self.arb_headroom_w) - runs = self.mode_runs - if tick.action and (size == 0.0 or runs is None): - # Nothing the batteries may place (a refusal, a hard limit): no - # trade, and the roles say so rather than "charging". - back = {modetrade.CHARGE: modetrade.HOLD, - modetrade.REST: modetrade.HOUSE, - modetrade.SELL: modetrade.GIVE} - tick = self.mode_tick = modetrade.Tick( - tick.ranges, {n: back.get(r, r) for n, r in tick.roles.items()}, - None, 0.0, "size") - if size == 0.0 or runs is None: - reason = ("battery modes: no price run now" if tick.why is None - else f"battery modes: no trade ({tick.why})") - self.arb = arbitrage.ArbitrageDecision( - action=None, power_w=None, reason=reason, - spread_eur_kwh=None, required_eur_kwh=None, - price_now_eur_kwh=None) - else: - charge = tick.action == modetrade.CHARGE - run = runs.cheap if charge else runs.sell - who = ", ".join(n for n, r in sorted(tick.roles.items()) - if r == tick.action) - act = arbitrage.CHARGE if charge else arbitrage.DISCHARGE - self.arb = arbitrage.ArbitrageDecision( - action=act, power_w=size, - reason=(f"{act}: battery modes, " - f"{'cheap' if charge else 'sell'} run " - f"{run.start.isoformat()} to {run.end.isoformat()} " - f"at {run.mean_eur_kwh:.4f} EUR/kWh; " - f"{abs(size):.0f} W on {who}"), - spread_eur_kwh=None, required_eur_kwh=runs.level_eur_kwh, - price_now_eur_kwh=None) - claims.append(arbitrage.claim(self.arb)) - if self.arb.action != self._arb_action: - self._arb_action = self.arb.action - self.log_event(self.arb.explain(), hint=messages.hint( - "trade_charge" if self.arb.action == arbitrage.CHARGE else - "trade_give" if self.arb.action == arbitrage.DISCHARGE - else "trade_off")) - return self.arb.action is not None - - def _reseed_run_edge(self, claims, now) -> None: - """OPT-11 P3: a mode trade just ended, so the loop takes over from a - command no loop chose: it starts at the house's own point E, not at the - trade's watts, and OPT-15 HOLDS it at E (`_edge_grid()`) until the - meter shows the step or `EDGE_HOLD_S` passes. - DECISIONS/OPT-11-battery-modes.md""" - self._edge = None - batt = self.site_batt_w - if batt is None or self.grid is None: - return - want = float(batt) + float(self.grid) - self.tuning.target_grid_w - bound = self.loop_bound(claims) - if bound is not None: - want = max(bound[0], min(bound[1], want)) - want = float(round(max(-self.site_max_w, min(self.site_max_w, want)))) - self.target = self.i_w = want - if want != float(batt): - self._edge = (want, 1.0 if want > float(batt) else -1.0, - now + timedelta(seconds=EDGE_HOLD_S), float(batt)) - - def _edge_grid(self, end, now): - """OPT-15: the grid reading the loop acts on this tick. While a run - edge is held, the target itself (no error, the command stays at E); the - hold ends when the meter reaches the target, on `end`, or after - `EDGE_HOLD_S`. (DECISIONS/MAIN-orchestration-archive.md)""" - e = self._edge - if e is None: - return self.grid - held_w, sign, until, last_b = e - tg, db = self.tuning.target_grid_w, self.tuning.deadband_w - b = self.site_batt_w - moving = (b is not None and sign * (float(b) - last_b) >= db - and sign * (held_w - float(b)) >= db - and now < until + timedelta(seconds=EDGE_HOLD_S)) - if (end or (now >= until and not moving) - or sign * (float(self.grid) - tg) < db): - self._edge = None - return self.grid - self._edge = (held_w, sign, until, last_b if b is None else float(b)) - self.i_w = held_w - return tg - - def _charge_track(self, claims, maint_results) -> None: - """SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can - no longer be placed (stale link, refusal). Kept until the loop runs. - SAFETY-95: a drain likewise; its refusal (SAFETY-73) is read from the - battery (+1 = will not give). (DECISIONS/MAIN-orchestration-archive.md)""" - if len(self.inverters) < 2: - return - live = set(self.live_units()) - now = {CHARGE: set(), DRAIN: set()} - for inv, res in zip(self.inverters, maint_results): - u = self.actuator_of("maintenance", inv) - if res.phase in now and u in live and not pre_resolve( - [c for c in claims if c.actuator == u]).contradiction \ - and not (res.phase == DRAIN and (inv.refused or 0) > 0): - now[res.phase].add(inv.name) - self._charge_ended |= self._charging_was - now[CHARGE] - self._charging_was = frozenset(now[CHARGE]) - self._drain_ended |= self._draining_was - now[DRAIN] - self._draining_was = frozenset(now[DRAIN]) - - def _reseed_charge_end(self, maint_results) -> None: - """SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop - starts from what the others stood at plus that battery's new command - (its HOLD pin, or its last discharge if it is free; a charge stops); - else the charge's watts land on the others (B 0 -> -1600 W in one - cycle). DECISIONS/SAFETY-93-charge-end.md""" - ended, self._charge_ended = self._charge_ended, frozenset() - if len(self.inverters) < 2 or not ended: - return - for inv, res in zip(self.inverters, maint_results): - if inv.name not in ended: - continue - now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint - else max(float(inv.target), 0.0)) - if not inv.is_live(datetime.now(timezone.utc))[0]: - now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W - self.target += now_w - float(inv.target) - # SAFETY-96: it settles from its charge to that command. - self._loop_start[inv.name] = now_w - self._settle[inv.name] = self._now_mono - self.i_w = self.target - - def _reseed_drain_end(self, maint_results, live) -> None: - """SAFETY-95, N>=2: when a battery's maintenance drain ends, its drain - leaves the loop's start point, as a charge does (SAFETY-93): the start - is what the others stood at plus its new command (its pin, else 0 W), - never above last cycle's start; else the drain's floor lands on the - others (B 0 -> +1600 W, exporting). DECISIONS/SAFETY-95-drain-end.md""" - ended, self._drain_ended = self._drain_ended, frozenset() - if len(self.inverters) < 2 or not ended: - return - hi = self.target - for inv, res in zip(self.inverters, maint_results): - if inv.name not in ended: - continue - now_w = (float(res.setpoint_w or 0.0) - if res.owns_setpoint and res.phase != DRAIN - else min(float(inv.target), 0.0)) - if inv.name not in live: - now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W - else: - self._drain_tail.add(inv.name) - self.target += now_w - float(inv.target) - self._loop_start[inv.name] = now_w - self._settle[inv.name] = self._now_mono - lo, hi = sorted((self.target, hi)) - # A dropped battery's power is the meter's, like the house's. - here = float(self.grid) - self.tuning.target_grid_w + sum( - float(inv.batt) for inv in self.inverters if inv.name in live) - self.target = self.i_w = min(hi, max(lo, here)) - - def _drain_grid_w(self, live) -> float: - """SAFETY-95: the grid the loop regulates. While a battery settles from - its ended drain (`_held_out_w`, at most PLANT_SETTLE_DOWN_S) the - batteries count at the site command, so the loop does not chase the - drain's tail. SAFETY-103: a reading can be up to a register period (10 - s) older than the meter, so the house point is held and moved only as - far as the meter proves. - DECISIONS/SAFETY-103-old-readings-after-a-drain.md""" - grid = float(self.grid) - self._drain_tail &= set(self._settle) & set(live) - if not self._drain_tail: - self._drain_house = None - return grid - aim = self.tuning.target_grid_w - lo = hi = grid - aim - for inv in self.inverters: - if inv.name not in live: - continue - r = float(inv.batt) - span = self._read_span.get(inv.name) - if (span is None or span[2] != inv.batt_fresh.changed_mono - or span[2] == self._now_mono): - span = (r, r) - # A battery that refuses one way (SAFETY-73) is not heading - # that way, whatever it was asked: that side is its reading. - if (inv.refused or 0) > 0: - span = (min(span[0], r), r) - elif (inv.refused or 0) < 0: - span = (r, max(span[1], r)) - lo, hi = lo + span[0], hi + span[1] - held = (float(self.target) if self._drain_house is None - else self._drain_house) - self._drain_house = min(hi, max(lo, held)) - return self._drain_house - float(self.target) + aim - - def _track_reads(self) -> None: - """SAFETY-103, N>=2: per battery, the range its power can be in now: - its last changed reading and every command on its wire since (a - lagging battery moves from one toward the others, never beyond).""" - if len(self.inverters) < 2: - return - for inv in self.inverters: - if inv.batt is None: - continue - cmd, since = inv.standing_w(), inv.batt_fresh.changed_mono - lo, hi, was = self._read_span.get(inv.name, (0.0, 0.0, None)) - if was is None or since != was: - lo = hi = float(inv.batt) - self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since) - - def _reseed_drop(self, live) -> None: - """SAFETY-96, N>=2: a battery that drops out of the split (stale link) - leaves the loop's start point, so the others take over at one slew step - a cycle instead of taking its share at once. - DECISIONS/SAFETY-96-dropped-battery.md""" - was, self._live_was = self._live_was, frozenset(live) - if len(self.inverters) < 2 or was is None or was == live: - return - for inv in self.inverters: - if inv.name in self._charge_ended | self._drain_ended: - continue # SAFETY-93/95's edge: `_reseed_*_end()` - if inv.name in was and inv.name not in live: - now_w = 0.0 - elif inv.name in live and inv.name not in was: - now_w = float(inv.batt) - # The split is made again, so every battery's command steps. - self._settle.update( - {u: self._now_mono for u in live}) - else: - continue - self.target += now_w - float(inv.target) - self._loop_start[inv.name] = now_w - self.i_w = self.target - - def _held_out_w(self, live) -> float: - """SAFETY-96, N>=2: what to add to the site sum so that a battery the - loop cannot move right now counts at its command, not at its reading. - Its real power still reaches the loop through the meter: neither lost - nor counted twice. DECISIONS/SAFETY-96-dropped-battery.md""" - if len(self.inverters) < 2: - return 0.0 - out = 0.0 - for inv in self.inverters: - cmd = self._loop_start.get(inv.name, float(inv.target)) - since = self._settle.get(inv.name) - age = (None if since is None or self._now_mono is None - else self._now_mono - since) - if since is not None and ( - age is None or age >= _ctl.PLANT_SETTLE_DOWN_S - or (age > 0 and abs(float(inv.batt) - cmd) - < self.tuning.deadband_w)): - del self._settle[inv.name] - if (inv.name not in live or inv.rejected_since is not None - or inv.name in self._settle): - out += cmd - float(inv.batt) - return out - - def _mode_fault_log(self, resolution) -> None: - """OPT-11: say once, per battery, that its mode was dropped as a fault. - A mode range without 0 W would force a direction, which no mode may do, - so it is never applied (allocator.unit_interval()); main.py never - builds one. (DECISIONS/MAIN-orchestration-archive.md)""" - alloc = resolution.allocation - now = {u for u, _ in getattr(alloc, "mode_faults", ())} - if resolution.mode_fault is not None and self.inverters: - now.add(self.inverters[0].name) - for inv in self.inverters: - if inv.name in now and inv.name not in self._mode_faults: - _LOG.error("Battery mode ignored for %s. Software fault " - "MODE_RANGE.", inv.name) - self.log_event(f"battery mode ignored on {inv.name}, a " - f"software fault", hint=messages.hint( - "mode_fault", unit=inv.name)) - self._mode_faults = now - - def _pack_action(self, what, unit, value, now) -> dict: - """OPT-11: switch one battery on or off, set its mode, or (P3) turn its - selling on or off. Store state, not an option: it applies next cycle - with no restart. Switching a battery off also stops its maintenance - cycle. (DECISIONS/MAIN-orchestration-archive.md)""" - if not self.modes_on: - return {"error": messages.pack_refused("off")} - inv = next((i for i in self.inverters if i.name == unit), None) - if inv is None: - return {"error": messages.pack_refused("unit")} - if what == "pack_use": - if not isinstance(value, bool): - return {"error": messages.pack_refused("value")} - inv.store_set(packmode.USE_KEY, value) - self.log_event(f"{inv.name} switched {'on' if value else 'off'} " - f"by operator", - hint=messages.hint("pack_on" if value - else "pack_off", unit=inv.name)) - if not value: - self._maint_stop_off(self.maints[self.inverters.index(inv)], - now) - elif what == "pack_sell": - if not packmode.SELLING_ENABLED: - return {"error": messages.pack_refused("sell_off")} - if not isinstance(value, bool): - return {"error": messages.pack_refused("value")} - inv.store_set(packmode.SELL_KEY, value) - self.log_event(f"{inv.name} selling switched " - f"{'on' if value else 'off'} by operator", - hint=messages.hint("pack_sell", unit=inv.name)) - else: - if value not in packmode.MODES: - return {"error": messages.pack_refused("value")} - inv.store_set(packmode.MODE_KEY, value) - self.log_event(f"{inv.name} mode set to {value.replace('_', ' ')} " - f"by operator", - hint=messages.hint("pack_mode", unit=inv.name)) - pm = self.pack_mode(inv) - out = {"unit": inv.name, "use": pm.use, "mode": pm.mode} - if what == "pack_sell": - out["sell"] = pm.sell - return out - - async def _maint_unit_action(self, what, unit, now) -> dict: - """MAINT-11: start or abort the maintenance cycle of ONE named battery. - The site keeps ONE maintenance slot: a start while any battery holds it - is refused (no queue). (DECISIONS/MAIN-orchestration-archive.md)""" - inv = next((i for i in self.inverters if i.name == unit), None) - if inv is None: - return self._maint_refusal("unit") - machine = self.maints[self.inverters.index(inv)] - if what == "maint_start": - holder = next((i for i, m in zip(self.inverters, self.maints) - if m.phase in POWER_MOVING), None) - if holder is not None: - return self._maint_refusal("busy", holder.name) - if self.modes_on and not self.pack_mode(inv).use: - return self._maint_refusal("off", inv.name) - for msg in machine.force_start(now): - self.log_event(msg, hint=messages.hint( - "maintenance", phase=machine.phase)) - return {"unit": inv.name, "phase": machine.phase} - if machine.phase not in POWER_MOVING: - return self._maint_refusal("idle", inv.name) - for msg in machine.abort(now, "operator"): - self.log_event(msg, hint=messages.hint("maintenance", - phase=machine.phase)) - await self.command_site_zero() - return {"unit": inv.name, "phase": machine.phase} - - @staticmethod - def _maint_refusal(code, unit="") -> dict: - return {"error": messages.maint_refused(code, unit), "code": code, - "unit": unit} - - def mode_values(self) -> dict: - """OPT-11 P2: each battery's mode and switch, for their HA state - topics. Read from the store, so they are right after a restart. Empty - while battery modes are off.""" - if not self.modes_on: - return {} - out = {} - names = [inv.name for inv in self.inverters] - for inv, slug in zip(self.inverters, unit_slugs(names)): - pm = self.pack_mode(inv) - out[unit_state_key(slug, "mode")] = MODE_LABELS.get(pm.mode, - pm.mode) - out[unit_state_key(slug, "use")] = USE_ON if pm.use else USE_OFF - return out - - async def mqtt_command(self, topic, payload, retained) -> None: - """OPT-11 P2: one command from a battery's HA switch or mode list, - through handle_action(). A retained command is never applied (the store - already holds the last choice). Never raises: MQTT must not reach - control. (DECISIONS/MAIN-orchestration-archive.md)""" - try: - slug, action, value, why = parse_command( - topic, payload, retained, packmode.MODES) - if why is None: - names = [inv.name for inv in self.inverters] - unit = dict(zip(unit_slugs(names), names)).get(slug) - result = await self.handle_action(action, unit=unit, - value=value) - if not (isinstance(result, dict) and "error" in result): - self.mqtt.publish(self.mode_values()) - return - why = result["error"] - else: - why = messages.pack_refused(why) - # Printable only: a topic is sender-chosen, so no log injection. - topic_s = "".join(c if c.isprintable() else "?" - for c in str(topic)[:128]) - _LOG.debug("Home Assistant command %s %s refused: %s", topic_s, - repr(payload)[:48], why) - if (topic_s, why) in self._mqtt_refused \ - or len(self._mqtt_refused) >= 32: - # Not logged again, but counted: 1, 10, 100... stay visible. - self._mqtt_refused_more += 1 - more = self._mqtt_refused_more - if str(more).rstrip("0") == "1": - _LOG.warning("Home Assistant commands still ignored: %d " - "more so far.", more) - return - self._mqtt_refused.add((topic_s, why)) - _LOG.warning("Home Assistant command on %s ignored: %s", topic_s, - why) - except Exception: # noqa: BLE001 - _LOG.exception("Home Assistant command could not be handled") - - def _drain_floor(self, claims, maint_results): - """SAFETY-78: the site `lo` for a pinned maintenance drain, or None. At - N>=2 the drain pins its pack and the site target is split around the - pin; below the pin a sibling would have to charge (opposite - directions, refused, 0 W, the drain never moves), so the site target - may not go below what the pin places. At N=1 the drain is the site - SET itself: nothing filed. A draining pack with nothing to give adds - no floor (logged once).""" - if len(self.inverters) < 2: - return None - live = set(self.live_units()) - floor, waits = 0.0, set() - for inv, res in zip(self.inverters, maint_results): - if res.phase != DRAIN or not res.owns_setpoint \ - or not (res.setpoint_w or 0.0) > 0.0: - continue - u = self.actuator_of("maintenance", inv) - b = pre_resolve([c for c in claims if c.actuator == u]) - p = (None if u not in live or b.contradiction - or b.pinned_by != "maintenance" else allocator.pin_w(b)) - if p is not None and p > 0.0: - floor += p - else: - waits.add(inv.name) - for name in sorted(waits - self._drain_waits): - self.log_event(f"maintenance: {name} cannot give power now, so " - f"its monthly cycle waits", - hint=messages.hint("maint_waits", unit=name)) - self._drain_waits = waits - return floor if floor > 0.0 else None - - def _peak_on_able(self, decision, maint_results): - """SAFETY-87: at N>=2, `decision` placed on the batteries that can - still give: live, not refusing, above their own reserve. `able_w` is - their rating plus the drain a held battery gives anyway. - DECISIONS/SAFETY-87-guard-uses-able-batteries.md""" - if len(self.inverters) < 2: - return decision - floor = float(self.o.get( - "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) - ok, held = peakguard.reserve_split(self.packs, floor) - if not held: - return decision - live = set(self.live_units()) - able = [p for p, inv in zip(self.packs, self.inverters) - if p in ok and inv.name in live and not (inv.refused or 0) > 0] - if not able: - return dataclasses.replace(decision, held=held) - drain = sum(min(float(r.setpoint_w), i.rating_w()) - for i, r in zip(self.inverters, maint_results) - if i.name in held and r.phase == DRAIN - and r.owns_setpoint and (r.setpoint_w or 0.0) > 0.0) - names = {p.name for p in able} - return peakguard.place_on_able( - decision, held, - able_w=drain + sum(i.rating_w() for i in self.inverters - if i.name in names), - soc=min(float(p.soc) for p in able), - soc_floor=peakguard.binding_soc_floor(able, floor)) - - def _peak_landing(self, decision): - """SAFETY-126: `decision` marked `within_aim` while the guard wants - power but the batteries at 0 W would end the quarter within - `ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). The claim and the - commands are unchanged; only the alarms and the "Why" read it. - DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md""" - batt, prev = self.site_batt_w, self._land_batt_prev - self._land_batt_prev = batt - if not (decision.engaged and decision.blind is None - and (decision.wanted_w or 0.0) > 0.0): - self._peak_needed = False - return decision - if batt is not None and prev is not None: - batt = min(float(batt), float(prev)) - self._peak_needed = self._peak_needed or ( - self._peak_proj is not None and peakguard.misses_aim( - self._peak_proj, batt_w=batt, target_w=decision.target_w)) - return (decision if self._peak_needed - else dataclasses.replace(decision, within_aim=True)) - - def _readings_late(self, standing_w): - """SAFETY-128: update the answer gate; seconds the late verdict has - stood, or None. DECISIONS/SAFETY-128-late-readings.md.""" - a = self.answer - if self._now_mono is None: - return None - a.observe(self._now_mono, standing_w, self.site_batt_w, - grid_w=self.grid, exempt=bool(self.batt_refused) or any( - i.refused or ( - i.batt is not None - and abs(i.standing_w()) >= lateness.ANSWER_W - and abs(i.batt - i.idle_w) < lateness.ANSWER_W) - for i in self.inverters), - idle_w=self._site_idle_w()) - if a.late_seen != self._late_logged: - self._late_logged = a.late_seen - if a.late_seen: - self.log_event("readings late - holding", - hint=messages.hint("readings_late")) - _LOG.warning("Home Assistant readings arrive late, so control " - "holds the batteries: READINGS_LATE.") - _LOG.debug("READINGS_LATE: the readings show a power request " - "%.0f s after it is sent.", a.delay_s) - else: - self.log_event("readings on time", hint=messages.hint( - "readings_back")) - _LOG.info("Home Assistant readings are on time again.") - return a.late_s(self._now_mono) - - def _peak_reserve(self) -> frozenset: - """SAFETY-87: the batteries that give nothing for the peak this - cycle: under their reserve while the others carry a peak floor.""" - g = self.peak_guard - if g is None or g.able_w is None or not (g.floor_w or 0.0) > 0.0: - return frozenset() - return frozenset(g.held) - - def _held_back(self) -> frozenset: - """SAFETY-91, N>=2: the batteries at or under their own peak reserve - while another can still give (live, not refusing). One held back stays - so until its SoC reads more than 1 point above its reserve. - DECISIONS/SAFETY-91-held-battery-last.md""" - if len(self.inverters) < 2: - return frozenset() - floor = float(self.o.get( - "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) - ok, held = peakguard.reserve_split(self.packs, floor) - held = set(held) | { - p.name for p in ok if p.name in self._held_was - and float(p.soc) <= peakguard.pack_floor_pct(p, floor) + 1.0} - live = set(self.live_units()) - if not held or not any( - p.name not in held and inv.name in live - and not (inv.refused or 0) > 0 - for p, inv in zip(self.packs, self.inverters)): - self._held_was = frozenset() - else: - self._held_was = frozenset(held) - return self._held_was - - def _drain_rise(self, claims, maint_results) -> None: - """SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a - draining battery's pin becomes a floor at its drain rate, so it rises - towards its rating before the held one gives. A site stop still decides - alone. DECISIONS/SAFETY-91-held-battery-last.md""" - held = self._held_back() - if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE - and c.priority > P_PEAK for c in claims): - return - live = set(self.live_units()) - for inv, res in zip(self.inverters, maint_results): - u = self.actuator_of("maintenance", inv) - if res.phase != DRAIN or not res.owns_setpoint \ - or inv.name in held or u not in live: - continue - b = pre_resolve([c for c in claims if c.actuator == u]) - p = (None if b.contradiction or b.pinned_by != "maintenance" - else allocator.pin_w(b)) - if p is None or not p > 0.0: - continue - for i, c in enumerate(claims): - if c.source == "maintenance" and c.kind == SET \ - and c.actuator == u: - claims[i] = Claim.limit("maintenance", P_MAINTENANCE, - lo=p, reason=c.reason, - actuator=u) - - def _fill_peak_gaps(self, proj, now, reading): - """SAFETY-90: `proj` with a meter gap's uncounted seconds counted at - `peakguard.gap_w()` of the imports seen just before and just after it, - and those before this run's first reading likewise at the import seen - first (shown as not measured). - DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.""" - self.peak_gap_s = self.peak_unmeasured_s = 0.0 - avg = self.p1.averager - since = avg.held_since - if proj is None or proj.block_start is None or since is None: - return proj - if self._unseen is None and self.p1.last is not None: - self._unseen = [proj.block_start, max(0.0, ( - since - proj.block_start).total_seconds() - avg.elapsed_s), - since, None] - seen = (None if self.grid is None or self.site_batt_w is None - else float(self.grid) + max(0.0, float(self.site_batt_w))) - if proj.block_start != self._gap_block: - self._gap_block, self._gap_w = proj.block_start, None - self._gap_filled_s = self._gap_held_ws = 0.0 - cap = float(self.o.get("peak_cap_w", 3500)) - ceiling = peakguard.quarter_ceiling_w(cap, reading)[0] - unseen_on = (self._unseen is not None - and self._unseen[0] == proj.block_start) - if unseen_on and self._unseen[3] is None and seen is not None: - # While the add-on was down the batteries were at 0 W, so the - # seconds before its first reading count like a gap, at the - # import seen first; a restored reading's held part is topped up. - self._unseen[3] = peakguard.gap_w(ceiling, seen) - if self._run_pre is not None: - s0, g0 = self._run_pre - h = (min(s0 + timedelta(seconds=avg.max_hold_s), - self._unseen[2]) - - max(s0, proj.block_start)).total_seconds() - self._unseen_held_ws = max(0.0, h) * max( - 0.0, self._unseen[3] - max(0.0, g0)) - kw = dict(tail_s=(now - since).total_seconds(), - max_hold_s=avg.max_hold_s, - unseen_s=self._unseen[1] if unseen_on else 0.0, - unseen_w=(self._unseen[3] if unseen_on - and self._unseen[3] is not None else ceiling)) - _p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw) - if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S: - self._gap_w = peakguard.gap_w(ceiling, self._gap_w, - self._gap_seen_w, seen) - if self._gap_pre is not None: - # The averager credited the gap's first max_hold_s at the - # reading before it; top that part up to gap_w as well. - s0, g0 = self._gap_pre - h = (min(s0 + timedelta(seconds=avg.max_hold_s), since) - - max(s0, proj.block_start)).total_seconds() - self._gap_held_ws += max(0.0, h) * max( - 0.0, self._gap_w - max(0.0, g0)) - self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s " - f"of this 15-minute period, counted at " - f"{self._gap_w:.0f} W", - hint=messages.hint("peak_gap")) - self._gap_filled_s = max(self._gap_filled_s, gap_s) - if seen is not None: - self._gap_seen_w = seen - if self.grid is not None: - self._gap_pre = (since, float(self.grid)) - out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps( - proj, gap_w=self._gap_w if self._gap_w is not None else ceiling, - held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on - else 0.0), **kw) - return out - - def _peak_over_drain(self, claims, maint_results) -> None: - """SAFETY-83: at N=1 a peak floor above the drain rate outranks it: the - drain is the site SET at P_MAINTENANCE, above P_PEAK, so the floor - did not bind. Refiled at P_MAINTENANCE where it asks for more; - P_SAFETY still outranks. N>=2: the `drain` floor and - `_drain_over_reserve()`.""" - if len(self.inverters) != 1: - self._drain_over_reserve(claims, maint_results) - return - res = maint_results[0] - if res.phase != DRAIN or not res.owns_setpoint: - return - rate = float(res.setpoint_w or 0.0) - for i, c in enumerate(claims): - if c.source == peakguard.SOURCE and c.lo > rate: - claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE) - - def _held_pin(self, claims, u): - """SAFETY-87: what held unit `u` places anyway (its own pin), or None - when that pin charges, so the site is never split both ways.""" - b = pre_resolve([c for c in claims if c.actuator == u]) - p = None if b.contradiction else allocator.pin_w(b) - return None if p is not None and p < 0.0 else max(p or 0.0, 0.0) - - def _drain_over_reserve(self, claims, maint_results) -> None: - """SAFETY-87, N>=2: while a battery is held at its peak reserve, a - draining battery that may still give is raised from its drain rate - towards its rating for the peak floor, after the others. A site stop - still decides alone. DECISIONS/SAFETY-87-guard-uses-able-batteries.md""" - reserve = self._peak_reserve() - floor = max((c.lo for c in claims if c.source == peakguard.SOURCE), - default=0.0) - if not reserve or not floor > 0.0 or any( - c.kind == SET and c.actuator == ACTUATOR_SITE - and c.priority > P_PEAK for c in claims): - return - live = set(self.live_units()) - drains, others = [], 0.0 - for inv, res in zip(self.inverters, maint_results): - u = self.actuator_of("maintenance", inv) - if u not in live: - continue - if inv.name in reserve: - p = self._held_pin(claims, u) - if p is None: - return - others += p - continue - b = pre_resolve([c for c in claims if c.actuator == u]) - if b.contradiction: - continue - p = allocator.pin_w(b) - if p is not None and p < 0.0: - return # a pack pinned to charge: never split both ways - if res.phase == DRAIN and b.pinned_by == "maintenance" \ - and p is not None and p > 0.0: - drains.append((u, p, allocator.hi_w(b))) - others += allocator.hi_w(b) if p is None else p - give = floor - others - for u, p, hi in drains: - if give <= 0.0: - break - up = max(p, min(hi, p + give)) - give -= up - p - for i, c in enumerate(claims): - if c.source == "maintenance" and c.kind == SET \ - and c.actuator == u: - claims[i] = dataclasses.replace(c, value=up) - - def _peak_over_hold(self, claims, maint_results): - """SAFETY-86: a peak floor outranks a maintenance HOLD (0 W). N=1: - refiled at P_MAINTENANCE as for a drain. N>=2: the holding pack's - pin is raised to the floor minus what the others may place, and the - floor is returned as the site `hold` ceiling. A site stop still - decides alone. DECISIONS/SAFETY-86-peak-over-hold.md.""" - hold = {self.actuator_of("maintenance", inv) - for inv, res in zip(self.inverters, maint_results) - if res.phase == HOLD and res.owns_setpoint} - peak = [i for i, c in enumerate(claims) - if c.source == peakguard.SOURCE and c.lo > 0.0] - if not hold or not peak: - return - if len(self.inverters) == 1: - for i in peak: - claims[i] = dataclasses.replace(claims[i], - priority=P_MAINTENANCE) - return - if any(c.kind == SET and c.actuator == ACTUATOR_SITE - and c.priority > P_PEAK for c in claims): - return - live = set(self.live_units()) - reserve = self._peak_reserve() # SAFETY-87: they give nothing - others = 0.0 - for inv in self.inverters: - u = self.actuator_of("maintenance", inv) - if u in hold or u not in live: - continue - if inv.name in reserve: # only its own pin, never both ways - p = self._held_pin(claims, u) - if p is None: - return - others += p - continue - b = pre_resolve([c for c in claims if c.actuator == u]) - if b.contradiction: - continue - p = allocator.pin_w(b) - if p is not None and p < 0.0: - return # a pack pinned to charge: never split both ways - others += allocator.hi_w(b) if p is None else p - floor = max(claims[i].lo for i in peak) - give = floor - others - if give <= 0.0: - return - for i, c in enumerate(claims): - if c.source == "maintenance" and c.kind == SET \ - and c.actuator in hold: - claims[i] = dataclasses.replace(c, value=give) - return floor - - def _charge_headroom(self, floor: float, claims) -> float: - """SAFETY-84: a cheap-window charge pulls no more than the quarter's - room under the peak aim (`charge_room_w()`), so it yields just enough - to the peak guard; at N>=2 nothing under a site stop (its 0 W would - need the other pack to give). SAFETY-125: at N=1 too; the stop rule - stays N>=2 only. - DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md""" - if len(self.inverters) > 1 and any( - c.kind == SET and c.actuator == ACTUATOR_SITE - and c.priority > P_MAINT_SHAPE for c in claims): - return 0.0 - room = peakguard.charge_room_w(self.peak_guard, self._peak_proj) - floor = floor if room is None else min(floor, room) - self.room_charge = self.room_charge or floor > 0.0 - return floor - - def _charge_ceiling(self, claims): - """SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None: - the charging pack's own `hi` (P_MAINT_SHAPE) makes it charge; above - that the other pack would have to give (opposite directions, - refused, 0 W). Not filed at N=1.""" - if len(self.inverters) < 2: - return None - live = set(self.live_units()) - ceiling = 0.0 - for inv in self.inverters: - u = self.actuator_of("maintenance", inv) - b = pre_resolve([c for c in claims if c.actuator == u]) - if u in live and not b.contradiction and b.hi < 0.0: - ceiling += allocator.hi_w(b) - return ceiling if ceiling < 0.0 else None - - def _maint_stop_off(self, machine, now) -> None: - """OPT-11: stop this battery's maintenance cycle because it is off.""" - if machine.phase == IDLE: - return - for msg in machine.abort(now, "battery switched off"): - self.log_event(msg, hint=messages.hint("maint_off", - unit=machine.name)) - - @property - def why_words(self) -> str: - """The page's "Why" line. While control waits for a missing reading - the age is counted when the page asks (UX-10), not frozen at the age - the last cycle saw.""" - if self._why_waiting_since is not None: - return messages.why_waiting(max(0.0, ( - datetime.now(timezone.utc) - - self._why_waiting_since).total_seconds())) - return self._why_words - - @why_words.setter - def why_words(self, text: str) -> None: - self._why_words = text - self._why_waiting_since = None - - @property - def inputs_ok(self) -> bool: - """The site meter, and EVERY pack's own two readings: every pack, not - the live ones. A unit whose SoC did not answer cannot be graded for - the maintenance FSM or the pack floor, and 11.1 rule 1 for a missing - input is command zero and keep commanding.""" - if self.grid is None or not self.inverters: - return False - return all(inv.soc is not None and inv.batt is not None - for inv in self.inverters) - - def missing_reading(self) -> str: - """UX-11: the first reading `inputs_ok` finds missing, in words.""" - if self.grid is None or not self.inverters: - return "meter" - for inv in self.inverters: - of = "" if len(self.inverters) == 1 else f" from {inv.name}" - if inv.batt is None: - return f"battery power{of}" - if inv.soc is None: - return f"battery level{of}" - return "input" - - # -- io ------------------------------------------------------------------ - @property - def pv_roster(self) -> tuple: - """Every configured PV production entity, or `()` (TEL-11/TEL-12). ONE - reading of the options, `pv.entities()`, the same call - `mqtt.optional_keys()` makes, so what is ANNOUNCED, READ and CHECKED - cannot disagree; it replaced a `pv_entity` property.""" - return pv_read.entities(self.o) - - async def _read_pv(self) -> None: - """This cycle's SITE PV production, or an ABSENCE with a reason - (TEL-11/12). `pv.total()` makes "one of three absent" an absence of - the SITE figure. It assigns on EVERY call including failing ones (a - stale value must not survive a failure), and uses `hass.state()` not - `hass.number()` because the UNIT is half of the answer.""" - # TEL-12: every entity in ONE gather. Read serially, three slow sensors - # would add three 10 s timeouts to a cycle that feeds the ESP32 - # heartbeat; telemetry must have no foot in the control path. - roster = self.pv_roster - states = await asyncio.gather( - *(self.hass.state(entity) for entity in roster)) - self.pv_w, self.pv_why, self.pv_parts = pv_read.total( - list(zip(roster, states)), pv_read.inverted(self.o)) - # UX-54: kept for the Forecast tab only. It must never cost a cycle. - try: - self.solar_today.add(datetime.now(timezone.utc), self.pv_w) - except Exception: # noqa: BLE001 - _LOG.debug("measured solar not kept", exc_info=True) - - def _peak_fc_unit_log(self, fault) -> None: - """SAFETY-118. One WARNING when the peak forecast's unit cannot be read - in watts, and again only if the unit changes; the unit is on DEBUG.""" - if fault == self._peak_fc_fault: - return - self._peak_fc_fault = fault - if fault is None: - _LOG.info("The 15-minute demand forecast is used again.") - elif fault == "": - _LOG.warning("The 15-minute demand forecast has no unit, so it " - "is not used.") - else: - _LOG.warning("The 15-minute demand forecast is not in a power " - "unit, so it is not used.") - _LOG.debug("The 15-minute demand forecast reads in %r.", fault) - - async def _read_forecast(self) -> None: - """OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A - failed or stale read is no forecast, never a zero.""" - # OPT-17: every member of both days in one gather. - ents = self.fc_entities + self.fc_tomorrow_entities - states = await asyncio.gather(*(self.hass.state(e) for e in ents)) - n = len(self.fc_entities) - now = datetime.now(timezone.utc) - fc, why, gaps = solarfc.read_sum( - list(zip(self.fc_entities, states[:n])), - list(zip(self.fc_tomorrow_entities, states[n:])), now, - hold=self.fc_hold) - if self.fc_hold.changed: - self.fc_hold.changed = False - self.store.set(FC_HOLD_KEY, self.fc_hold.dump()) - # A member is named only in a sum of several; one entity reads as - # before. Logged once per episode: on a change, never every read. - gaps = tuple(g for g in gaps if len( - self.fc_entities if g[0] == "today" - else self.fc_tomorrow_entities) > 1) - was_kept = bool(self.solar_fc is not None and self.solar_fc.kept) - if (fc is None) != (self.solar_fc is None) \ - or why != self.solar_fc_why or gaps != self.solar_fc_gaps \ - or bool(fc is not None and fc.kept) != was_kept: - self.log_event( - "solar forecast: " + ( - "none (" + str(why) + ")" if fc is None else - f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept - else "read") - + "".join(f", {d} {e} not usable ({w})" - for d, _i, e, w in gaps), - hint=messages.hint( - "forecast_none" if fc is None else - "forecast_no_tomorrow" if gaps else "forecast_read")) - self.solar_fc, self.solar_fc_why = fc, why - self.solar_fc_gaps = gaps - - async def read_inputs(self, now_mono: float | None = None) -> None: - """Every input this cycle needs, and SAFETY-37's freshness verdict. - `now_mono` is defaulted rather than read inside `_read_unit()` so - ONE instant covers every unit in a gather.""" - o = self.o - now_mono = freshness.monotonic() if now_mono is None else float(now_mono) - self._now_mono = now_mono - if self.p1_enabled: - # P1 is the only authoritative measurement of what the utility sees - # (5.1); when stale this is None -> "inputs missing -> command 0 - # W". NO fallback to an inverter-side figure: its AC power - # correlates 0.998 with battery power and 0.09 with the real meter - # (a controller would regulate against its own output). - self.grid = self.p1.net_w - else: - self.grid = await self.hass.number(o.get("meter_entity", ""), - bool(o.get("meter_invert"))) - # DEV-02: each inverter's own two readings from its own entities, - # CONCURRENTLY across units and sequentially within one (a slow entity - # must not delay the heartbeat path). The PV reads ride in the same - # gather and an empty PV roster appends nothing (no coroutine). - # DECISIONS/MAIN-orchestration.md. - _reads = [self._read_unit(inv, now_mono) for inv in self.inverters] - if self.pv_roster: - _reads.append(self._read_pv()) - # OPT-11 P4: the solar forecast, in the same gather (a slow sensor must - # not delay control), at most once per FORECAST_READ_S. - if self.fc_entity is not None and ( - self._fc_read_mono is None - or now_mono - self._fc_read_mono >= FORECAST_READ_S - or now_mono < self._fc_read_mono): - self._fc_read_mono = now_mono - _reads.append(self._read_forecast()) - await asyncio.gather(*_reads) - if o.get("peak_forecast_entity"): - # SAFETY-118: read in its own unit like the PV path, one state - # read. No unit or a non-power unit: not used, as if the option - # were empty. - st = await self.hass.state(o["peak_forecast_entity"]) - self.peak_fc, _why = pv_read.watts(st) - # A missing reading leaves the unit-fault state as it was. - if pv_read.has_reading(st): - self._peak_fc_unit_log(pv_read.unit_fault(st)) - else: - self.peak_fc = None - self._peak_fc_fault = None - - now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity") - if now_e and avg_e: - now_p = await self.hass.number(now_e) - avg_p = await self.hass.number(avg_e) - self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p) - self._price_gated = True - self.tariff_words = "Prices from Home Assistant entities" - elif uses_chain(o): - # PRICE-01. A provider with fallbacks gives the maintenance gate - # its own prices: the current 15-minute price against today's - # average, as the Home Assistant pair did. No I/O here: the series - # is the one the background refresh stored. - # (DECISIONS/MAIN-orchestration-archive.md) - self._provider_gate(datetime.now(timezone.utc)) - else: - # TARIFF-03: 9.2 makes a static day/night tariff a first-class - # product and its EMS handling TIME-WINDOW SHIFTING; the one - # shiftable load is the maintenance cycle's cheap-window grid - # top-up. Synchronous and I/O-free on purpose (the provider-backed - # plan is the background task's); validate.py refuses - # `static_profile` with the price entities, so the two cannot both - # be set. - _now = datetime.now(timezone.utc) - self.cheap, self.tariff_words = static_status(o, _now) - self._price_gated = self._static_gated(o, _now) - - # TARIFF-11: the provider plan reaches the *Tariff* row here, the only - # place the two sources meet. `fresh_tariff_plan()` is an attribute - # read and an age comparison against TARIFF_MAX_AGE_S: still no fetch - # on this path. - _tick = datetime.now(timezone.utc) - _standing = self.fresh_tariff_plan(_tick) - self.tariff_words = tariff_plan_row( - _standing, self.tariff_words, - age_s=(None if _standing is None - else (_tick - self.tariff_at).total_seconds()), - horizon_h=float(price_option(o, "price_horizon_h")), - provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S) - - # UX-11: the red meter row says "the add-on log shows why", so a quiet - # meter gets one WARNING per episode (at most one per - # METER_QUIET_REPEAT_S; the rest DEBUG). Log only: no notification, - # decides nothing. - if self.p1_enabled: - if (self.p1.stale and not self._meter_quiet_logged - and self.p1.published_age_s > self.p1.max_age_s): - self._meter_quiet_logged = True - now = _mono() - self._meter_quiet_loud = ( - self._meter_quiet_warned is None - or now - self._meter_quiet_warned >= METER_QUIET_REPEAT_S) - if self._meter_quiet_loud: - self._meter_quiet_warned = now - _LOG.warning("No meter reading for over %s, so control " - "waits and then sets the battery to 0 W.", - messages.seconds(self.p1.max_age_s)) - else: - _LOG.debug("No meter reading for over %s again.", - messages.seconds(self.p1.max_age_s)) - elif not self.p1.stale and self._meter_quiet_logged: - self._meter_quiet_logged = False - if self._meter_quiet_loud: - _LOG.info("Meter readings are back.") - else: - _LOG.debug("Meter readings are back.") - - self.answer.watch(now_mono, [(inv.name, inv.standing_w(), inv.batt) - for inv in self.inverters], # SAFETY-128 - sum(inv.standing_w() for inv in self.inverters), - self.grid) - if self.inputs_ok: - if self.inputs_bad_since is not None: - self.log_event("inputs recovered", - hint=messages.hint("inputs_back")) - self.inputs_bad_since = None - elif self.inputs_bad_since is None: - self.inputs_bad_since = datetime.now(timezone.utc) - - def _driver(self, inv): - """`inv`'s driver, wired to this Controller's Home Assistant on first use - (SMA-2: bound here, so a unit added later and a swapped `self.hass` are - followed). DECISIONS/SMA-2-driver-seam.md.""" - drv = inv.driver - if not drv.bound(): - drv.bind_getter(lambda: self.hass) - return drv - - async def _read_unit(self, inv, now_mono: float) -> None: - """One inverter's own SoC and battery power, from ITS OWN entities. - SAFETY-37: the POWER reading goes through the freshness gate and the - SoC does not, deliberately: `batt` feeds SAFETY-30's per-unit sign - check and the delivery check, which a frozen register would fool. - The standing command is asked of the unit, not recomputed.""" - # SMA-2: the driver reads SoC, then power, as this method always did. - inv.soc, raw, _extra = await self._driver(inv).read() - was_stale = inv.batt_fresh.stale - was_releases = inv.batt_fresh.releases - inv.batt_fresh.submit(raw, inv.standing_w(), now_mono) - # `inv.batt` is the GATED value: a stale register is a MISSING input, - # so the site holds for `stale_input_s` then commands 0 W and keeps - # commanding (11.1 rule 1). - inv.batt = inv.batt_fresh.reading() - # SAFETY-97: learn this unit's idle reading, only while control runs - # and nothing says the unit is refusing, stale or faulty. - if inv.idle.observe( - inv.batt, inv.standing_w(), now_mono, - bool(self.auto) and inv.batt is not None - and not inv.batt_fresh.stale and not inv.refused - and not self.batt_refused and not inv.sign_wd.tripped): - inv.store_set("idle_w", round(inv.idle.value, 1)) - if inv.batt_fresh.stale and not was_stale: - self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason, - hint=messages.hint("battery_stuck", unit=inv.name)) - self._refusal_from_stale(inv, now_mono) - # SAFETY-56/57: the stale-reading line names two explanations once, - # in `%s`. The escape clause "clears itself when the register - # reports a different value" was false for the state this line - # announces (the 0 W it announces is what stops the value - # changing), so SAFETY-57 rewrote it and SAFETY-56 the diagnosis. - # DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md. - _LOG.error("%s: battery power reading stopped changing, set to " - "0 W until it changes, or %.0f s after the command " - "drops below %.0f W: %s.", - inv.name, inv.batt_fresh.bound.release_s, - inv.batt_fresh.bound.move_w, - messages.reason_code(inv.batt_fresh.reason)) - _LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason) - _LOG.debug("%s: the limits are unchanged for %.0f s while asked " - "to move: a command over %.0f W that moved over %.0f W.", - inv.name, - inv.batt_fresh.bound.unchanged_s, - inv.batt_fresh.bound.move_w, - inv.batt_fresh.bound.move_w) - elif inv.batt_fresh.releases > was_releases: - # SAFETY-57: reported, never silent: a latch that lets go on a - # timer and says nothing cannot be audited, and this one withdraws - # a SITE STOP. `release_note()` says nothing has been learned about - # the register. - self.log_event(f"STALE WITHDRAWN {inv.name}", - hint=messages.hint("battery_back", unit=inv.name)) - # Not "recovered": nothing is known about the reading yet, so the - # line gives the value it holds and the window it is rechecked over - # (SAFETY-57). - _LOG.warning("%s: the command stayed below %.0f W for %.0f s, so " - "battery power reading %+.0f W is used again and " - "rechecked over %.0f s.", inv.name, - inv.batt_fresh.bound.move_w, - inv.batt_fresh.bound.release_s, inv.batt_fresh.held, - inv.batt_fresh.bound.unchanged_s) - _LOG.debug("SAFETY-57: %s", inv.batt_fresh.release_note(inv.name)) - - @property - def shadow(self) -> bool: - """SAFETY-10. Is the battery being left alone right now? A property, - not a field (as `self.units`): the cycle, the write and the status - page ask at three moments, and the write gate asks THIS, not a - parameter passed down.""" - return shadow.engaged(self.o, control_started=self.auto) - - def shadow_latches(self) -> tuple: - """The failsafe latches that are UP, named, for the shadow banner: a - standing latch is standing when the controller stops being inert, so - an operator who turns shadow off is not handed a battery that will - not move with no reason shown.""" - out = [] - if self.circ_wd.tripped: - out.append("circulating power") - if self.site_sign_wd.tripped and len(self.inverters) > 1: - out.append("site sign watchdog") - for inv in self.inverters: - if inv.sign_wd.tripped: - out.append(f"{inv.name} sign watchdog") - return tuple(out) - - def write_refusal(self, value: float, inv=None) -> str | None: - """Why `value` may NOT be written to `inv` right now, or None if it - may. SAFETY-28: a number reaches the wire only if THE SINGLE ALLOCATOR - placed it, on this tick, for THIS unit (`Allocation.authorises()`); a - second writer must file a Claim. 0 W is ALWAYS authorised and tested - first, reading nothing: the failsafe writes run while the allocator has - REFUSED, so a gate asking permission to command zero would deadlock the - failsafe. It tests `value == 0.0`, not a flag the caller sets. DEV-02: - the unit is an ARGUMENT (the record holds one share per unit); the - default is for N=1 callers and REFUSES at N>=2. - DECISIONS/MAIN-orchestration.md.""" - if value == 0.0: - return None - inv = self._only() if inv is None else inv - rec = self.allocation - if rec is None: - return ("no allocation record exists yet - nothing has authorised " - f"{value:+.0f} W on {inv.name}") - return rec.authorises(inv.name, value, self.alloc_tick) - - async def write_setpoint(self, value: float, *, force: bool = False, - unit=None, last: bool = False) -> None: - """Put `value` on ONE inverter, or with `unit=None` on all. `unit=None` - is for a number that is not a share (stop button, `shutdown()`, - `maint_abort()`: 0 W means the same to every inverter). Writes are - concurrent, each under its own budget (DEV-02). `last`: SAFETY-137.""" - if unit is None: - targets = [i for i in self.inverters] - if len(targets) > 1 and is_commandable(value) and float(value) != 0.0: - # Loud, and it commands zero rather than the value asked for: - # fail toward inaction, as every other refusal here. - self.log_event( - f"refusing to broadcast {float(value):+.0f} W to " - f"{len(targets)} inverters - that is the ARCH-01 fault; " - f"commanding 0 W", hint=messages.hint("all_blocked")) - _LOG.error("Refused to send %+.0f W to all %s at once, so " - "every inverter is set to 0 W. This is a software " - "fault.", float(value), - messages.count(len(targets), "inverter")) - _LOG.debug("ARCH-01: a site target handed to each unit is " - "supplied by each of them, so the gain at the meter " - "becomes N times the per-unit gain. Only 0 W may be " - "written site-wide, and shares come from the " - "allocator.") - value = 0.0 - else: - targets = [unit] - if not targets: - return - await asyncio.gather(*(self._write_unit(inv, value, force=force, - last=last) - for inv in targets)) - - async def _await_inflight(self, inv, budget_s: float) -> None: - """Wait, at most `budget_s`, for writes already on their way to `inv`; one - still running is left to its own WRITE_BUDGET_S. SAFETY-137.""" - pending = [t for t in inv.inflight if not t.done()] - if pending: - await asyncio.wait(pending, timeout=budget_s) - - async def _release_last(self, inv) -> bool: - """`release()` once any write in flight to `inv` has finished (at most - SETTLE_BUDGET_S): the release is the LAST thing the unit hears.""" - await self._await_inflight(inv, SETTLE_BUDGET_S) - return await self._driver(inv).release() - - async def _hand_back(self, inv, *, force: bool = False) -> bool: - """SMA-10. Give ONE unit back: exactly one `release()`, then nothing. - True when handed back. Never raises; a failure is retried on the next - idle write. ⚠️ `released` is set before the first await (one release per - unit, not two). ⚠️ It is not a write: no `note_write`, no `last_write`. - DECISIONS/SMA-10-two-idle-states.md.""" - if inv.released: - return True - if not inv.driver.configured(): - return False - # After a failure the next try waits one heartbeat; Stop and shutdown - # (`force`) always try. - now_m = time.monotonic() - if not force and now_m < inv.release_retry_at: - return False - inv.released = True - why = "" - try: - ok = bool(await _bounded(self._release_last(inv), - RELEASE_BUDGET_S)) - except (asyncio.TimeoutError, TimeoutError): - ok, why = False, f"took over {RELEASE_BUDGET_S:.0f} s" - except Exception as err: # noqa: BLE001 - one unit never stops the rest - ok, why = False, f"{type(err).__name__}: {err}" - if ok: - inv.last_write = None - if inv.release_fails: - _LOG.info("Handing back %s worked again after %d failed " - "tries.", inv.name, inv.release_fails) - inv.release_fails = 0 - inv.release_retry_at = 0.0 - inv.release_zero_sent = False - self.log_event(f"{inv.name}: handed back", - hint=messages.hint("handed_back", unit=inv.name)) - return True - inv.released = False - inv.release_fails += 1 - inv.release_retry_at = now_m + float(self.o.get("heartbeat_s", 10)) - _LOG.debug("Handing back %s failed (try %d): %s", inv.name, - inv.release_fails, why) - if inv.release_fails == 1: # one line per failure streak - _LOG.error("Handing back %s failed, so it is sent 0 W once and " - "handing back is tried again.", inv.name) - self.log_event(f"{inv.name}: hand-back failed", - hint=messages.hint("hand_back_failed", unit=inv.name)) - if not inv.release_zero_sent: - # ⚠️ ONCE: a 0 W re-sent on every try would reset the inverter's own - # fallback timer for ever. - inv.release_zero_sent = True - try: - if await _bounded(self._driver(inv).release_fallback(), - FALLBACK_BUDGET_S): - inv.last_write = 0.0 - except Exception as err: # noqa: BLE001 - _LOG.debug("The fallback write to %s failed: %r", inv.name, err) - return False - - async def _write_unit(self, inv, value: float, *, force: bool = False, - last: bool = False) -> None: - if not inv.driver.configured(): - return - # SMA-10: a unit that hands back is released once and not written while - # the add-on is idle (shutdown, stopped, shadow). ⚠️ A safety stop is - # NOT idle: it falls through to the 0 W write. Control starting again - # re-arms the unit; `stopping` never does. - # DECISIONS/SMA-10-two-idle-states.md. - if inv.driver.hands_back: - if self.stopping or self.shadow or not self.auto: - await self._hand_back(inv) - return - if inv.released or inv.release_fails or inv.release_zero_sent: - # Control has the unit again: forget the hand-back and any - # failed streak. - inv.released = False - inv.last_write = None - inv.release_fails = 0 - inv.release_retry_at = 0.0 - inv.release_zero_sent = False - entity = inv.driver.actuator_id() # for the log line below - - # SAFETY-03: the clamp is on the ONLY path to the wire (cycle, - # heartbeat, stop button, shutdown(); no optimizer, maintenance or - # manual path routes around it). A non-finite value is loss of input, - # not an out-of-range one (11.1): coerced to 0 W. - if not is_commandable(value): - self.log_event(f"non-finite setpoint {value!r} - commanding 0 W", - hint=messages.hint("bad_value")) - _LOG.error("refusing to write a non-finite setpoint %r - commanding " - "0 W and continuing to command. This is a bug upstream, " - "not a limit.", value) - value = 0.0 - - # SAFETY-28: the refusal is HERE, above the clamp and below the - # non-finite coercion (a nan is loss of input, not an unauthorised - # number), and asked about `inv`, not the site. - # DECISIONS/MAIN-orchestration.md. - refusal = self.write_refusal(value, inv) - if refusal is not None: - self.log_event( - f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W", - hint=messages.hint("write_blocked", unit=inv.name)) - _LOG.error("Refused to send %+.0f W to %s, so it is set to 0 W " - "instead, which is a software fault. The reason: %s.", - value, inv.name, refusal) - value = 0.0 - - # This unit's own three numbers: `inv.max_w` (supervised cap on THIS - # setpoint entity) and `inv.dev_min/dev_max` (what THIS device - # reported). Read off `self.o` they were one inverter's rating applied - # to every inverter. - result = clamp_to_rating(value, inv.max_w, inv.dev_min, inv.dev_max) - inv.clamp_last = result - if result.engaged: - # EVERY engagement is logged, never deduplicated: the action log - # (15) is a different surface from the deduplicated notification. - # The count says how hard the loop pushed. - self.log_event(f"{inv.name}: {result.describe()}", - hint=messages.hint("write_cut", unit=inv.name)) - value = result.applied_w - - # SAFETY-10: shadow mode runs the whole decision path and commands - # nothing, and the suppression is HERE, the last thing before the wire, - # in the one method every write reaches (so no call site routes round - # it). A substitution, not a return, like the two refusals above; below - # SAFETY-28's gate and SAFETY-03's clamp; not a second writer (no - # claim, no transport call). - if self.shadow and value != shadow.WIRE_W: - # UX-10: DEBUG, because it repeats every cycle in shadow mode; the - # INFO line when shadow comes on says it once. - _LOG.debug("Shadow mode: would send %+.0f W to %s, and holds " - "%+.0f W. Nothing was sent to the inverter.", - value, inv.name, shadow.WIRE_W) - value = shadow.WIRE_W - - # SAFETY-137: `last` (the Stop and shutdown 0 W) waits, bounded, for a - # write already on its way; once `stopping`, every other write is dropped - # here with no await before the write is made. - # DECISIONS/SAFETY-137-last-write-is-zero.md. - if last: - await self._await_inflight(inv, SETTLE_BUDGET_S) - elif self.stopping: - _LOG.debug("Shutting down, so %+.0f W to %s is not sent.", - value, inv.name) - return - changed = (inv.last_write is None or value != inv.last_write) - if not (changed or force): - return - # BOUNDED, and the budget is AC 6: one hung transport would hold the - # coroutine, the whole loop and every OTHER inverter's heartbeat. An - # abandoned write is re-issued next tick (`last_write` moves only on an - # ACCEPTED write). - try: - _w = self._driver(inv).write(value) - # SMA-10 / SAFETY-137: kept so a release or last 0 W can wait for it. - _w = asyncio.ensure_future(_w) - inv.inflight.add(_w) - _w.add_done_callback(inv.inflight.discard) - ok = await asyncio.wait_for(_w, timeout=WRITE_BUDGET_S) - except (asyncio.TimeoutError, TimeoutError): - ok = False - _LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was " - "dropped, so it is sent again next cycle.", - value, inv.name, entity, WRITE_BUDGET_S) - self.log_event(f"{inv.name}: write timed out after " - f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick", - hint=messages.hint("write_slow", unit=inv.name)) - now = datetime.now(timezone.utc) - inv.note_write(bool(ok), now) - if ok: - inv.last_write = value - inv.last_write_at = now - - # -- the cycle ----------------------------------------------------------- - def actuator_of(self, source: str, inv=None) -> str: - """Which actuator a claim from `source` is filed on this cycle. `inv` - is the roster entry the claim is ABOUT; left out it is the first - inverter (what every per-inverter claim meant at N=1). The - classification is not here: `arbiter.CLAIM_SCOPE` decides.""" - return actuator_for(source, (inv or self.inverters[0]).name, self.units) - - def live_units(self) -> tuple: - """Which inverters may be commanded THIS TICK, for the allocator. The - allocator DROPS what this does not list and the survivors carry the - whole site target, so it is reported (`Allocation.dropped`). - Answered per tick, never cached (a master goes stale). It answers - ONE question: can this controller put a number on this unit's wire. - Deliberately not "its sensors are missing". At a roster of ONE, - False is a 0 W site.""" - now = datetime.now(timezone.utc) - live = [] - for inv in self.inverters: - ok, why = inv.is_live(now) - if ok: - live.append(inv.name) - else: - # Level-logged at DEBUG only: `Allocation.dropped` is the - # operator-facing surface (edge-logged in cycle()); a line - # every second would flush the 40-row action log. - _LOG.debug("%s is not commandable this tick: %s", inv.name, why) - return tuple(live) - - def liveness_notes(self) -> list: - """Why each unit is or is not commandable, for the status page. - SAFETY-37's stale-reading line rides here too: a different fault, a - different remedy (a dropped unit is carried by the survivors; a - stale power register is a missing input).""" - now = datetime.now(timezone.utc) - return [n for n in - [x for inv in self.inverters - for x in (inv.link_note(now), inv.stale_note(), - inv.refusal_note())] - if n] - - def _provider_gate(self, now: datetime) -> None: - """PRICE-01: `cheap`, the gate and the gap from the standing plan. No - price for now is a closed gate, so the charge waits rather than run - unpriced; the gap flag stays False until the first refresh has - finished. (DECISIONS/MAIN-orchestration-archive.md)""" - plan = self.fresh_tariff_plan(now) - published = None if plan is None else plan.published - if published is not None: - self._last_published = published - gate = gate_for(published, now) - self.cheap, self._price_gated = gate.cheap, True - self.tariff_words = gate.words - # Owner, 2026-10-14: the words say 'maintenance waits', so they show - # only while the maintenance charge is on. - self.price_gap = ((not gate.covered) and self.tariff_at is not None - and MaintConfig.from_options(self.o).enabled) - last = self._last_published - if gate.covered or last is None: - self.price_gap_since = None - elif last.covers(now): - # The last series still covers now, but no refresh has made it - # current for TARIFF_MAX_AGE_S: the refresh task has stopped. The - # gap began when the plan was retired. - self.price_gap_since = ( - None if self.tariff_at is None - else self.tariff_at + timedelta(seconds=TARIFF_MAX_AGE_S)) - else: - self.price_gap_since = last.last_end_before(now) - - def _static_gated(self, o: dict, now: datetime) -> bool: - """`static_gated()`, worked out once an hour: it builds the same - month-long plan as `static_status()` (about 20 ms with a profile), and - whether a fixed tariff is set up does not change from tick to tick - (options change only with a restart).""" - key = (id(o), now.strftime("%Y-%m-%d %H")) - if key != self._gated_key: - self._gated_key, self._gated = key, static_gated(o, now) - return self._gated - - def _charge_gate_open(self, inv) -> bool: - """MAINT-13: may this pack charge now, for the maintenance timeout? - Closed only when a price gate exists, it is shut, and the pack is not - really taking charge. (DECISIONS/MAIN-orchestration-archive.md)""" - if not self._price_gated or self.cheap: - return True - batt = inv.batt - return batt is not None and float(batt) <= -MAINT_CHARGING_W - - async def cycle(self) -> None: - """Gather claims, resolve precedence, write the winner. Nothing here - decides who wins (arbiter.py does, by one rule); this method only - states what each strategy wants. - (DECISIONS/MAIN-orchestration-archive.md)""" - now = datetime.now(timezone.utc) - # BEFORE the first `return` of this method on purpose (TARIFF-11): it - # stamps that a cycle RAN, a different fact from the decisions it may - # reach. - self.cycle_at = now - - # Each pack on ITS OWN reading, not "the SoC": 12's recalibration - # drains and refills ONE pack and grades ONE coulomb counter; a minimum - # would abort on a sibling's state and an average would declare a pack - # full that is not. `arbitrate()` runs first, once, over all of them. - _off = ({inv.name for inv in self.inverters - if not self.pack_mode(inv).use} if self.modes_on else set()) - for inv, machine in zip(self.inverters, self.maints): - if inv.name in _off: - self._maint_stop_off(machine, now) - _on = [m for inv, m in zip(self.inverters, self.maints) - if inv.name not in _off] - _plan_on = iter(maint_arbitrate(_on, now) if _off - else maint_arbitrate(self.maints, now)) - maint_plan = [None if inv.name in _off else next(_plan_on) - for inv, _m in zip(self.inverters, self.maints)] - maint_results = [] - for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan): - if blocked is None: - maint_results.append(MaintResult(IDLE, False, None, False, [])) - continue - # MAINT-13: the charge timeout counts only the minutes the pack was - # allowed to charge (see `_charge_gate_open`). - res = machine.tick(now, inv.soc, blocked_by=blocked, - gate_open=self._charge_gate_open(inv)) - for msg in res.events: - self.log_event(msg, hint=messages.hint( - "maint_failed" if res.failed else "maintenance", - phase=machine.phase)) - if res.failed: - # Said once: only the tick that failed carries `res.failed`. - multi = len(self.inverters) > 1 - slug = (unit_slugs(self.units)[self.inverters.index(inv)] - if multi else "battery") - await self.hass.call( - "persistent_notification", "create", - messages.maint_failed( - res.failed, FAIL_BACKOFF_DAYS, - unit=inv.name if multi else "", slug=slug).data()) - maint_results.append(res) - # Carried to the status page, not left in the 40-line action log: the - # deferral is edge-logged, and a pack that is due, idle and WAITING - # must be able to say so when looked at (12's failure mode is a - # schedule that silently stops firing). - self._maint_deferred = [(m.name, r.deferred) - for m, r in zip(self.maints, maint_results) - if r.deferred] - - # SAFETY-02, and it runs BEFORE the early return below: evaluated every - # cycle, else a run of held cycles leaves `disagree_since` untouched - # and the timer resumes from a start time that predates the gap. One - # watchdog per inverter on that inverter's own reading; what it is - # compared against is decided by the roster (SAFETY-30). - self._arm_sign_watchdogs() - n_units = len(self.inverters) - standing = {} - for inv in self.inverters: - was_tripped = inv.sign_wd.tripped - # `Inverter.standing_w()`, not the expression it replaces: - # SAFETY-37's freshness gate arms off the same method. - stood = inv.standing_w() - standing[inv.name] = stood - against = self.grid if n_units <= 1 else inv.batt - if inv.sign_wd.evaluate(stood, against, self.following, now) \ - and not was_tripped: - # REASON first, FLAG second (each `set` writes the file): a - # crash between the two persists a reason nothing reads, not a - # latch with no explanation. Both keys are namespaced to this - # unit. - inv.store_set("sign_reason", inv.sign_wd.reason) - inv.store_set("sign_tripped", True) - # The legacy flat keys are MIRRORED, the other half of store.py - # rule 2: that rule only covered the latch existing at - # migration time, so a trip after it wrote nothing flat and a - # rollback would find no latch. - self.store.set("sign_reason", inv.sign_wd.reason) - self.store.set("sign_tripped", True) - self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason, - hint=messages.hint("direction", unit=inv.name)) - _LOG.error("%s is set to 0 W until control is started, " - "because its power went the wrong way: %s.", - inv.name, messages.reason_code(inv.sign_wd.reason)) - _LOG.debug("Evidence for %s: %s.", inv.name, - inv.sign_wd.reason) - # The site's own standing command is the SUM of the units': SAFETY-07 - # and SAFETY-18 grade a SITE quantity (the quarter average at the grid - # connection; a meter not moving while the site pushes near its rail). - # Same number at a roster of one. - site_standing = float(sum(standing.values())) - - # SAFETY-30: the site half of the sign check: the TOTAL standing on the - # wire against the meter, catching a fault no per-unit check sees - # (every unit faithfully executing the wrong total). Only at N>=2 (at - # one inverter it would duplicate `inverters[0].sign_wd`; AC 8). Flat - # keys, not namespaced. - if n_units > 1: - was_site = self.site_sign_wd.tripped - if self.site_sign_wd.evaluate(site_standing, self.grid, - self.following, now) and not was_site: - # REASON first, FLAG second, as the per-unit trip and - # SAFETY-31. The keys are flat, not namespaced, and not the - # per-unit pair (store.py). - self.store.set("site_sign_reason", self.site_sign_wd.reason) - self.store.set("site_sign_tripped", True) - self.log_event("FAILSAFE site: " + self.site_sign_wd.reason, - hint=messages.hint("site_direction")) - _LOG.error("Every inverter is set to 0 W until control is " - "started, because the total power went the wrong " - "way: %s.", - messages.reason_code(self.site_sign_wd.reason)) - _LOG.debug("Evidence for the site: %s.", - self.site_sign_wd.reason) - - # SAFETY-31, beside SAFETY-02 for the same reason: evaluated EVERY - # cycle, unconditionally, above the input hold (else `over_since` - # resumes from a start that predates the gap). The input is MEASURED - # power, never commanded: a unit ignoring or lagging its share, a stale - # share still executing. - circ_w = self.site_circulating_w - was_circ = self.circ_wd.tripped - if self.circ_wd.evaluate(circ_w, now) and not was_circ: - # REASON first, FLAG second (each `set` writes the file): a crash - # between must not persist a latch with no explanation. - self.store.set("circ_reason", self.circ_wd.reason) - self.store.set("circ_tripped", True) - self._circ_announced = False - self.log_event("CIRCULATING POWER - " + self.circ_wd.reason, - hint=messages.hint("circulating")) - _LOG.error( - "Every inverter is set to 0 W until control is started, " - "because the inverters worked against each other: %s.", - messages.reason_code(self.circ_wd.reason)) - _LOG.debug("Evidence for the site: %s.", self.circ_wd.reason) - - # SAFETY-42, for SAFETY-02's and SAFETY-31's reason: evaluated every - # cycle above the input hold. The operands are `standing` and - # `inv.batt`, the pair SAFETY-30 uses. `delinquent()` is called once - # over the whole roster, and every number is the configured one, never - # a literal that could drift (`self.delivery_cfg`, not - # `DeliveryConfig()`: part 4b grades a hold that is not the shipped 185 - # s). - for inv in self.inverters: - # `self.delivery_cfg`, not `DeliveryConfig()`: the window is - # `delivery_hold_s`, and part 4b grades a hold that is not the - # shipped 185 s. - 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. (DECISIONS/MAIN-orchestration-archive.md) - live=inv.is_live(now)[0], - # SAFETY-73: a refusing unit is asked 0 W on purpose, and - # one asked under PROBE_W that gives under 100 W is a - # refusal too small to call (its sibling covers the meter). - parked=inv.sign_wd.tripped or bool(inv.refused) or ( - n_units > 1 and inv.batt is not None - and abs(float(standing[inv.name])) <= _ctl.PROBE_W - and abs(float(inv.batt) - inv.idle_w) - < _ctl.REFUSED_BELOW_W), - # ⚠️ THIS UNIT'S OWN CUT-OUT (`bms_floor`): not the shipped - # 10 % and not the site's `peak_soc_floor`, which bounds a - # money-saving discharge only. - # (DECISIONS/MAIN-orchestration-archive.md) - 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 (as - # the three trips above): a crash between must not persist a - # latch with no explanation. ⚠️ NO FLAT MIRROR OF THIS PAIR, - # unlike SAFETY-02: store.py's rule 2 keeps flat keys so a - # rollback finds its latch, but the version rolled back TO - # drives one actuator, where this check is inert; a mirror - # would hand it a latch about a roster it cannot have. - # DECISIONS/MAIN-orchestration.md. - 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, - hint=messages.hint("not_delivering", - unit=inv.name)) - _LOG.error( - "%s is not delivering while control keeps running, and " - "only stopping and starting control clears this: %s.", - inv.name, messages.reason_code(inv.delivery_wd.reason)) - _LOG.debug("Evidence for %s: %s.", inv.name, - inv.delivery_wd.reason) - _LOG.debug( - "SAFETY-42 reports and does not act: commanding a unit " - "that is not listening achieves nothing (HW-01), and the " - "only re-allocation the add-on can express would throw " - "away a partly delivering unit's output.") - - stale_after = float(self.o.get("stale_input_s", 15)) - late_s = self._readings_late(site_standing) # SAFETY-128 - self._late_zero = late_s is not None and late_s >= stale_after - # ⚠️ `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 list below, because its `failsafe` SET is what parks it. - # SAFETY-30's site latch takes the roster past the hold too (its - # `sign_site` SET parks every inverter): the trip would stop acting the - # moment a poll was missed. (DECISIONS/MAIN-orchestration-archive.md) - if not self.inputs_ok and not any(i.sign_wd.tripped - for i in self.inverters) \ - and not (n_units > 1 and self.site_sign_wd.tripped): - bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0 - if bad_for < stale_after: - # A single missed poll is not a fault. Hold, keep the heartbeat - # going, and give it a few seconds to come back. - self.reason = f"inputs missing {bad_for:.0f}s" - # UX-10: the age is rendered when the page asks, not frozen at - # this cycle's. (DECISIONS/MAIN-orchestration-archive.md) - self.why_words = messages.why_waiting(bad_for) - self._why_waiting_since = now - timedelta(seconds=bad_for) - return - if late_s is not None and late_s < stale_after and self.inputs_ok \ - and (self.auto or self.shadow) \ - and not any(i.sign_wd.tripped for i in self.inverters) \ - and not (n_units > 1 and self.site_sign_wd.tripped): - self.reason = f"readings late {late_s:.0f}s" # SAFETY-128: hold - self.why_words = messages.why_late() - return - - claims = [] - - # safety limits: these bind EVERY strategy, always. Defence in depth: - # write_setpoint() clamps to the device range too, and the duplication - # is deliberate. ⚠️ PER-INVERTER, ONE PAIR PER UNIT from THAT unit's - # own min/max and supervised cap: there is no site rating - # (arbiter.CLAIM_SCOPE). (DECISIONS/MAIN-orchestration-archive.md) - for inv in self.inverters: - if inv.dev_min is not None and inv.dev_max is not None: - claims.append(Claim.limit("device", P_SAFETY, inv.dev_min, inv.dev_max, "rating", - actuator=self.actuator_of("device", inv))) - # `max_w` is the supervised cap on what may be WRITTEN to one - # setpoint entity (§11.3), not a bound on the grid connection. - claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w", - actuator=self.actuator_of("supervised", inv))) - # OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS. ⚠️ AN ENVELOPE, - # NOT A WEIGHT: a zero weight does not stop `allocator._fill()` - # commanding a pack (at or under its BMS cut-out `hi = 0`, at 100 % - # `lo = 0`). ⚠️ NEVER FILED AT N=1 (it would bound the site target: - # SAFETY-25 Option A), so there nothing here stops the loop - # discharging into the BMS cut-out; the BMS does. - # (DECISIONS/MAIN-orchestration-archive.md) - if n_units > 1: - _pe = packshare.envelope(pack_energy_of(inv)) - if _pe.binding: - claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi, - _pe.why, - actuator=self.actuator_of("pack", inv))) - # The SITE bound, for the site-level consumers below. It is the roster - # sum: what this site can deliver, not what one of its inverters can. - max_w = self.site_max_w - - # safety stops. ⚠️ SAFETY-02 is claimed FIRST and unconditionally while - # latched, a SET at P_SAFETY, and re-filed EVERY cycle (claims are not - # sticky). ⚠️ AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE: - # at N>=2 the evidence cannot be pinned on a sibling this controller - # never commanded (arbiter.CLAIM_SCOPE). - # (DECISIONS/MAIN-orchestration-archive.md) - for inv in self.inverters: - if inv.sign_wd.tripped: - claims.append(Claim.set("failsafe", P_SAFETY, 0.0, - "sign disagreement", - actuator=self.actuator_of("failsafe", inv))) - - # SAFETY-30: THE SITE SIGN TRIP, A SITE SET: the SUM of every standing - # command against the meter, which no single inverter owns; parking one - # unit on it would leave the rest pushing the same way. ⚠️ The `n_units - # > 1` guard is not redundant: the latch is restored from the store, - # and an install reconfigured back to one inverter must come up - # driving. (DECISIONS/MAIN-orchestration-archive.md) - if n_units > 1 and self.site_sign_wd.tripped: - claims.append(Claim.set("sign_site", P_SAFETY, 0.0, - "site sign disagreement", - actuator=self.actuator_of("sign_site"))) - - # SAFETY-31: ⚠️ A SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. A - # circulating loop needs two participants and one is this controller: - # stopping it halves the loop and makes the residue visible at P1. It - # does NOT stop a wedged inverter (HW-01); the remaining actuator is a - # human at the isolator. A site claim: circulating power is zero for - # any one unit (arbiter.CLAIM_SCOPE). - # (DECISIONS/MAIN-orchestration-archive.md) - if self.circ_wd.tripped: - claims.append(Claim.set("circulation", P_SAFETY, 0.0, - "circulating power", - actuator=self.actuator_of("circulation"))) - - # SITE, both of them: "stopped" (the operator switching control off) - # and "inputs missing" (the meter gone) are not about a pack. ⚠️ - # SAFETY-10: `and not self.shadow` is the one failsafe this card - # changes (a stop claim would record "stopped" on every shadow cycle); - # the wire gate in `_write_unit()` is stronger. - # (DECISIONS/MAIN-orchestration-archive.md) - if not self.auto and not self.shadow: - claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped", - actuator=self.actuator_of("safety"))) - elif not self.inputs_ok: - if self.target != 0.0: - self.log_event("inputs missing - commanding 0 W", - hint=messages.hint("inputs_missing")) - claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing", - actuator=self.actuator_of("safety"))) - elif late_s is not None: - claims.append(Claim.set("safety", P_SAFETY, 0.0, "readings late", - actuator=self.actuator_of("safety"))) - - # SAFETY-07: the quarter-peak projection guard: ONE `lo` limit at - # P_PEAK; peakguard.py carries the argument. ⚠️ `frozen` is evaluated - # on THIS cycle's standing command, not `self.freeze_wd.active` (a - # cycle behind). ⚠️ `achievable_w` keeps the floor from filing `lo > - # hi` (0 W). ⚠️ Filed EVERY cycle (claims are not sticky). ⚠️ NOT RUN - # WITHOUT P1: it would be blind, and blind means "no charging"; the - # report is NOT COVERED. (DECISIONS/MAIN-orchestration-archive.md) - self._peak_proj = (None if not self.p1_enabled - else self.peak.project(self.p1.averager, now, - roll=self.modes_on)) - # OPT-15 x SAFETY-81: see `check_peak_saturated()`. - self._peak_rolled = (self._peak_proj is not None - and self._peak_proj.block_start is not None - and self._peak_proj.block_start - != self.p1.averager.block_start) - _reading = self.peak.peak(now) if self.p1_enabled else None - # SAFETY-90: seconds a meter gap left uncounted are counted, so the - # guard goes on after the readings return. - self._peak_proj = self._fill_peak_gaps(self._peak_proj, now, _reading) - # SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter, - # so a HOLD's ceiling does not lapse for a cycle onto a held battery. - if self._peak_proj is not None and len(self.inverters) > 1: - self._peak_proj = next_quarter(self._peak_proj, - self.p1.averager.max_hold_s) - self.peak_guard = None if not self.p1_enabled else peakguard.decide( - self._peak_proj, - # ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is - # ALREADY removing from the meter, and the meter sees every - # inverter. (DECISIONS/MAIN-orchestration-archive.md) - batt_w=self.site_batt_w, - cap_w=float(self.o.get("peak_cap_w", 3500)), - margin_w=self.peak_margin_w, - reading=_reading, - # ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each unit - # contributes `min(its max_w, |its own device bounds|)`, so - # inverter B's rating bounds inverter B. - # (DECISIONS/MAIN-orchestration-archive.md) - achievable_w=self.site_rating_w, - # ⚠️ SAFETY-24. `pack_floor_soc` IS A MEASURED READING AND MAY BE - # None, and it is handed over as None rather than defaulted (a - # defaulted 100 % is a full battery, the value that disarms this - # floor); `decide()` treats None as floor-limited. - # (DECISIONS/MAIN-orchestration-archive.md) - soc=self.pack_floor_soc, - # ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN - # CUT-OUTS: `binding_soc_floor()` takes the MAXIMUM with each - # pack's `bms_soc_floor`, pairing with the MINIMUM in - # `pack_floor_soc` (both move towards refusing a shave). - # "Unreachable" is a claim about rule ordering, and sim/inject.py - # does not run rule 8: asserted over the whole schema range in - # test_peakguard.py part 12. - # (DECISIONS/MAIN-orchestration-archive.md) - soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get( - "peak_soc_floor", - peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))), - max_hold_s=self.p1.averager.max_hold_s, - # ⚠️ SAFETY-20. The device range goes in TOO: `standing` is - # `last_write`, the value AFTER `clamp_to_rating()`, so a rail from - # bare `max_w` is a ceiling the device cannot reach. - # (DECISIONS/MAIN-orchestration-archive.md) - frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None), - site_standing, max_w, self.freeze_wd.cfg, - dev_min=self.site_dev_bound("dev_min"), - dev_max=self.site_dev_bound("dev_max")), - ) - # SAFETY-87: at N>=2 a battery at or under its own reserve gives - # nothing for the peak, and the others carry the guard's need. - if self.peak_guard is not None: - self.peak_guard = self._peak_on_able(self.peak_guard, - maint_results) - self.peak_guard = self._peak_landing(self.peak_guard) - self.peak_guard = peakguard.cap_floor( # SAFETY-128 - self.peak_guard, self.answer.peak_cap(site_standing)) - # SITE, and peakguard.claim() says so on the object it builds: the - # quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION, - # which the capacity tariff bills (one quarter-hour peak per site, not - # per pack). ⚠️ It is also the `lo` half of SAFETY-25 §0c's table: a - # site `lo` that the deferred loop reserve's per-inverter `hi` must - # never cross. (DECISIONS/MAIN-orchestration-archive.md) - peak_claim = (peakguard.claim(self.peak_guard) - if self.peak_guard is not None else None) - if peak_claim is not None: - claims.append(peak_claim) - # ⚠️ EDGE-LOGGED, not level-logged: the action log holds 40 lines, and - # a line every 15 s for a quarter would push the cause off the end. - # (DECISIONS/MAIN-orchestration-archive.md) - engaged_now = self.peak_guard is not None and self.peak_guard.engaged - if engaged_now != self._peak_engaged: - self._peak_engaged = engaged_now - if self.peak_guard is not None: - _pg = self.peak_guard - self.log_event(_pg.explain(), hint=messages.hint( - "peak_off" if not _pg.engaged else - "peak_blind" if _pg.blind else - "peak_on" if _pg.within_aim else - "peak_reserve" if _pg.floor_limited else - "peak_saturated" if _pg.saturated else "peak_on")) - - # OPT-04: price arbitrage (§10.1 priority 3): ONE `set` claim at - # P_ARBITRAGE; arbitrage.py carries the argument. ⚠️ AFTER THE PEAK - # BLOCK BECAUSE IT READS ITS CONCLUSION (`engaged_now`). ⚠️ The - # three-state collapse lives in arbitrage.guard_state(), NOT here (None - # = the guard never ran, without P1). ⚠️ NO FETCH ON THIS PATH. ⚠️ The - # uncertainty set is NOT empty (no weather forecast is built). - # TARIFF-11: `arb_at` keeps the status row honest. - # (DECISIONS/MAIN-orchestration-archive.md) - self.arb, self.arb_at = None, now - self.arb_headroom_w = None - self.room_charge = False - # OPT-11 P3: with battery modes on, each battery's mode decides who - # trades (`_mode_roles()`, `_mode_trade()`, below, which read the - # same headroom), and this site-wide trade is not run. - site_trade = arbitrage.enabled(self.o) and not self.modes_on - if site_trade or self.modes_on: - # SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED: one evaluation - # feeds both the sizing below and `arbitrage.sized_from_meter()`, - # so the caveat on the recorded watts is never answered from a - # different cycle's guard. - # (DECISIONS/MAIN-orchestration-archive.md) - self.arb_headroom_w = peakguard.charge_room_w(self.peak_guard, - self._peak_proj) - if site_trade: - floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) - charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct) - self.arb = arbitrage.decide( - self.fresh_tariff_plan(now), now, - peak_guard_active=arbitrage.guard_state(self.peak_guard), - # SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS - # ONE: the watts the billed quarter can still afford clamp the - # buy leg (a switch alone gave 28 charge onsets an hour on - # TEST-23). ⚠️ It reads the peak block's conclusion, so it must - # stay after it. (DECISIONS/MAIN-orchestration-archive.md) - charge_headroom_w=self.arb_headroom_w, - # The emptiest pack again, and for the sell side that is the - # right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there - # is one `soc` argument - `pack_floor_soc` names that residual - # rather than hiding it; a per-pack charge ceiling is CFG-04's, - # and at the shipped roster of one the two reductions are the - # same number. - soc=self.pack_floor_soc, - min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), - uncertainty=frozenset({Uncertainty.NO_WEATHER}), - # OPT-09. PER-PACK SIZING, not `max_w`: the trade is sized to - # the packs whose own state of charge permits that direction - # (`trade_power_w()`). ⚠️ At N=1 both sides are `max_w` - # exactly. ⚠️ On the sell side this is a no-op today (`soc=` - # below is the EMPTIEST pack); test_packshare.py part 9 grades - # it. (DECISIONS/MAIN-orchestration-archive.md) - charge_w=charge_w, discharge_w=discharge_w, - soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct) - # SITE, and arbitrage.claim() says so on the object it builds. It - # buys and sells at the grid against a day-ahead price: the price is - # a property of the connection and the energy crosses the meter. - arb_claim = arbitrage.claim(self.arb) - if arb_claim is not None: - claims.append(arb_claim) - self.room_charge = self.arb.action == arbitrage.CHARGE - # Edge-logged, for the peak guard's reason: the action log holds 40 - # lines and a trade lasts a whole hour, so a line per cycle would - # flush the context that explains it. - # (DECISIONS/MAIN-orchestration-archive.md) - if self.arb.action != self._arb_action: - self._arb_action = self.arb.action - self.log_event(self.arb.explain(), hint=messages.hint( - "trade_charge" if self.arb.action == arbitrage.CHARGE else - "trade_give" if self.arb.action == arbitrage.DISCHARGE - else "trade_off")) - - # maintenance ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST - # STAYS SHORT. (DECISIONS/MAIN-orchestration-archive.md) - for inv, result in zip(self.inverters, maint_results): - if result.owns_setpoint: - # PER-INVERTER: §12's recalibration is a PACK procedure, filed - # on the pack whose state machine produced it (`inv`, - # MAINT-05). ⚠️ IT STAYS `SCOPE_UNIT`, never a site claim - # (ARCH-01: per-inverter limits filed site-wide are the - # cross-inverter `lo > hi`). - # (DECISIONS/MAIN-orchestration-archive.md) - claims.append(Claim.set("maintenance", P_MAINTENANCE, - float(result.setpoint_w or 0.0), result.phase, - actuator=self.actuator_of("maintenance", inv))) - self.sat_count = 0 - elif result.charge_only: - # Money outranks the maintenance schedule: while the projection - # is over the cap the charge-only shaping is not claimed, so - # the loop can shave. ⚠️ Shaping is `hi <= 0` and the peak - # claim `lo >= 0`; both filed give `lo > hi`, which resolves to - # 0 W in the quarter that sets the bill, so the higher claimant - # SUPPRESSES the lower. ⚠️ THE CONJUNCTION LIVES IN - # peakguard.shaping_suspended(), NOT HERE. One suspension flag - # per SITE (`arbitrate()` allows one pack in CHARGE). - # (DECISIONS/MAIN-orchestration-archive.md) - if peakguard.shaping_suspended( - self.peak_guard, self.peak_fc, self.peak_aim_w()): - # Edge-logged: level-logging here would write one line per - # cycle for as long as the event lasts and flush the 40-line - # action log of the very context that explains it. - if not self._maint_shape_suspended: - self._maint_shape_suspended = True - self.log_event("peak at risk - maintenance charge shaping " - "suspended so the peak claim is not " - "contradicted", - hint=messages.hint("maint_paused")) - else: - self._maint_shape_suspended = False - hi = 0.0 - reason = "charge-only" - if self.cheap: - # ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND - # READ OF `self.o`: a literal fallback here let the - # shaping and the state machine use different rates - # from the same install. - # (DECISIONS/MAIN-orchestration-archive.md) - floor = maintenance_charge_floor( - self.maint.cfg.charge_w, - self.peak_fc, self.peak_aim_w()) - floor = self._charge_headroom(floor, claims) - if floor > 0: - hi, reason = -floor, "cheap-window charge" - # PER-INVERTER, like the SET above and for the same reason - - # the shaping is about the pack being charged, which is `inv`. - claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, - reason=reason, - actuator=self.actuator_of("maintenance", - inv))) - - # OPT-11: each battery's mode, a soft unit limit at P_MODE. Filed only - # with battery modes on, and never on a battery in any maintenance - # phase (maintenance outranks the mode). The range always holds 0 W - # (packmode.mode_range()) and gives way to the peak floor only as far - # as needed, so the two never meet in `lo > hi` - # (arbiter._unit_modes()). (DECISIONS/MAIN-orchestration-archive.md) - if self.modes_on: - tick = self._mode_roles(now, maint_results) - for inv, result in zip(self.inverters, maint_results): - if result.phase != IDLE: - continue - pm = self.pack_mode(inv) - lo, hi = packmode.mode_range(pm, inv.max_w) - rlo, rhi = tick.ranges.get(inv.name, (lo, hi)) - lo, hi = max(lo, rlo), min(hi, rhi) - claims.append(Claim.limit("mode", P_MODE, lo, hi, pm.key, - actuator=self.actuator_of("mode", - inv))) - - # the grid-following loop. ⚠️ SAFETY-10: `or self.shadow`: the loop is - # the leg a shadow run most needs; it is safe because the loop's - # `prev_w` is `self.target`, re-seeded to what is on the wire. - # SAFETY-52: a refusal learned while control ran does not survive a - # stop or a start. (DECISIONS/MAIN-orchestration-archive.md) - if bool(self.auto) != self._refusal_auto: - self._refusal_auto = bool(self.auto) - self._refusal_end("control started" if self.auto - else "control stopped") - for inv in (self.inverters if len(self.inverters) > 1 else ()): - self._unit_refusal_end(inv, "control started" if self.auto - else "control stopped") - # SAFETY-73: each unit's own refusal, engaged and ended on its own - # reading, before the loop reads `self.target` (an end reseeds it). - _probing = {} - if len(self.inverters) > 1 and self.inputs_ok: - self._unit_refusals() - _probing = {inv.name: self._unit_probe(inv) - for inv in self.inverters if inv.refused} - # SAFETY-73: each refusing unit's own P_SAFETY claim: a hard unit limit - # that always intersects (OPT-11's P_MODE sits inside it), closed in - # the refused direction, so the allocator hands the share to the others - # within their own limits; a pin at PROBE_W during its probe. Filed - # before the loop so OPT-11's loop bound sees it. - # (DECISIONS/MAIN-orchestration-archive.md) - for inv in self.inverters: - if not inv.refused: - continue - if _probing.get(inv.name): - claims.append(Claim.set("refused", P_SAFETY, - inv.refused * _ctl.PROBE_W, - _ctl.PROBING, - actuator=self.actuator_of("refused", - inv))) - else: - claims.append(Claim.limit("refused", P_SAFETY, - *_refused_interval(inv.refused), - _ctl.REFUSED, - actuator=self.actuator_of("refused", - inv))) - # --- SAFETY-78: a pinned drain is a floor under the site target ----- - # After every unit claim, so the floor is what the pin really places. - drain = self._drain_floor(claims, maint_results) - if drain is not None: - claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain, - reason="maintenance drain", - actuator=self.actuator_of("drain"))) - self._peak_over_drain(claims, maint_results) - # --- SAFETY-86: while a HOLD gives, the site stays at the peak floor -- - # So the loop never asks for watts the hold keeps back. - hold_hi = self._peak_over_hold(claims, maint_results) - if hold_hi is not None: - claims.append(Claim.limit("hold", P_MAINTENANCE, hi=hold_hi, - reason="maintenance hold", - actuator=self.actuator_of("hold"))) - # --- SAFETY-91: a drain rises before a held battery gives ------------ - self._drain_rise(claims, maint_results) - # --- SAFETY-84: a cheap-window charge is a ceiling over the site ---- - charge = self._charge_ceiling(claims) - if charge is not None: - claims.append(Claim.limit("charge", P_MAINT_SHAPE, hi=charge, - reason="maintenance charge", - actuator=self.actuator_of("charge"))) - # --- OPT-11 P3: the site trade, after every unit claim -------------- - # Sized from what the batteries may place with their modes and hard - # limits (the loop bound), so none is asked for watts it may not give. - mode_trade, trade_was = False, self._mode_trade_was - if self.modes_on: - mode_trade = self._mode_trade(claims, now) - self.room_charge = self.room_charge or ( - mode_trade and self.arb.action == arbitrage.CHARGE) - self._mode_trade_was = mode_trade - self._charge_track(claims, maint_results) - _live96 = set(self.live_units()) - self._loop_start = {} - if (self.auto or self.shadow) and self.inputs_ok: - # ⚠️ prev is the ARBITER's last output, not the loop's own wish: if - # something outranked the loop, that is what the hardware did. The - # integrator re-seeds from the same (entering failsafe re-seeds it - # to zero). (DECISIONS/MAIN-orchestration-archive.md) - moved = self.loop_w is None or self.target != self.loop_w - if moved: - self.i_w = self.target - # OPT-11 P3: a run edge (a mode trade ended) reseeds the loop, and - # OPT-15 holds it there until the meter shows the step. - if trade_was and not mode_trade: - self._reseed_run_edge(claims, now) - moved = False - loop_grid = self._edge_grid(mode_trade or moved, now) - # SAFETY-52: another writer overrode the loop, so a refusal learned - # against the loop's own ask may no longer describe the wire. - # (DECISIONS/MAIN-orchestration-archive.md) - rejudged = 0 - if self.loop_w is not None and self.target != self.loop_w: - rejudged = self.batt_refused - # SAFETY-96: a battery that dropped out of the split leaves the - # loop's start point (SAFETY-93's own if its charge ended too). - self._reseed_drop(_live96) - # SAFETY-93: a charge that ended leaves the loop's start point. - self._reseed_charge_end(maint_results) - # SAFETY-95: then a drain that ended, last (it reads the start - # the others left). - self._reseed_drain_end(maint_results, _live96) - # No probe while it is judged afresh (the loop does not hold the wire). - probe = False if rejudged else self._refusal_probe() - if len(self.inverters) > 1: - probe = any(_probing.values()) - # ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF. - # The anti-windup test in compute() is `abs(prev_w - actual_w) > - # saturation_w` and `prev_w` is the SITE target; one inverter's - # power at N=2 would trip the saturation freeze permanently. - # (DECISIONS/MAIN-orchestration-archive.md) - site_batt = self.site_batt_w - # SAFETY-103: what each battery can be doing (its reading may be - # older than the meter). - self._track_reads() - # SAFETY-96: a battery the loop cannot move counts at its command, - # so its own power does not freeze the loop (the meter has it). - site_batt += self._held_out_w(_live96) - # TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED - # AGAINST. (DECISIONS/MAIN-orchestration-archive.md) - loop_prev = self.target - decision = compute( - prev_w=loop_prev, - # SAFETY-95: a drain's tail is not the loop's to chase. - # OPT-15: `loop_grid` is the grid, or the target while a run - # edge is held. - grid_w=(self._drain_grid_w(_live96) if self._drain_tail - else loop_grid), - actual_w=site_batt, - tuning=self.tuning, - sat_count=self.sat_count, - i_w=self.i_w, - # SAFETY-52 at one inverter only: with several, OPT-09's pack - # limit already closes an empty battery's discharge end. - # SAFETY-73: at N>1 the site law holds 0 W only while every - # unit refuses the same way. Otherwise None, the old law. - refused=(0 if rejudged else self.batt_refused) - if len(self.inverters) == 1 - else self._site_refused(), - probe=probe, - # SAFETY-94: at N=1 the engage also needs the reading to have - # given nothing for the whole span, as at N>1. - gave_nothing=(bool(self._gave_nothing(self.inverters[0])) - if len(self.inverters) == 1 else None), - # SAFETY-97: "gives nothing" is centred on the idle reading. - idle_w=self._site_idle_w(), - hold=self.answer.hold, hold_w=self.answer.hold_w, # SAFETY-128 - # OPT-11: None unless battery modes are on. SAFETY-85: or while - # maintenance holds a battery at N>=2, so the loop does not - # wind past what the others can give and jump when it ends. - bound=(self.loop_bound(claims) if self.modes_on or ( - len(self.inverters) > 1 - and any(r.phase != IDLE for r in maint_results)) - else None), - ) - if decision.frozen and self.sat_count < self.tuning.saturation_cycles: - self.log_event(f"saturation freeze ({self.target:.0f} W vs " - f"{site_batt:.0f} W)", - hint=messages.hint("not_keeping_up")) - self.sat_count = decision.sat_count - self.i_w = decision.i_w - self.loop_w = decision.target_w - self._loop_decision = decision - if len(self.inverters) == 1: - self._refusal_update(decision, loop_prev, site_batt, probe, - rejudged) - # TEL-10. ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS: `loop_i` - # is `Decision.i_w`, the value BEFORE the clamp, slew and - # quantisation, so it is NOT `setpoint` and must not be published - # from it; `loop_lead` is how far it stands ahead of the command; - # `loop_reason` is `compute()`'s own word and NOT `self.reason`. - # (DECISIONS/MAIN-orchestration-archive.md) - self.loop_i_w = decision.i_w - self.loop_i_lead = decision.i_w - loop_prev - self.loop_reason = decision.reason - # SAFETY-03. ⚠️ READ THE BOOLEANS, NOT `decision.reason`: `reason` - # is overwritten by slew-limiting or the saturation freeze after - # the clamp, so a string test is silent on the cases a winding loop - # most often presents as. SAFETY-40: every number comes from THIS - # decision, at clamp time. - # (DECISIONS/MAIN-orchestration-archive.md) - self.loop_clamped = ( - decision.i_clamped or decision.clamped, - loop_clamp_note(decision), - decision.clamped, - ) - self.loop_clamp_facts = ( - (messages.Clamp(None, decision.requested_w, decision.clamped_w, - "rating"),) if decision.clamped else ()) - # SITE. compute() regulates `grid_w` - the P1 measurement - to - # zero. Its subject is the meter, and §6.2's architecture is ONE - # integrator on P1 whose output is then SPLIT. - # (DECISIONS/MAIN-orchestration-archive.md) - claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason, - actuator=self.actuator_of("loop"))) - else: - # Stopped or blind: no accumulation may survive the outage. - self.i_w = 0.0 - self.loop_w = None - self._live_was = frozenset(_live96) - # TEL-10, and it is the same zero the line above writes rather than - # a second opinion about it. ⚠️ `not-running` rather than a stale - # `tracking`: a reason row that keeps repeating the last word the - # loop said while the loop is not running is a row that reads as - # health, which is the failure `publish()`'s own None-dropping note - # is about one field over. - self.loop_i_w = 0.0 - self.loop_i_lead = 0.0 - self.loop_reason = "not-running" - # A loop that is not running cannot be winding. Clearing this here - # rather than letting the last value stand is what makes the alarm - # de-assert when control is stopped. - self.loop_clamped = (False, "", False) - self.loop_clamp_facts = () - self._loop_decision = None - - # SAFETY-28. A new resolution is a new tick, and the previous record is - # stale from this line onwards. Incremented BEFORE resolve() so that the - # record resolve() builds is stamped with the tick it is for. - self.alloc_tick += 1 - # ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. A METHOD, NOT - # A DICT: the weighting is by ENERGY HEADROOM, so it depends on the - # direction (the sign of the site target, chosen inside `resolve()`; - # see `pack_weights()`). ⚠️ A PREFERENCE, NOT A PERMISSION: a weight - # cannot stop a unit being commanded. - # (DECISIONS/MAIN-orchestration-archive.md) - _live = self.live_units() - while True: - resolution = resolve(claims, units=self.units, live=_live, - weights=self.pack_weights, tick=self.alloc_tick, - reserve=self._held_back()) - # SAFETY-73: a probe pin stands only while the resolved site target - # asks at least PROBE_W its way; below that a sibling would carry - # the opposite direction (circulation), so the pin goes back to the - # closed limit. (DECISIONS/MAIN-orchestration-archive.md) - _void = [k for k, c in enumerate(claims) - if c.source == "refused" and c.kind == "set" - and c.value * resolution.target_w < _ctl.PROBE_W ** 2] - if not _void: - break - for k in _void: - claims[k] = Claim.limit( - "refused", P_SAFETY, - *_refused_interval(1 if claims[k].value > 0 else -1), - _ctl.REFUSED, actuator=claims[k].actuator) - self.allocation = resolution.allocation - if self.modes_on: - self._mode_fault_log(resolution) - # THE TWO REFUSALS ARE NOT THE SAME REFUSAL: both REPLACE the - # resolution with a 0 W failsafe, written like any other cycle; they - # differ in WHO refused, hence two branches and two messages. ⚠️ - # NEITHER MAY BE LEFT UNGRADED (test_peakguard.py part 10h). - # (DECISIONS/MAIN-orchestration-archive.md) - if not resolution.allocated: - self.log_event("ARBITER: " + resolution.allocation.explain(), - hint=messages.hint("split")) - resolution = Resolution(0.0, "failsafe", None, False, - resolution.considered, - unit_bounds=resolution.unit_bounds, - shares={}, allocated=False, - scope_errors=resolution.scope_errors, - allocation=resolution.allocation, - winner_priority=resolution.winner_priority) - # 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT. ⚠️ - # RE-AIMED BY DEV-02, NOT DELETED: a roster entry with no writer would - # have the site target written to the others (over-command by its - # share); "nothing" here means 0 W. Not folded into `live_units()`: a - # dead link is dropped and announced, an undeliverable roster stops the - # site. (DECISIONS/MAIN-orchestration-archive.md) - elif {u for u in resolution.shares if not self._can_write(u)}: - undeliverable = ", ".join( - sorted(u for u in resolution.shares if not self._can_write(u))) - self.log_event( - f"ARBITER: the site target was allocated across " - f"{len(resolution.shares)} inverters and this controller has no " - f"setpoint entity for {undeliverable} - refusing to command a " - f"partial site", hint=messages.hint("no_setpoint")) - # ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE - # DEV-02 THAT IS LOAD-BEARING: the write loop commands - # `shares.get(name, 0.0)`, so carrying the split through would - # write the numbers this branch declined, or nothing at all when - # they equal what stands (an unchanged value is not re-sent). - # (DECISIONS/MAIN-orchestration-archive.md) - resolution = Resolution(0.0, "failsafe", None, False, - resolution.considered, - unit_bounds=resolution.unit_bounds, - shares={}, allocated=True, - scope_errors=resolution.scope_errors, - allocation=resolution.allocation, - winner_priority=resolution.winner_priority) - # 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its - # share, a real change in what the site can deliver, and an operator - # who is not told reads a working site. Edge-triggered on the SET of - # dropped units (the action log is a log of events, §15). - # (DECISIONS/MAIN-orchestration-archive.md) - _dropped = tuple(u for u, _why in (self.allocation.dropped - if self.allocation else ())) - if _dropped != self._dropped_last: - for _u, _why in (self.allocation.dropped if self.allocation else ()): - self.log_event(f"ALLOCATOR: {_u} dropped from the split " - f"({_why}) - the remaining inverter(s) now carry " - f"its share of the site target", - hint=messages.hint("unit_dropped", unit=_u)) - if self._dropped_last and not _dropped: - self.log_event("ALLOCATOR: every inverter is back in the split", - hint=messages.hint("units_back")) - self._dropped_last = _dropped - # 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER. - # `unmet_w` is the site target the envelopes could not absorb; not a - # broken allocation (fusing it with `ok` would let one saturated - # inverter park the site), but the site failing to deliver what the - # loop asked. Edge-triggered on the VALUE. - # (DECISIONS/MAIN-orchestration-archive.md) - _unmet = int(self.allocation.unmet_w if self.allocation else 0) - if _unmet != self._unmet_last: - if _unmet: - self.log_event( - f"ALLOCATOR: {_unmet:+d} W of the site target could not be " - f"placed on any inverter - {self.allocation.explain()}", - hint=messages.hint("unmet", w=_unmet)) - else: - self.log_event("ALLOCATOR: the whole site target fits again", - hint=messages.hint("unmet_gone")) - self._unmet_last = _unmet - if resolution.scope_errors: - # A claim reached the arbiter on an actuator its source's scope - # does not permit: a BUG in the filing site, not a state to survive - # (a per-inverter constraint in the site list is the `lo > hi` this - # card removes). Loud, every cycle; the resolution is unchanged. - # (DECISIONS/MAIN-orchestration-archive.md) - _LOG.error("A limit was filed on the wrong inverter, which is a " - "software fault. %s.", " / ".join(resolution.scope_errors)) - if resolution.contradiction: - self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log", - hint=messages.hint("clash")) - # SAFETY-02's arming gate: was the number chosen by the grid-following - # loop, or by a strategy that commands against the meter on purpose? - # Only the former is expected to agree in sign with P1. ⚠️ THE - # EXPRESSION LIVES IN watchdog.py, NOT HERE: read loop_is_following() - # before touching the gate. (DECISIONS/MAIN-orchestration-archive.md) - self.following = loop_is_following(resolution, self.loop_w) - self.target = resolution.target_w - self.reason = resolution.explain() - # UX-08: the same answer in plain words, for the page's "Why" line; - # `reason` stays the technical record the log and the checks read. - # UX-10: every key `why_key()` can return has words in - # messages.WHY_WINNER. (DECISIONS/MAIN-orchestration-archive.md) - _why = why_key(resolution.winner, claims) - if _why == "inputs missing" and not resolution.contradiction: - # UX-11: past the hold, say which reading is missing. - self.why_words = messages.why_missing(self.missing_reading(), - stale_after) - else: - self.why_words = messages.why( - _why, resolution.target_w, resolution.bound_by, - contradiction=resolution.contradiction, - raised=why_raised(resolution, claims)) - # DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE, NEVER THE SITE TARGET - # (handing each unit `self.target` is the ARCH-01 fault: each supplies - # it in full and the gain at the meter becomes N times the per-unit - # gain). At N=1 this is byte-identical. ⚠️ A unit not in `shares` is - # commanded 0 W, not left standing: an inverter holds its last command - # for ever. (DECISIONS/MAIN-orchestration-archive.md) - self._loop_alarm_input(resolution) - writes = [] - for inv in self.inverters: - inv.target = float(resolution.shares.get(inv.name, 0.0)) - writes.append(self.write_setpoint(inv.target, unit=inv)) - await asyncio.gather(*writes) - - # SAFETY-03, and its boundary is the WRITE. ⚠️ NOT THE SAME BOUNDARY AS - # SAFETY-18's BELOW, THOUGH THE CALLS ABUT: this one reads - # `self.clamp_last`, None until write_setpoint() has run, so it must - # follow the write (test_clamp.py part 6). Two guards, two orderings: - # do not "tidy" them into one rule. - # (DECISIONS/MAIN-orchestration-archive.md) - await self.check_output_clamp() - - # SAFETY-18, and its boundary is the ASSIGNMENT. ⚠️ THE BOUNDARY IS - # `self.target = resolution.target_w` ABOVE, NOT write_setpoint(): both - # terms of the conjunction must describe the SAME moment, so moving it - # above the assignment grades the previous cycle's command against this - # cycle's meter (test_freeze.py part 5). - # (DECISIONS/MAIN-orchestration-archive.md) - await self.check_meter_frozen() - - # --- PRICE-01: no price for now, said once and taken down once -------- - # Not a safety check, so it is one of the cheap ones: an attribute test - # and, on a change only, one Home Assistant call. - await self.check_price_gap() - await self.check_fixed_note() - - # SAFETY-31's alarm, and its boundary is the LATCH, not a number: it - # grades `self.circ_wd.tripped`, advanced near the top of this method. - # Called on EVERY cycle, not only the one that trips, because its - # second job is re-raising the alarm after a restart. - # (DECISIONS/MAIN-orchestration-archive.md) - await self.check_circulating() - - # SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's: - # it grades `inv.delivery_wd.tripped`, and is called on EVERY cycle - # because its second job is re-raising the alarm after a restart. - # (DECISIONS/MAIN-orchestration-archive.md) - 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. - await self.check_peak_saturated() - - # SAFETY-24's alarm, and it is the OTHER way the money gets spent. Same - # boundary argument as check_peak_saturated(); kept as its own method - # because the two states are mutually exclusive and want OPPOSITE - # operator actions (saturated: shed load; this: the pack is at its - # floor and the quarter is not being shaved). - # (DECISIONS/MAIN-orchestration-archive.md) - await self.check_peak_floor_limited() - - # TARIFF-01, and it is LAST on purpose: banking a quarter is - # bookkeeping about a moment that has passed, and a slow SD card must - # never sit between the meter and the setpoint. It grades neither - # `target` nor `standing`. (DECISIONS/MAIN-orchestration-archive.md) - self.peak.sync(self.p1) - - # SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS: every - # guard above has read `self.target`, the resolved intent (SAFETY-18 - # grades exactly that); re-seeding before them would hand each a 0 W no - # decision produced. See `_shadow_settle()`. - # (DECISIONS/MAIN-orchestration-archive.md) - self._shadow_settle(resolution) - - def _shadow_settle(self, resolution) -> None: - """SAFETY-10. Record the decision, then re-seed the carried command - (`self.target`, each `inv.target`) to `shadow.WIRE_W`, in this order - because the second destroys the first's evidence. ⚠️ THE RE-SEED IS A - SAFETY PROPERTY: compute's slew base, the sign watchdogs - (`standing_w()`) and SAFETY-42 read the carried command as a statement - about the HARDWARE; a shadow decision there would latch a trip that - survives into live operation. One log line per CHANGE of decision. - (DECISIONS/MAIN-orchestration-archive.md)""" - engaged = self.shadow - if engaged != self._shadow_was: - # ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT (a - # "shadow mode off" line at every start pushed a clamp episode out - # of the 40-row log); coming up IN shadow mode always gets a line. - # (DECISIONS/MAIN-orchestration-archive.md) - first = self._shadow_was is None - self._shadow_was = engaged - if engaged: - self.log_event( - "SHADOW MODE ON - " + shadow.why( - self.o, control_started=self.auto) - + f"; inverter held at {shadow.WIRE_W:.0f} W", - hint=messages.hint("shadow_on")) - # UX-10: INFO. It follows a setting or a button press, and - # no person has to act on it. - _LOG.info( - "Shadow mode is on, so every inverter stays at %.0f W and " - "nothing the add-on decides is sent. %s.", shadow.WIRE_W, - shadow.why(self.o, control_started=self.auto)) - elif not first: - self.log_event( - "Shadow mode off: the add-on controls the battery again, " - f"starting from {shadow.WIRE_W:.0f} W", - hint=messages.hint("shadow_off")) - _LOG.info( - "Shadow mode is off, so the add-on controls the battery " - "again, starting from %.0f W.", shadow.WIRE_W) - if not engaged: - self.shadow_would_w = None - self.shadow_sized_from_meter = False - self._shadow_winner = None - return - self.shadow_would_w = float(resolution.target_w) - # SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY: all three rungs live in - # `arbitrage.sized_from_meter()`; a rung written at a call site is one - # no check can reach with the other two clear (TEST-16). - # (DECISIONS/MAIN-orchestration-archive.md) - self.shadow_sized_from_meter = arbitrage.sized_from_meter( - self.arb, self.arb_headroom_w, winner=resolution.winner) - # ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE (a loop - # target that that wanders by a watt would be the whole log). ⚠️ - # SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE: the same winner at the - # same watts with and without the caveat are two different claims. - # (DECISIONS/MAIN-orchestration-archive.md) - edge = (resolution.winner, round(float(resolution.target_w)), - self.shadow_sized_from_meter) - if edge != self._shadow_winner: - self._shadow_winner = edge - self.log_event(shadow.log_line( - would_w=resolution.target_w, reason=resolution.explain(), - sized_from_meter=self.shadow_sized_from_meter), - hint=messages.hint("shadow_decision", - sw=resolution.target_w)) - self.target = shadow.WIRE_W - for inv in self.inverters: - inv.target = shadow.WIRE_W - - 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): - """The stored plan if it is still current, else None. NEVER a stale - one. THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for - `loop_is_following()`'s reason: a check has to be able to assert on the - shipped expression rather than on a transcription that drifts. - (DECISIONS/MAIN-orchestration-archive.md)""" - if self.tariff_plan is None or self.tariff_at is None: - return None - if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S: - return None - return self.tariff_plan - - async def run_tariff_refresh(self) -> None: - """Resolve the tariff off the control path, forever. NEVER on the tick. - ⚠️ IT CATCHES EVERYTHING AND KEEPS GOING: raising out of here would - freeze the plan, looking current for ever (`fresh_tariff_plan()`'s age - bound is the backstop). ⚠️ A FAILURE DOES NOT CLEAR THE PLAN - (TARIFF-12): a degraded plan replaces a standing observation only once - it has aged out; a §11.4 fallback never replaces a fresh provider plan. - ⚠️ `tariff_at` DOES NOT MOVE ON A KEPT PLAN. - (DECISIONS/MAIN-orchestration-archive.md)""" - horizon = float(price_option(self.o, "price_horizon_h")) - while not self.stopping: - now = datetime.now(timezone.utc) - try: - plan = await resolve_tariff( - self.o, now, now + timedelta(hours=horizon), now=now, - history=self.price_history, watch=self.price_watch) - standing = self.fresh_tariff_plan(now) - # Three terms, and each of them is the only false one in some - # check: the feed answered (replace), nothing current stands - # (take the degradation - this is what makes the age bound - # govern), or what stands is itself a degradation (replace). - keep = (not plan.from_feed - and standing is not None and standing.from_feed) - if keep: - _LOG.warning( - "The price update was incomplete, so the plan from %s " - "is kept until it is %s old. The update held %s.", - messages.when(brussels(self.tariff_at)), - messages.duration(TARIFF_MAX_AGE_S), plan.words) - else: - self.tariff_plan, self.tariff_at = plan, now - _LOG.info("tariff refreshed: %s", plan.words) - except Exception as err: # noqa: BLE001 - # ⚠️ NOT THE OUTAGE PATH - see the docstring: `load_prices()` - # returns on every failure a site can have, so what reaches - # here is unexpected, and the plan is kept on the same terms. - # (DECISIONS/MAIN-orchestration-archive.md) - _LOG.warning( - "The price update failed (%s), so the plan from %s is kept " - "until it is %s old, and trading stops then.", - err, messages.when(brussels(self.tariff_at)), - messages.duration(TARIFF_MAX_AGE_S)) - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(self.stop_event.wait(), - timeout=TARIFF_REFRESH_S) - - async def run_control(self) -> None: - # ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4 (one - # read of one `setpoint_entity` left a second inverter's clamp, freeze - # rail and `achievable_w` on the first one's numbers). Concurrently: a - # slow ESPHome on one unit must not delay the start on the others. - # (DECISIONS/MAIN-orchestration-archive.md) - async def _limits(inv): - if inv.driver.configured(): - inv.dev_min, inv.dev_max = await self._driver(inv).limits() - await asyncio.gather(*(_limits(inv) for inv in self.inverters)) - entity = self.o.get("setpoint_entity", "") - # ⚠️ THIS USED TO WARN AND CARRY ON, the bug CFG-01 exists to remove: a - # max_w above the device's rating was silently clamped. amain() ran - # this gate already; it runs AGAIN because ESPHome is often not up at - # boot (limits None) and this is the first moment the real rating is - # known; a conflict refuses through the normal shutdown (0 W). ⚠️ THE - # `if entity:` THAT WRAPPED THIS BLOCK FAILED PERMISSIVE (CFG-04's - # hazard): a per-inverter config leaves the flat `setpoint_entity` - # blank, so the re-check was skipped; the per-unit rule now runs - # unconditionally. (DECISIONS/MAIN-orchestration-archive.md) - problems = [] - if entity: - _first = self.inverters[0] - problems += validate_startup_config(self.o, _first.dev_min, - _first.dev_max) - problems += validate_unit_ranges(self.inverters, skip_entity=entity) - if problems: - refuse(problems) - self.refused = problems - self.log_event("REFUSED: " + problems[0], - hint=messages.hint("refused")) - # Signal and return WITHOUT entering the loop: amain() runs the - # wind-down (0 W, or a hand-back: SMA-10) and exits with code 78. - self.stop_event.set() - return - - last_grid = object() - heartbeat = float(self.o.get("heartbeat_s", 10)) - # ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not - # a second scheduler: every cycle resets it, so on a healthy meter it - # never elapses and the per-cycle gain is unchanged. It exists because - # a meter that repeats a value would run NO cycles, and the `inputs - # missing -> 0 W` claim lives inside cycle(). NOT a freeze detector. ⚠️ - # THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN: it sits under - # validate.FIRMWARE_INGEST_VETO_S (30 s) and `meter_max_age_s` and - # moves with the firmware's veto; the schema minimum of 5 s is the - # reference meter's interval and deserves a second look. - # (DECISIONS/MAIN-orchestration-archive.md) - floor = float(self.o.get("cycle_floor_s", 15)) - last_cycle = None - while not self.stopping: - await self.read_inputs() - now_mono = asyncio.get_running_loop().time() - # A cycle is one meter update, exactly as on the reference install - - # or the floor elapsing, whichever comes first. - overdue = last_cycle is None or (now_mono - last_cycle) >= floor - if self.grid != last_grid or overdue: - last_grid = self.grid - last_cycle = now_mono - # ⚠️ The FULL cycle, not a degraded one: in P1 mode past - # `meter_max_age_s` (30 s) p1.net_w is None ("inputs missing"). - # ⚠️ TRANSPORT-CONDITIONAL: with `meter_source: off`, - # read_inputs() has NO age gate, so a quiet entity reads as - # current; the floor does not make it fresh. The integrator - # advances per CYCLE, so bounding the gap bounds the - # accumulation. (DECISIONS/MAIN-orchestration-archive.md) - await self.cycle() - # ⚠️ The heartbeat is not an optimisation: the ESP32 zeroes the - # inverter after ~30 s of silence, and refreshing the SAME value - # proves we are alive. ⚠️ ONE DEADLINE PER INVERTER, refreshed - # concurrently, each bounded by WRITE_BUDGET_S (budget plus the - # largest `heartbeat_s` stays under layer 1's veto: graded); each - # unit refreshes its own `inv.target`, never the site target. - # (DECISIONS/MAIN-orchestration-archive.md) - await self.send_heartbeats(asyncio.get_running_loop().time(), - heartbeat) - self.publish() - self.loop_pass_mono = time.monotonic() - await asyncio.sleep(1) - - async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None: - """Refresh every inverter whose own deadline has come. AC 6 lives here. - ⚠️ A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` so a check - can inject a hang: "a hung write to A cannot consume B's layer-1 - budget"; inline, the mutation to a serial `for` loop passed the whole - suite. (DECISIONS/MAIN-orchestration-archive.md)""" - due = [inv for inv in self.inverters - if loop_now - inv.last_beat >= heartbeat_s] - if not due: - return - # ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT: after, a - # hanging write drifts into being re-sent every tick and a raising one - # never advances. (DECISIONS/MAIN-orchestration-archive.md) - for inv in due: - inv.last_beat = loop_now - # ⚠️ CONCURRENTLY: in series, every inverter behind a hung one waits - # out its whole budget, pushing a healthy sibling towards layer 1's ~30 - # s veto. (DECISIONS/MAIN-orchestration-archive.md) - await asyncio.gather(*( - self.write_setpoint(inv.target, force=True, unit=inv) - for inv in due)) - - def publish(self) -> None: - # ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE: `battery` stays the SUM and - # `soc` the EMPTIEST pack (their `unique_id`s are on live dashboards); - # per-inverter entities are TEL-07's. - # (DECISIONS/MAIN-orchestration-archive.md) - values = { - "setpoint": self.target, - "grid": self.grid, - "battery": self.site_batt_w, - "soc": self.pack_floor_soc, - "phase": self.maint.phase, - # ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing - # one: "failsafe" is additive to a sensor already on every - # dashboard, and a P_SAFETY stop that did not reach it would be a - # site at 0 W reporting "running". - # (DECISIONS/MAIN-orchestration-archive.md) - "status": ("failsafe" if any(i.sign_wd.tripped - for i in self.inverters) - or (self.site_sign_wd.tripped - and len(self.inverters) > 1) - or self.circ_wd.tripped - # SAFETY-10: "shadow" is its own value and not a flavour - # of "stopped". ⚠️ BELOW `failsafe`: a latch that is up - # is up, and shadow does not clear it. - # (DECISIONS/MAIN-orchestration-archive.md) - else "shadow" if self.shadow - else "running" if self.auto else "stopped"), - # SAFETY-31. ⚠️ MEASURED, not the allocation record - see - # `site_circulating_w`. `publish()` drops None values, so a missing - # per-unit reading leaves the last figure standing rather than - # publishing a zero that would read as health. - # (DECISIONS/MAIN-orchestration-archive.md) - "circulation": (None if self.site_circulating_w is None - else round(self.site_circulating_w)), - # TEL-10. The loop's own three, written from the fields `cycle()` - # recorded and not re-derived (the page and the publisher run on - # their own tasks, and `self.target` has moved on). - # (DECISIONS/MAIN-orchestration-archive.md) - "loop_i": round(self.loop_i_w, 1), - "loop_lead": round(self.loop_i_lead, 1), - "loop_reason": self.loop_reason, - } - # TEL-11. ⚠️ THE KEY IS PRESENT WITH `None` WHEN THERE IS NO READING - # AND ABSENT WHEN THERE IS NO SOURCE: two states - # (`mqtt.ABSENT_AS_UNKNOWN` makes the first an `unknown`, a gap in the - # recorder). TEL-12: still ONE number, the SITE total, None unless - # EVERY entity answered. ⚠️ THE GATE IS `optional_keys()`, THE SAME - # EXPRESSION THE ANNOUNCEMENT USES. - # (DECISIONS/MAIN-orchestration-archive.md) - if "pv" in optional_keys(self.o): - values["pv"] = (None if self.pv_w is None - else round(self.pv_w, 1)) - # ⚠️ ONLY when P1 ingestion is running: the ESP32's stale-input - # watchdog subscribes to sensor.goodwe_p1_sample_age and forces layer-1 - # failsafe at max_age_s, so with meter_source off a published age would - # climb unbounded and pin every existing install at 0 W within 30 s. - # Publishing nothing leaves the entity non-existent (firmware - # has_state()). (DECISIONS/MAIN-orchestration-archive.md) - if self.p1_enabled: - # Recomputed here, once a second, on purpose - see P1Ingest. - values["p1_age"] = round(self.p1.published_age_s, 1) - # TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE: ⚠️ no `if - # len(self.inverters) > 1` here, deliberately: `announced_units()` - # answers [] at N=1, so what is announced and what is published come - # from one expression. ⚠️ The site rows do not move: `battery` stays - # the SUM and `soc` the EMPTIEST pack (live dashboards use those ids). - # (DECISIONS/MAIN-orchestration-archive.md) - names = announced_units([inv.name for inv in self.inverters]) - for inv, slug in zip(self.inverters, unit_slugs(names)): - values[unit_state_key(slug, "setpoint")] = inv.target - values[unit_state_key(slug, "battery")] = inv.batt - values[unit_state_key(slug, "soc")] = inv.soc - values[unit_state_key(slug, "sign_latch")] = ( - "tripped" if inv.sign_wd.tripped else "clear") - # OPT-11 P2: each battery's mode and switch; nothing while modes are off. - values.update(self.mode_values()) - self.mqtt.publish(values) - async def shutdown(self) -> None: """Deterministic wind-down. Do not skip this.""" self.stopping = True @@ -5077,824 +1017,12 @@ class Controller: await self.command_site_release() self.mqtt.close() - async def command_site_release(self) -> None: - """SMA-10. The add-on is going away (Stop, shutdown, restart, task death, - config refusal): units that hand back are given back, the rest held at - 0 W as `command_site_zero()` does. ⚠️ Safety stops and refusals do NOT - come here: they hold 0 W for EVERY unit. A roster with no hand-back unit - is `command_site_zero()` line for line. - DECISIONS/SMA-10-two-idle-states.md.""" - if not any(inv.driver.hands_back for inv in self.inverters): - await self.command_site_zero(last=True) - return - self.target = 0.0 - for inv in self.inverters: - inv.target = 0.0 - await asyncio.gather(*( - self._hand_back(inv, force=True) if inv.driver.hands_back - else self._write_unit(inv, 0.0, force=True, last=True) - for inv in self.inverters)) - - async def command_site_zero(self, *, last: bool = False) -> None: - """Every inverter to 0 W, and every inverter's TARGET with it. ⚠️ NOT - BOOKKEEPING (DEV-02): the heartbeat re-emits `inv.target`, so zeroing - only `self.target` lets the next heartbeat put each unit's last share - back on the wire. ⚠️ 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE - (`write_setpoint()` refuses others: ARCH-01). - (DECISIONS/MAIN-orchestration-archive.md)""" - self.target = 0.0 - for inv in self.inverters: - inv.target = 0.0 - await self.write_setpoint(0.0, force=True, last=last) - - # -- UI ------------------------------------------------------------------ - def checks(self, when: datetime | None = None) -> list: - """The status rows. `when` is the reference time for anything billed by - the calendar - today's month when it is None, which is what the live - page means. ⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17): a - suite that banks an August quarter went red at the September boundary. - (DECISIONS/MAIN-orchestration-archive.md)""" - o = self.o - out = [] - # SAFETY-61. First, so a stalled loop is the headline: the heartbeat is - # sent from this loop, so past layer 1's veto the inverter is at 0 W. - if self.loop_pass_mono is None: - out.append({"ok": True, "warn": False, "id": "controller", - "text": messages.row_controller_starting()}) - else: - _pass_age = time.monotonic() - self.loop_pass_mono - if _pass_age > FIRMWARE_INGEST_VETO_S: - out.append({"ok": False, "warn": False, "id": "controller", - "text": messages.row_controller_stalled( - _pass_age, FIRMWARE_INGEST_VETO_S)}) - else: - out.append({"ok": True, "warn": False, "id": "controller", - "text": messages.row_controller_ran(_pass_age)}) - # SAFETY-112. The headline unless control has stalled: while the - # Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or - # write gets through, and the rows below that need it follow from it. - if OUTAGE.waiting(): - out.insert(1 if out[0]["ok"] is False else 0, - {"ok": False, "warn": False, "id": "home_assistant", - "banner": messages.banner_ha_waiting(), - "text": messages.row_ha_waiting()}) - if self.p1_enabled: - age = self.p1.published_age_s - if (self.p1.stale and self.p1.age_s() is None - and age <= self.p1.max_age_s): - # UX-10: no reading yet since the start, still inside the - # limit. Amber, not red: it is not a fault yet, but control - # waits for it. Red at 0 s contradicted the limit the row - # prints. (DECISIONS/MAIN-orchestration-archive.md) - out.append({"ok": False, "warn": True, "id": "meter", - "text": messages.row_meter_waiting( - self.p1.max_age_s)}) - elif self.p1.stale: - # The last error is on the add-on log's WARNING line (p1.py). - out.append({"ok": False, "warn": False, "id": "meter", - "text": messages.row_meter_stale( - age, self.p1.max_age_s)}) - else: - # ⚠️ unchanged_s is REPORTED, never thresholded and never - # folded into the age - see HomeWizardLocalSource.unchanged_s - # for why (at the converged -10 W this controller aims for, a 1 - # Wh register needs ~6 minutes to move, so any limit - # false-trips at the exact operating point we target). - # (DECISIONS/MAIN-orchestration-archive.md) - unchanged = getattr(self.p1_source, "unchanged_s", None) - out.append({"ok": True, "warn": False, "id": "meter", - "text": messages.row_meter( - self.p1.net_w, age, self.p1.parse_errors, - unchanged)}) - # SAFETY-18. Reported in both states (a detector nobody can see is - # armed is not trusted when it fires). ok=False/warn=False so the - # banner goes red; it changes NOTHING about control (see - # check_meter_frozen()). (DECISIONS/MAIN-orchestration-archive.md) - if self.freeze_wd.active: - # The figures are in `freeze_wd.reason`, on the log and the - # event list (check_meter_frozen()). - out.append({"ok": False, "warn": False, "id": "meter_frozen", - "text": messages.row_meter_frozen()}) - elif getattr(self.p1_source, "unchanged_s", None) is None: - # Not "armed". Every transport but homewizard_local is invisible - # to this check, and saying "armed" there would be a lie the - # status page tells on behalf of a guard that cannot run. - out.append({"ok": False, "warn": True, "id": "freeze_check", - "text": messages.row_freeze_off("source")}) - elif rail_unreachable(self.site_max_w, - dev_min=self.site_dev_bound("dev_min"), - dev_max=self.site_dev_bound("dev_max")): - # SAFETY-20: the same row as the transport case above (the - # check cannot be evaluated here); only the missing term - # differs. (DECISIONS/MAIN-orchestration-archive.md) - _no_rating = (not self.site_max_w - or not is_commandable(self.site_max_w)) - out.append({"ok": False, "warn": True, "id": "freeze_check", - "text": messages.row_freeze_off( - "rating" if _no_rating else "range")}) - else: - # ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE - # USES, device range included. - # (DECISIONS/MAIN-orchestration-archive.md) - _rail = rail_w(self.site_max_w, - dev_min=self.site_dev_bound("dev_min"), - dev_max=self.site_dev_bound("dev_max")) - # And when the device set that number, SAY SO: the operator - # configured `max_w`. ⚠️ Either bound may be None on its own - # (`hass.limits()` reads min and max independently), so render - # what is there. (DECISIONS/MAIN-orchestration-archive.md) - out.append({"ok": True, "warn": False, "id": "freeze_check", - "text": messages.row_freeze_on( - self.freeze_wd.cfg.hold_s, _rail, - own_limit=_rail < rail_w(self.site_max_w))}) - - # TARIFF-01. The month's billed peak, and a DIFFERENT row when the - # store could not answer: "no peak yet" is normal on the 1st, - # "unreadable" is a broken instrument. - # (DECISIONS/MAIN-orchestration-archive.md) - _pk = self.peak.peak(when) - if _pk.is_unreadable: - out.append({"ok": False, "warn": True, "id": "month_peak", - "text": messages.row_month_peak_unreadable()}) - else: - # §9.1's "warning entity at 80 %", as a status row rather than - # a second entity - see the constructor on not doubling up. - _frac = self.peak.warning_frac(when) or 0.0 - _ok = _frac < 0.8 - out.append({"ok": _ok, "warn": not _ok, "id": "month_peak", - "text": (messages.row_month_peak_none() - if not _pk.is_observed else - messages.row_month_peak( - _pk.peak_w, - brussels(_pk.quarter_start), - _frac))}) - out.append({"ok": True, "warn": False, "id": "periods_measured", - "text": messages.row_periods_measured( - self.peak.recorded)}) - - # SAFETY-07. Three rows, not one, because "idle" and "cannot see" - # are opposite states and a page that spelled them the same would - # show a green line over a guard that is switched off. `None` is a - # fourth state on purpose: before the first cycle nothing has been - # concluded. (DECISIONS/MAIN-orchestration-archive.md) - _pg = self.peak_guard - if _pg is None: - out.append({"ok": True, "warn": True, "id": "peak_guard", - "text": messages.row_peak_guard_waiting()}) - elif _pg.saturated and not _pg.within_aim: - out.append({"ok": False, "warn": False, "id": "peak_guard", - "runs": True, - "text": messages.row_peak_saturated(_pg.ceiling_w)}) - elif _pg.floor_limited and ( - not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim) - or self._peak_floor_limited): - # SAFETY-24. NOT ok, like saturation (the money is being spent - # and a human is the only actuator left); a separate row - # because the remedy is opposite. ⚠️ SAFETY-25: the scope - # sentence (the floor binds the guard, not the loop) is part of - # the row, which carries its own short `banner`; shortening the - # ROW would weaken AC 3. - # (DECISIONS/MAIN-orchestration-archive.md) - out.append({"ok": False, "warn": False, "id": "peak_guard", - "runs": True, - "banner": messages.banner_peak_reserve(), - "text": messages.row_peak_reserve( - _pg.ceiling_w, soc_unread=_pg.soc is None, - floor_unknown=(_pg.soc is not None - and _pg.soc_floor is None), - packs=(_pg.held if len(self.inverters) > 1 - else ()), - others=(_pg.floor_w or 0.0) > 0.0)}) - elif _pg.blind: - out.append({"ok": False, "warn": True, "id": "peak_guard", - "text": messages.row_peak_guard_blind( - peakguard.blind_kind(_pg.blind))}) - elif self.peak_unmeasured_s > 0.0: - # SAFETY-90: the start of this quarter came before the first - # reading, and the guard counts it as high use. - out.append({"ok": False, "warn": True, "id": "peak_guard", - "text": messages.row_peak_guard_unmeasured()}) - elif _pg.engaged: - out.append({"ok": False, "warn": True, "id": "peak_guard", - "text": messages.row_peak_guard_on( - _pg.floor_w or 0.0, _pg.ceiling_w, - shadow=self.shadow)}) - elif _pg.projected_w is not None: - # SAFETY-125: "under" only when it is; over the limit at this - # rate, a grid charge is cut back to the quarter's room. - out.append({"ok": True, "warn": False, "id": "peak_guard", - "text": messages.row_peak_guard_idle( - _pg.projected_w, _pg.ceiling_w, - month_peak=(_pg.ceiling_from - == peakguard.CEILING_MONTH_PEAK), - cut=self.room_charge and not self.shadow)}) - else: - out.append({"ok": True, "warn": False, "id": "peak_guard", - "text": _pg.explain()}) - else: - # ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the - # point: SAFETY-07 is NOT COVERED on the legacy single-entity path - # (no telegrams), and a row that rendered only where the guard runs - # would leave the unprotected install showing nothing. - # (DECISIONS/MAIN-orchestration-archive.md) - out.append({"ok": False, "warn": True, "id": "peak_guard", - "text": messages.row_peak_guard_off()}) - - # ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row - # on a site with two packs is a page that shows one pack and calls it - # the site. (DECISIONS/MAIN-orchestration-archive.md) - rows = [] - for inv in self.inverters: - unit = None if len(self.inverters) == 1 else inv.name - rows.append((messages.LABEL_SOC, unit, inv.soc, - inv.driver.soc_source())) - rows.append((messages.LABEL_BATTERY, unit, inv.batt, - inv.driver.batt_source())) - if not self.p1_enabled: - # In P1 mode the check above replaces this one; leaving both in - # would report "no entity configured" for a meter_entity that is - # correctly unused, i.e. a permanent false NOT READY. - rows.insert(0, (messages.LABEL_GRID, None, self.grid, - o.get("meter_entity"))) - # UX-08: the rows name the setting, never the entity id (STYLE.md). - for label, unit, value, entity in rows: - if not entity: - out.append({"ok": False, "warn": False, "id": "sensor", - "text": messages.row_sensor_unset(label, unit)}) - elif value is None: - # ⚠️ SAFETY-56. THIS ROW ONCE ASSERTED A FALSE FACT ABOUT A - # NAMED ENTITY: `Freshness.reading()` returns None BY DESIGN - # while the gate withholds a numeric reading. ⚠️ FIXED BY - # SAYING LESS: the gate has its own surface (`stale_note()` in - # `liveness_notes()`). - # (DECISIONS/MAIN-orchestration-archive.md) - out.append({"ok": False, "warn": False, "id": "sensor", - "text": messages.row_sensor_unusable(label, unit)}) - else: - out.append({"ok": True, "warn": False, "id": "sensor", - "text": messages.row_sensor(label, value, unit)}) - - for inv in self.inverters: - unit = None if len(self.inverters) == 1 else inv.name - if not inv.driver.configured(): - out.append({"ok": False, "warn": False, "id": "control", - "text": messages.row_control_unset(unit)}) - elif inv.dev_max is None: - out.append({"ok": False, "warn": False, "id": "control", - "text": messages.row_control_missing(unit)}) - else: - out.append({"ok": True, "warn": False, "id": "control", - "text": messages.row_control(inv.dev_min, - inv.dev_max, unit)}) - - # DEV-02. Whether this controller can still put a number on each - # inverter: a dropped link that shows nothing on the page reads as a - # healthy site. Red only for a unit no longer live; the rest amber. The - # same notes, in the same order, as `liveness_notes()`. - # (DECISIONS/MAIN-orchestration-archive.md) - _now = datetime.now(timezone.utc) - for inv in self.inverters: - for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]), - (inv.stale_note(), False), - (inv.refusal_note(), False)): - if note: - out.append({"ok": False, "warn": not red, "text": note}) - if self.answer.late_seen: # SAFETY-128 - out.append({"ok": False, "warn": True, "id": "late", - "text": messages.row_readings_late( - self.answer.shown_s, self._late_zero)}) - - # SAFETY-03. Red while an over-rating clamp is engaged (amber for the - # integrator bound, SAFETY-40), and the engagement COUNT stays shown - # after it clears. Each count is shown under ITS alarm's name, never - # the two summed (UX-07/UX-08). - # (DECISIONS/MAIN-orchestration-archive.md) - _cw = self.clamp_wd - if _cw.active and _cw.bug: - out.append({"ok": False, "warn": False, "id": "power_too_high", - "banner": messages.banner_power_too_high(), - "text": messages.row_power_too_high( - _cw.facts, _cw.over_rating, shadow=self.shadow)}) - elif _cw.active: - # SAFETY-40: the integrator bound alone is amber, not red. - out.append({"ok": False, "warn": True, "id": "not_keeping_up", - "text": messages.row_not_keeping_up( - _cw.bound_episodes)}) - # No over-rating in THIS episode (`bug` would be set), so any count - # is from an earlier one: keep that software-fault evidence shown. - if _cw.over_rating: - out.append({"ok": False, "warn": True, "id": "power_too_high", - "text": messages.row_before( - messages.T_OUTPUT_CLAMP, _cw.over_rating)}) - else: - if _cw.over_rating: - out.append({"ok": False, "warn": True, "id": "power_too_high", - "text": messages.row_before( - messages.T_OUTPUT_CLAMP, _cw.over_rating)}) - if _cw.bound_episodes: - out.append({"ok": False, "warn": True, "id": "not_keeping_up", - "text": messages.row_before( - messages.T_LOOP_BOUND, _cw.bound_episodes)}) - - for inv in self.inverters: - if inv.last_write_ok is False: - out.append({"ok": False, "warn": False, "id": "write", - "text": messages.row_write_refused( - None if len(self.inverters) == 1 - else inv.name)}) - - # SAFETY-02. Reported in both states. ⚠️ ONE ROW PER INVERTER: a trip - # on B leaves A commanding. Since SAFETY-30 the row says what the unit - # was compared against (`sign_wd.against`, the string the trip sentence - # uses); at N=1 the wording is unchanged. - # (DECISIONS/MAIN-orchestration-archive.md) - for inv in self.inverters: - unit = None if len(self.inverters) == 1 else inv.name - # ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE: at N=1 the - # two sentences below are the ones that have always been on the - # page, word for word (AC 8); at N>=2 the noun is load-bearing, so - # the row does not send an operator to P1 for a fault visible only - # on one inverter's own power. - # (DECISIONS/MAIN-orchestration-archive.md) - if inv.sign_wd.tripped: - out.append({"ok": False, "warn": False, "id": "direction", - "text": messages.row_direction_trip(unit)}) - else: - held = inv.sign_wd.held_s(datetime.now(timezone.utc)) - out.append({"ok": True, "warn": False, "id": "direction", - "text": messages.row_direction_check( - held, inv.sign_wd.cfg.hold_s, unit)}) - - # SAFETY-30: the SITE row. ⚠️ ONLY AT N>=2, and the absence at N=1 is - # the honest report: with one inverter the site comparison and the row - # above are the same sentence about the same actuator. - # (DECISIONS/MAIN-orchestration-archive.md) - if len(self.inverters) > 1: - if self.site_sign_wd.tripped: - out.append({"ok": False, "warn": False, "id": "site_direction", - "text": messages.row_site_direction_trip()}) - else: - _sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc)) - out.append({"ok": True, "warn": False, "id": "site_direction", - "text": messages.row_site_direction_check( - _sheld, self.site_sign_wd.cfg.hold_s)}) - - # SAFETY-31. ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and - # the row says so: there is one circulating figure for the site, and - # naming an inverter would invite clearing "the other one". - # (DECISIONS/MAIN-orchestration-archive.md) - _circ = self.site_circulating_w - if self.circ_wd.tripped: - out.append({"ok": False, "warn": False, "id": "circulating", - "text": messages.row_circulating_trip()}) - elif len(self.inverters) <= 1: - # AC 5. At the shipped roster the quantity is identically zero for - # every value of every reading, so "armed" would be a claim to be - # watching something that cannot happen. Say what is true instead. - out.append({"ok": True, "warn": False, "id": "circulating", - "text": messages.row_one_inverter( - messages.CHECK_CIRCULATING)}) - else: - held = self.circ_wd.held_s(datetime.now(timezone.utc)) - out.append({"ok": True, "warn": False, "id": "circulating", - "text": messages.row_circulating_check( - _circ, held, self.circ_wd.cfg.hold_s)}) - - # SAFETY-42. ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE - # ONE PLACE THE "reports, does not act" DECISION IS VISIBLE: every - # other latched row means the site is at 0 W, this one means it is - # STILL RUNNING and quietly short by one unit's share; `ok: False` with - # `warn: True` is this list's caution. - # (DECISIONS/MAIN-orchestration-archive.md) - if len(self.inverters) > 1: - for inv in self.inverters: - if inv.delivery_wd.tripped: - out.append({"ok": False, "warn": True, "id": "delivery", - "text": messages.row_not_delivering(inv.name)}) - else: - _dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc)) - out.append({"ok": True, "warn": False, "id": "delivery", - "text": messages.row_delivery_check( - inv.name, _dheld, - inv.delivery_wd.cfg.hold_s)}) - else: - # AC 5: at one inverter this check has nothing to find, so it is - # not called "armed" (as `circulating power: n/a` above). - # (DECISIONS/MAIN-orchestration-archive.md) - out.append({"ok": True, "warn": False, "id": "delivery", - "text": messages.row_one_inverter("Delivery check")}) - - if not o.get("estop_fitted", False): - out.append({"ok": False, "warn": True, "id": "estop", - "text": messages.row_no_estop()}) - else: - out.append({"ok": True, "warn": False, "id": "estop", - "text": messages.row_estop_fitted()}) - - if not o.get("peak_forecast_entity"): - # ok=False + warn=True renders as a caution, not a pass. A check that - # is both is a check nobody reads. - out.append({"ok": False, "warn": True, "id": "peak_forecast", - "text": messages.row_no_peak_forecast()}) - elif getattr(self, "_peak_fc_fault", None) is not None: - out.append({"ok": False, "warn": True, "id": "peak_forecast", - "text": messages.row_peak_forecast_unit( - self._peak_fc_fault)}) - # OPT-17: a forecast summed from several entities names, by position, - # each member with no usable reading. A caution: control goes on. - gaps = getattr(self, "solar_fc_gaps", ()) - for day, ents in (("today", getattr(self, "fc_entities", ())), - ("tomorrow", - getattr(self, "fc_tomorrow_entities", ()))): - gone = [i for d, i, _e, _w in gaps if d == day] - if gone: - out.append({"ok": False, "warn": True, "id": "solar_forecast", - "text": messages.row_forecast_gap( - day, gone, len(ents))}) - # TEL-11. ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY (pure - # instrumentation: no warning spam). ⚠️ WHEN IT IS SET, A FAILED READ - # IS `ok=False, warn=True`, a CAUTION never `bad`: telemetry must not - # put a battery site into NOT READY. UX-08 replaced 0.11.0's sentences - # on purpose. (DECISIONS/MAIN-orchestration-archive.md) - roster = self.pv_roster - if roster: - # UX-08: sources are numbered in the order of the settings list, - # never named by entity id (STYLE.md). The per-source figures and - # units are what `pv.total()` returns; they stay in `pv_parts`. - flip = pv_read.inverted(self.o) - n_src = len(roster) - if self.pv_w is None and n_src == 1: - out.append({"ok": False, "warn": True, "id": "pv", - "text": messages.row_pv_none(self.pv_why)}) - elif self.pv_w is None: - # ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN - # THOSE WORDS - and which sources are missing, by position. - out.append({"ok": False, "warn": True, "id": "pv", - "text": messages.row_pv_partial( - [i + 1 for i, (_e, watt, _y) - in enumerate(self.pv_parts) if watt is None], - n_src)}) - else: - out.append({"ok": True, "warn": False, "id": "pv", - "text": messages.row_pv( - self.pv_w, n_src, - n_src == 1 and roster[0] in flip)}) - # ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s - # `entities()` makes `pv_entities` REPLACE `pv_entity`, which is - # the rule an operator upgrading from 0.11.0 is most likely to trip - # over. (DECISIONS/MAIN-orchestration-archive.md) - if pv_read.entity_is_ignored(self.o): - out.append({"ok": False, "warn": True, "id": "pv", - "text": messages.row_pv_single_ignored()}) - # TEL-14: a sign that looks reversed, per entity rather than on the - # total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3. - for i, (ent, watt, _y) in enumerate(self.pv_parts): - if watt is not None and watt < pv_read.NEGATIVE_W: - out.append({"ok": False, "warn": True, "id": "pv", - "text": messages.row_pv_negative( - i + 1, n_src, watt, ent in flip)}) - _inv_list = [str(x or "").strip() - for x in (self.o.get("pv_invert_entities") or [])] - for ent in pv_read.stray_inverts(self.o): - out.append({"ok": False, "warn": True, "id": "pv", - "text": messages.row_pv_stray( - _inv_list.index(ent) + 1)}) - return out - - def status_payload(self, lang: str = "en") -> dict: - checks = self.checks() - bad = [c for c in checks if not c["ok"] and not c["warn"]] - # SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while - # control goes on, so it neither reads "Not ready" nor holds the page. - runs = [c for c in bad if c.get("runs")] - bad = [c for c in bad if not c.get("runs")] - running_alarm = waiting = False - # SAFETY-10, AND IT IS FIRST IN THE CASCADE: "The battery is NOT being - # controlled" may never be displaced by another condition. ⚠️ AND IT - # CARRIES A STANDING LATCH RATHER THAN HIDING ONE: `shadow.banner()` - # names the latch and goes red for it, so nothing the cascade would - # have said is lost. (DECISIONS/MAIN-orchestration-archive.md) - _shadow = shadow.banner(self.o, control_started=self.auto, - latched=self.shadow_latches()) - if _shadow is not None: - level, banner = _shadow - elif self.circ_wd.tripped: - # ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision: both are - # latched P_SAFETY stops and the banner carries one sentence; this - # one goes first because it is the fault the operator has NO OTHER - # WAY of learning about (a sign disagreement is visible at the - # meter, circulating power nowhere). - # (DECISIONS/MAIN-orchestration-archive.md) - level, banner = "bad", messages.banner_circulating() - elif self.site_sign_wd.tripped and len(self.inverters) > 1: - # SAFETY-30's site half, ABOVE the per-unit rows below and below - # circulating power above: the banner carries one sentence, so the - # one that says the widest true thing. A site sign trip parks EVERY - # inverter and the fault is in what the site was asking for; naming - # a unit would send an operator to swap the wrong box. - # (DECISIONS/MAIN-orchestration-archive.md) - level, banner = "bad", messages.banner_site_direction() - elif any(i.sign_wd.tripped for i in self.inverters): - # Outranks "Not ready": nothing is missing, something went wrong. - # At N>=2 the unit was graded against its own power, so the banner - # says so (see checks()). (DECISIONS/MAIN-orchestration-archive.md) - level, banner = "bad", messages.banner_direction( - len(self.inverters) > 1) - elif bad: - # ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE: a row may - # say more than fits the headline (SAFETY-25's floor row). - # (DECISIONS/MAIN-orchestration-archive.md) - level, banner = "bad", messages.banner_not_ready(bad[0]) - # SAFETY-112: nothing to do but wait, so Home says that instead - # of "Needs you now". Only when it IS the headline: a safety stop - # above outranks it and still needs a person. - waiting = bad[0].get("id") == "home_assistant" - elif not self.auto: - level, banner = "warn", messages.banner_stopped() - elif runs: - level, running_alarm = "bad", True - banner = runs[0].get("banner") or messages.headline_bi( - runs[0]["text"]) - 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 - # (the site is STILL RUNNING, one unit short). ⚠️ BUT IT MUST - # DISPLACE "Running - holding grid at zero": that sentence is a - # claim the site cannot make while a unit is absent. It sits BELOW - # "Stopped". (DECISIONS/MAIN-orchestration-archive.md) - level, banner = "warn", messages.banner_not_delivering( - [i.name for i in self.inverters if i.delivery_wd.tripped]) - elif self.maint.phase != IDLE: - # ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE: at N=1 the - # string is what it has always been, character for character. - # (DECISIONS/MAIN-orchestration-archive.md) - level, banner = "warn", messages.banner_maintenance( - self.maint.phase, - self.maint_unit.name if len(self.inverters) > 1 else "") - else: - level, banner = "ok", messages.banner_running() - - def w(v): - return "—" if v is None else f"{v:,.0f} W".replace(",", " ") - - # ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. `None` means - # "never run, due now" and has to win over any date, so it is tested - # for rather than sorted with. At N=1 this is that pack's own date. - # (DECISIONS/MAIN-orchestration-archive.md) - _now = datetime.now(timezone.utc) - _dues = [m.next_due(_now) for m in self.maints] - _waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u] - next_due = None if any(d is None for d in _dues) else min(_dues) - rows = [ - ("Grid", w(self.grid)), - ("Battery", w(self.site_batt_w)), - # SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is - # not derivable from ("Grid 0 W, Battery 0 W, Circulating 6000 W" - # is the whole card in three rows). Only shown at N>=2: at one - # inverter it is identically zero. - # (DECISIONS/MAIN-orchestration-archive.md) - *(() if len(self.inverters) <= 1 - else ((("Circulating", w(self.site_circulating_w)),))), - # TEL-11, and ONLY when a source is configured - see `checks()`. - # `w(None)` renders "-", which is how this row says "no reading" - # rather than "0 W". (DECISIONS/MAIN-orchestration-archive.md) - *(() if not self.pv_roster else ((("Solar production", - w(self.pv_w)),))), - ("Battery level", "—" if self.pack_floor_soc is None - else f"{self.pack_floor_soc:g} %"), - ("Power request", w(self.target)), - # SAFETY-10, and it sits BETWEEN the two rows it reconciles. - # "Commanded 0 W" above and a "Why" below that explains a trade is - # the exact pair an operator would otherwise have to guess at; this - # row is what makes the two of them consistent, and it is rendered - # in EVERY state - including `off`, where it says the battery IS - # being controlled - because a surface whose ABSENCE is how you - # learn something is a surface you have to already know about. - ("Shadow mode", shadow.words( - self.o, control_started=self.auto, - would_w=self.shadow_would_w, - # SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE - # - the page runs on its own task and `self.peak_guard` has - # moved on. See `Controller.__init__`. - sized_from_meter=self.shadow_sized_from_meter, - reason=self.why_words)), - ("Why", self.why_words), - ("Maintenance phase", self.maint.phase if len(self.inverters) <= 1 - else f"{self.maint.phase} ({self.maint_unit.name})"), - # ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status - # page - where `_maint_deferred` is empty on every tick - carries - # exactly the rows it carried before. - *((("Maintenance waiting", "; ".join(f"{n}: {why}" - for n, why in self._maint_deferred)),) - if self._maint_deferred else ()), - # MAINT-13: only while a failed cycle waits, so a site with nothing - # failed carries exactly the rows it carried before. - *((("Maintenance paused", messages.row_maint_waits( - min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()), - ("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")), - ("Cheap window", "yes" if self.cheap else "no"), - ("Tariff", self.tariff_words), - # PRICE-01: only while no price covers now. - *((("Prices", messages.row_no_prices(*self._gap_args())),) - if self.price_gap else ()), - # FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, the difference between a - # 48-hour plan and a 12-hour one on a weekday morning. Its own row: - # "Tariff" says what the plan IS, this says what it could still be - # filled from if the feed is late again. - # (DECISIONS/MAIN-orchestration-archive.md) - ("Price history", self.price_history.words), - # OPT-04. The strategy's own sentence, not a re-derivation: every - # refusal it can make carries a reason a human can act on. ⚠️ - # TARIFF-11: `self.arb is None` is NOT "trading is off" (cycle() - # returns at the input hold first); `arbitrage_row()` reads the - # option, and `cycle_at`/`arb_at` make "no decision this cycle" an - # observation. (DECISIONS/MAIN-orchestration-archive.md) - ("Arbitrage", arbitrage_row( - on=arbitrage.enabled(self.o) or self.modes_on, - decision=self.arb, - decided_at=self.arb_at, cycle_at=self.cycle_at, - now=datetime.now(timezone.utc), why=self.why_words)), - ] - held = level == "bad" and not running_alarm - # UX-46. The page's language is chosen once, here, for the Home - # sentences, checks, rows that carry one and the banner; a plain str - # (no Dutch twin) stays as it is. - # (DECISIONS/MAIN-orchestration-archive.md) - checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks] - rows = [(k, messages.pick(v, lang)) for k, v in rows] - banner = messages.pick(banner, lang) - out = { - "banner": banner, "level": level, "rows": rows, "checks": checks, - "hint": self.hint, - # The action log itself, newest first: not shown on the page (UX-08 - # 2d) but readable in the `status` JSON, since several events are - # written nowhere else. (DECISIONS/MAIN-orchestration-archive.md) - "events": list(self.events), - } - # UX-13 P1: the Home screen's structured half. Read-only, and it may - # never cost the page its rows: a fault here is logged and the old - # fields are served alone (the page then shows the installer details). - try: - out.update(homeview.build(self, level=level, checks=checks, - lang=lang, held=held, waiting=waiting)) - except Exception: # noqa: BLE001 - # Once per process at ERROR: the page polls every 3 s. - if not getattr(self, "_home_failed", False): - self._home_failed = True - _LOG.exception("Home view could not be built, so the page " - "shows the plain status rows only") - else: - _LOG.debug("Home view failed again", exc_info=True) - out["home_error"] = True - return out - - def forecast_payload(self, lang: str = "en") -> dict: - """UX-54: the Forecast tab's data. Built on request, from what the - control cycle already holds; no Home Assistant call. Today's blocks are - Home's own, so `status_payload()` is built first and read.""" - try: - return dayview.build(self, self.status_payload(lang=lang), - datetime.now(timezone.utc), lang) - except Exception: # noqa: BLE001 - if not getattr(self, "_forecast_failed", False): - self._forecast_failed = True - _LOG.exception("The Forecast tab could not be built") - else: - _LOG.debug("The Forecast tab failed again", exc_info=True) - return {"ok": False} - - async def handle_action(self, what: str, *, unit=None, value=None): - now = datetime.now(timezone.utc) - if what in ("pack_use", "pack_mode", "pack_sell"): - return self._pack_action(what, unit, value, now) - if what == "auto_toggle": - self.auto = not self.auto - self.store.set("auto", self.auto) - self.log_event("control started" if self.auto else "control stopped", - hint=messages.hint("control_started" if self.auto - else "control_stopped")) - if self.auto: - # ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose: - # it clears on a deliberate human start, not on the condition - # going away (the condition ALWAYS goes away, because the trip - # commands 0 W). ⚠️ IT CLEARS EVERY INVERTER'S LATCH, AND SAYS - # SO PER INVERTER, each under its OWN key. - # (DECISIONS/MAIN-orchestration-archive.md) - for inv in self.inverters: - if not inv.sign_wd.tripped: - continue - inv.sign_wd.reset() - inv.store_set("sign_tripped", False) - inv.store_set("sign_reason", "") - self.log_event(f"sign-disagreement failsafe cleared by " - f"operator on {inv.name}", - hint=messages.hint("stop_cleared", - unit=inv.name)) - # ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, - # UNCONDITIONALLY: this is what makes `flat True + namespaced - # False` MEAN something, and why `_grade_store_migration()` can - # run on every start. Left behind, a flat key is a fossil that - # re-arms the failsafe on every restart. - # (DECISIONS/MAIN-orchestration-archive.md) - self.store.set("sign_tripped", False) - self.store.set("sign_reason", "") - # SAFETY-30's SITE latch, cleared by the SAME press for the - # same reason (the trip commands 0 W, so the condition goes - # away on its own). ⚠️ UNCONDITIONAL, NOT GUARDED ON THE - # ROSTER: an install reconfigured back to one inverter would - # otherwise keep `site_sign_tripped: true` for ever. - # (DECISIONS/MAIN-orchestration-archive.md) - if self.site_sign_wd.tripped: - self.log_event("site sign-disagreement failsafe cleared " - "by operator", - hint=messages.hint("site_stop_cleared")) - self.site_sign_wd.reset() - self.store.set("site_sign_tripped", False) - self.store.set("site_sign_reason", "") - # SAFETY-31, cleared by the SAME press and for the same reason. - # ⚠️ AND `_circ_announced` GOES WITH IT: left True, the NEXT - # trip in this process would latch, stop the site and raise no - # notification. (DECISIONS/MAIN-orchestration-archive.md) - if self.circ_wd.tripped: - self.circ_wd.reset() - self.store.set("circ_tripped", False) - self.store.set("circ_reason", "") - self.log_event("circulating-power trip cleared by operator", - hint=messages.hint("site_stop_cleared")) - self._circ_announced = False - # SAFETY-42, cleared by the SAME press, for a reason that is - # NOT the one above: this latch commands nothing, so its - # evidence still stands and it could clear itself. It does not: - # a unit that stopped delivering and resumed is an INTERMITTENT - # fault. ⚠️ AND `_delivery_announced` GOES WITH IT, like - # `_circ_announced`. (DECISIONS/MAIN-orchestration-archive.md) - for inv in self.inverters: - if inv.delivery_wd.tripped: - self.log_event(f"not-delivering report on {inv.name} " - f"cleared by operator", - hint=messages.hint( - "not_delivering_cleared", - unit=inv.name)) - 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(). SMA-10: Stop gives hand-back units back. - # (DECISIONS/MAIN-orchestration-archive.md) - await self.command_site_release() - return {"auto": self.auto} - if what in ("maint_start", "maint_abort") and unit is not None: - # MAINT-11: the operator named the battery. A request with no unit - # falls through to the branches below, unchanged. - return await self._maint_unit_action(what, unit, now) - if what == "maint_start": - # ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS - # ONE LABEL: `_maint_button()` picks the pack that holds the - # maintenance slot, else the one longest without a cycle; at N=1 - # both are `maints[0]`. This is the bare request; a named battery - # is `_maint_unit_action()` (MAINT-11). - # (DECISIONS/MAIN-orchestration-archive.md) - _m = self._maint_button(now) - for msg in _m.force_start(now): - self.log_event(msg, hint=messages.hint("maintenance", - phase=_m.phase)) - return {"phase": self.maint.phase} - if what == "maint_abort": - # Aborts the pack that is actually cycling - `_maint_button()` again - # - and then zeroes the SITE, which is what it always did. - _m = self._maint_button(now) - for msg in _m.abort(now, "operator"): - self.log_event(msg, hint=messages.hint("maintenance", - phase=_m.phase)) - await self.command_site_zero() - return {"phase": self.maint.phase} - return None - - -# MAINT-13: a battery power reading at or below minus this many W is a pack -# that is taking charge. -MAINT_CHARGING_W = 50.0 # SAFETY-61. Exit status when the control task dies; 70 is sysexits(3) # EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md. EXIT_CONTROL_DIED = 70 -async def _bounded(awaitable, budget_s: float): - """SMA-10. `await awaitable`, abandoning it after `budget_s` without waiting - for it to wind down (`wait_for` would). Raises `asyncio.TimeoutError`.""" - task = asyncio.ensure_future(awaitable) - done, _ = await asyncio.wait({task}, timeout=budget_s) - if not done: - task.cancel() - # a result or error that arrives later has nobody to read it - task.add_done_callback(lambda t: t.cancelled() or t.exception()) - raise asyncio.TimeoutError - return task.result() - - class ControlTaskDied(RuntimeError): """The control task ended while nothing had asked it to stop.""" @@ -6052,6 +1180,7 @@ async def amain() -> None: # yet up is not a refusal (run_control() re-checks). # (DECISIONS/MAIN-orchestration-archive.md) # SMA-2: the flat options are the GoodWe driver's (no Controller yet). + # DECISIONS/SMA-2-driver-seam.md. _sp = str(opts.get("setpoint_entity", "") or "").strip() _drv = GoodWeEsphomeDriver(setpoint_entity=_sp).bind(hass) _dev_min = _dev_max = None diff --git a/goodwe_controller/app/rows.py b/goodwe_controller/app/rows.py new file mode 100644 index 0000000..a44c305 --- /dev/null +++ b/goodwe_controller/app/rows.py @@ -0,0 +1,83 @@ +"""Module functions for the status rows and the Why line. + +Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" + +from .arbiter import ACTUATOR_SITE, SET +from . import control as _ctl +from . import messages + + +def why_raised(resolution, claims) -> bool: + """F2 (OPT-11 P1 test): did the limit that bound the site target push it + ABOVE what the winner asked (the peak floor asking for more power)? Then + the Why line must not say "held back". False when nothing bound it.""" + if not resolution.bound_by or resolution.contradiction: + return False + asked = next((c.value for c in sorted( + (c for c in claims if c.kind == SET + and c.actuator == ACTUATOR_SITE and c.source == resolution.winner), + key=lambda c: c.priority, reverse=True)), None) + return asked is not None and resolution.target_w > float(asked) + + +def why_key(winner: str, claims) -> str: + """The key of `messages.WHY_WINNER` for a resolution's winner. "safety" and + "failsafe" stand for several causes, so their own claim reason is the + key ("stopped", "inputs missing", "sign disagreement", or "unsplit" when + this module refuses to split the power).""" + if winner == "loop": + # SAFETY-52: a loop holding a refused direction says so. + return next((c.reason for c in claims if c.source == "loop" + and c.reason in (_ctl.REFUSED, _ctl.PROBING)), winner) + if winner in ("safety", "failsafe"): + return next((c.reason for c in claims + if c.source == winner and c.kind == "set"), + "no claim was made" if not any( + c.kind == "set" for c in claims) else "unsplit") + return winner + + +def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float, + provider: bool, max_age_s: float) -> str: + """The *Tariff* status row: the 30 s tick's sentence AND the provider plan + (TARIFF-11): two sources, two clocks, and the row says so. The plan is + stamped with its own age from `tariff_at` (not moved on a kept plan, + TARIFF-12), up to TARIFF_MAX_AGE_S (5400 s); `age_s` is the caller's + measurement. "No plan" is said aloud on a site that configured a provider. + DECISIONS/MAIN-orchestration.md.""" + # UX-08: plain words, every figure kept. The tick's sentence is "now"; the + # plan carries its own age. + if plan is None: + if not provider: + return tick_words + return (f"{tick_words}. No price plan: none yet, or the last one is " + f"over {max_age_s:.0f} s old") + return (f"{tick_words}. Price plan from {age_s:.0f} s ago, for " + f"{horizon_h:.0f} hours ahead: {plan.words}") + + +def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now, + why: str) -> str: + """The *Arbitrage* status row: three states (TARIFF-11): off (not armed), + armed with no decision this cycle (`why` is the loop's reason), or a + decision with its age. + `on` is read from the options, never inferred from `decision is None`: + `cycle()` returns at the input hold before `self.arb = None`, so the old + row named an option that was ON. The age is always printed, even 0. + DECISIONS/MAIN-orchestration.md.""" + # UX-08: plain words; the setting is named by its label. + if not on: + return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not " + f"switched on") + if decided_at is None: + return (f"On, but no decision yet, because control has not got that " + f"far. Control says: {why or 'no reason recorded'}") + age = (now - decided_at).total_seconds() + stale = cycle_at is not None and decided_at < cycle_at + head = (f"On, no decision this cycle ({why or 'no reason recorded'}), " + f"the last one {age:.0f} s ago: " if stale else + f"On, decided {age:.0f} s ago: ") + if decision is None: + return (f"On, no decision this cycle ({why or 'no reason recorded'}) " + f"and none before") + return head + decision.explain() diff --git a/goodwe_controller/test_arbitrage.py b/goodwe_controller/test_arbitrage.py index 9a6e436..e128a26 100644 --- a/goodwe_controller/test_arbitrage.py +++ b/goodwe_controller/test_arbitrage.py @@ -109,6 +109,8 @@ from app.validate import validate_startup_config UTC = timezone.utc HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "tools")) +import main_files # noqa: E402 # PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every # point these fixtures build, so no period has ended and the whole series counts. @@ -779,9 +781,7 @@ check("...and cycle() calls it rather than inlining the conjunction, so a " "mutation back to a bare `engaged_now` is red rather than silent", "arbitrage.guard_state(self.peak_guard)" in ast.get_source_segment( - open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read(), - _fn(open(os.path.join(HERE, "app", "main.py"), - encoding="utf-8").read(), "cycle"))) + main_files.source(), _fn(main_files.source(), "cycle"))) # --------------------------------------------------------------------------- # @@ -907,8 +907,8 @@ check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export " # above calls decide() itself - and a required keyword-only parameter that no # caller fills is a TypeError only on the one path a suite does not run. Same # pin, same reason, as `weights=` in test_packshare.py part 10. -_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read() -_S45_MAIN_TREE = ast.parse(_S45_MAIN) +_S45_MAIN = main_files.source() +_S45_MAIN_TREE = main_files.tree(_S45_MAIN) _s45_calls = [n for n in ast.walk(_S45_MAIN_TREE) if isinstance(n, ast.Call) and ast.unparse(n.func).endswith("arbitrage.decide")] @@ -2215,8 +2215,7 @@ print() print("8. the background refresh - the tick must never await a fetch") -_MAIN_SRC = derived(lambda: open(os.path.join(HERE, "app", "main.py"), - encoding="utf-8").read()) +_MAIN_SRC = derived(main_files.source) _cycle = derived(lambda: _fn(_MAIN_SRC, "cycle")) _refresh = derived(lambda: _fn(_MAIN_SRC, "run_tariff_refresh")) _read_inputs = derived(lambda: _fn(_MAIN_SRC, "read_inputs")) diff --git a/goodwe_controller/test_clamp.py b/goodwe_controller/test_clamp.py index f651a2d..e5713e3 100644 --- a/goodwe_controller/test_clamp.py +++ b/goodwe_controller/test_clamp.py @@ -1969,9 +1969,11 @@ print("9. SAFETY-40: the integrator bound is not a BUG SIGNAL, and the numbers " # The fixtures are chosen so the numbers the old text used DIFFER from the true # ones (MUTATION.md habit 4); the PRECONDITION checks pin that. import logging +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402 +import main_files # noqa: E402 import app.main as _main_mod -_real_compute = _main_mod.compute +_real_compute = main_files.live.compute class _LogCap(logging.Handler): @@ -1995,7 +1997,7 @@ def s40_run(grid, cycles, track, **over): cap = _LogCap() lg = logging.getLogger("goodwe") lg.addHandler(cap) - _main_mod.compute = spy + main_files.live.compute = spy async def go(): hass = _Hass(grid, track=track) @@ -2024,7 +2026,7 @@ def s40_run(grid, cycles, track, **over): try: hass, c, red = asyncio.run(go()) finally: - _main_mod.compute = _real_compute + main_files.live.compute = _real_compute lg.removeHandler(cap) return ticks, hass, c, red, cap.records @@ -2104,8 +2106,7 @@ check("...and its ERROR says it is a software fault and that control keeps " any(x.levelno == logging.ERROR and "software fault" in x.getMessage() and "control runs on" in x.getMessage() for x in log_o) and "reached 14 768 W on this project" in - open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app", - "main.py"), encoding="utf-8").read()) + main_files.source()) check("...under the over-rating title, quoting a tick's own asked and sent " "numbers in that order, with a red status row", clamp_notes(hass_o) diff --git a/goodwe_controller/test_defaults_pin.py b/goodwe_controller/test_defaults_pin.py index c8acc21..2d43e5c 100644 --- a/goodwe_controller/test_defaults_pin.py +++ b/goodwe_controller/test_defaults_pin.py @@ -149,9 +149,15 @@ print("2. the mirrors, DISCOVERED from the tree rather than listed here") # `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and # grades whatever it finds, so the seventh is covered on the commit that # introduces it. That is the property the six hand-written pins cannot have. -MODULES = sorted(p.name for p in APP.glob("*.py")) +sys.path.insert(0, str(HERE / "tools")) +import main_files # noqa: E402 + +# MAINT-21: app/main.py stands for itself and the files split from it. +MODULES = sorted(p.name for p in APP.glob("*.py") + if p.name == "main.py" or p.name not in main_files.files()) TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name) for name in MODULES} +TREES["main.py"] = main_files.tree() MIRROR = {} # schema key -> (module, table name, value) TABLES = [] diff --git a/goodwe_controller/test_delivery.py b/goodwe_controller/test_delivery.py index 2688c84..3f6715a 100644 --- a/goodwe_controller/test_delivery.py +++ b/goodwe_controller/test_delivery.py @@ -1272,6 +1272,9 @@ check("the operator's START press is the only way out, and it clears the " print() print("8b. SAFETY-34: a Core-only restart - same add-on process, HA's memory wiped") +import os # noqa: E402 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402 +import main_files # noqa: E402 import app.main as _main_mod # noqa: E402 @@ -1283,8 +1286,8 @@ async def _core_restart(): _c = asyncio.run(_core_restart()) -_real_mono, _clock = _main_mod._mono, [_main_mod._mono()] -_main_mod._mono = lambda: _clock[0] +_real_mono, _clock = main_files.live._mono, [main_files.live._mono()] +main_files.live._mono = lambda: _clock[0] try: _c.hass = _DeliveryHass(b_soc=50.0) asyncio.run(_c.check_not_delivering()) @@ -1295,7 +1298,7 @@ try: _late = [x for x in _c.hass.calls if "not_delivering" in str(x[2].get("notification_id", ""))] finally: - _main_mod._mono = _real_mono + main_files.live._mono = _real_mono check("SAFETY-34: the latch tripped in this process", _c.inverters[1].delivery_wd.tripped) check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise", diff --git a/goodwe_controller/test_inverter.py b/goodwe_controller/test_inverter.py index 4b7281e..217c7fc 100644 --- a/goodwe_controller/test_inverter.py +++ b/goodwe_controller/test_inverter.py @@ -75,6 +75,9 @@ from pathlib import Path from named_absence import first, given, nth, present +import os # noqa: E402 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402 +import main_files # noqa: E402 from app import main as app_main from app.inverter import LINK_STALE_S, UNIT_A, Inverter, build_roster from app.main import WRITE_BUDGET_S, Controller @@ -88,8 +91,10 @@ from app.arbiter import P_LOOP, P_SAFETY, Claim, resolve fails = [] HERE = Path(__file__).resolve().parent -MAIN_SRC = io.open(HERE / "app" / "main.py", encoding="utf-8").read() -MAIN_AST = ast.parse(MAIN_SRC) +sys.path.insert(0, str(HERE / "tools")) +import main_files # noqa: E402 +MAIN_SRC = main_files.source() +MAIN_AST = main_files.tree(MAIN_SRC) CTRL = [n for n in MAIN_AST.body if isinstance(n, ast.ClassDef) and n.name == "Controller"][0] @@ -1097,8 +1102,8 @@ check("...and it is below hass.py's own 10 s HTTP timeout, so the budget is " # is healthy. The budget is shortened for the run - the shipped value is graded # above - because a 4 s wait per case is 8 s of suite time to measure something # that is already a ratio. -_REAL_BUDGET = app_main.WRITE_BUDGET_S -app_main.WRITE_BUDGET_S = 0.30 +_REAL_BUDGET = main_files.live.WRITE_BUDGET_S +main_files.live.WRITE_BUDGET_S = 0.30 async def _hung_heartbeat(): @@ -1124,18 +1129,18 @@ async def _hung_heartbeat(): _c6, _h6, _elapsed, _t0 = run(_hung_heartbeat()) _b_at = [t - _t0 for e, _v, t in _h6.write_at if e == "number.sp_b"] print(f" A hangs forever, B is healthy: the pair took {_elapsed:.2f} s " - f"(budget {app_main.WRITE_BUDGET_S:.2f} s); B's write reached the wire " + f"(budget {main_files.live.WRITE_BUDGET_S:.2f} s); B's write reached the wire " f"{(_b_at[0] if _b_at else float('nan')):.3f} s in; wire saw " f"{_h6.writes}") check("⚠️ AC 6. B'S WRITE REACHED THE WIRE WHILE A WAS STILL HANGING. That is " "the whole criterion: a hung write to A cannot consume B's layer-1 " "budget, because B's write was never behind A's in a queue", _h6.writes == [("number.sp_b", 700.0)] - and bool(_b_at) and _b_at[0] < app_main.WRITE_BUDGET_S / 2.0) + and bool(_b_at) and _b_at[0] < main_files.live.WRITE_BUDGET_S / 2.0) check("...and the pair returned in about ONE budget rather than in two, which " "is what says the writes were concurrent and not merely bounded. Two " "bounded writes in series is still a sibling waiting for a corpse", - _elapsed < app_main.WRITE_BUDGET_S * 1.9) + _elapsed < main_files.live.WRITE_BUDGET_S * 1.9) check("...and the hung inverter is recorded as having FAILED its write, so " "`last_write` is not advanced and the next tick re-issues it by itself", _c6.inverters[0].last_write is None @@ -1176,7 +1181,7 @@ check("⚠️ AND THE DEFECT IS REPRODUCED BESIDE THE FIX. Written in series, B' "every inverter behind the hung one, and with the unbounded write this " "card replaces it was the HTTP stack's timeout - which is what put a " "healthy sibling within reach of layer 1's veto", - _ser_b_at >= app_main.WRITE_BUDGET_S) + _ser_b_at >= main_files.live.WRITE_BUDGET_S) # AND THE DEADLINE ITSELF IS PER UNIT - graded through the shipped method, for # the reason the experiment above is: a `due` list computed in this file is a @@ -1219,7 +1224,7 @@ check("...and the heartbeat re-emits each unit's OWN share rather than the " # heartbeat is not the only place a hung inverter can hold the loop: every cycle # writes every unit's share, and in series that is the same defect at the cycle # rate rather than at the heartbeat rate. -app_main.WRITE_BUDGET_S = 0.30 +main_files.live.WRITE_BUDGET_S = 0.30 async def _hung_cycle(): @@ -1248,17 +1253,17 @@ check("⚠️ AND A REAL CYCLE DELIVERS B'S SHARE WHILE A HANGS. This is the sam "`cycle()` go out together, so a hung inverter costs the loop one budget " "and costs its siblings nothing", _h6c.writes and _h6c.writes[0][0] == "number.sp_b" - and bool(_cyc_b) and _cyc_b[0] < app_main.WRITE_BUDGET_S / 2.0 - and _cyc_elapsed < app_main.WRITE_BUDGET_S * 1.9) + and bool(_cyc_b) and _cyc_b[0] < main_files.live.WRITE_BUDGET_S / 2.0 + and _cyc_elapsed < main_files.live.WRITE_BUDGET_S * 1.9) check("...and the share B was told is the allocator's, not the site target - " "the hung sibling does not turn the cycle into a broadcast", _h6c.writes[0][1] == float(_c6c.allocation.shares["inverter-b"]) and _h6c.writes[0][1] != _c6c.target) -app_main.WRITE_BUDGET_S = _REAL_BUDGET +main_files.live.WRITE_BUDGET_S = _REAL_BUDGET check("...and the shipped budget is restored after the experiments, so nothing " "below runs against a 0.3 s one", - app_main.WRITE_BUDGET_S == WRITE_BUDGET_S) + main_files.live.WRITE_BUDGET_S == WRITE_BUDGET_S) # ⚠️ AND THE OPERATOR'S STOP HAS TO MOVE THE SAME TWO THINGS. This is a hazard diff --git a/goodwe_controller/test_maint10.py b/goodwe_controller/test_maint10.py index 6025c00..4a109c9 100644 --- a/goodwe_controller/test_maint10.py +++ b/goodwe_controller/test_maint10.py @@ -22,6 +22,7 @@ from pathlib import Path sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) +import main_files # noqa: E402 import app.main as M # noqa: E402 from app import maintenance as MT # noqa: E402 from app.maintenance import ( # noqa: E402 @@ -367,7 +368,7 @@ def amain_zone(zone_body, env_tz): M.load_options = lambda: {"setpoint_entity": "number.sp"} M.HomeAssistant = lambda session, **kw: _Hass() - M.validate_startup_config = lambda *a, **k: [] + main_files.live.validate_startup_config = lambda *a, **k: [] M.mode_announcement = lambda opts, store: ((), ()) M.Controller = _controller M.aiohttp.ClientSession = lambda *a, **k: _Session(zone_body) diff --git a/goodwe_controller/test_maint13.py b/goodwe_controller/test_maint13.py index 2e76f48..f510b57 100644 --- a/goodwe_controller/test_maint13.py +++ b/goodwe_controller/test_maint13.py @@ -520,9 +520,11 @@ check("a fixed tariff set up counts as a gate: outside its cheap hours a pack " "that is not charging is closed, inside them it is open", c._price_gated is True and c._charge_gate_open(c.inverters[0]) is c.cheap) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402 +import main_files # noqa: E402 import app.main as main_mod # noqa: E402 -_real_gated, _calls = main_mod.static_gated, [] -main_mod.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now) +_real_gated, _calls = main_files.live.static_gated, [] +main_files.live.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now) try: c._gated_key = None read(c) @@ -530,7 +532,7 @@ try: read(c) c._static_gated(c.o, datetime.now(UTC) + timedelta(hours=1)) finally: - main_mod.static_gated = _real_gated + main_files.live.static_gated = _real_gated check("...worked out once an hour, not on every tick (3 reads in one hour = 1 " f"build; a new hour = 1 more): {len(_calls)} builds", len(_calls) == 2 and c._price_gated is True) diff --git a/goodwe_controller/test_messages.py b/goodwe_controller/test_messages.py index c007134..a4e5a3c 100644 --- a/goodwe_controller/test_messages.py +++ b/goodwe_controller/test_messages.py @@ -241,6 +241,27 @@ check("the output-clamp alarm quotes asked before sent", # ============================================================================== # print("\n2. no notification is worded outside app/messages.py") APP = HERE / "app" +sys.path.insert(0, str(HERE / "tools")) +import main_files # noqa: E402 + + +class _AppFile: + """An app/*.py file. `main.py` stands for itself and the files split from + it (MAINT-21), so every scan below still sees the Controller whole.""" + + def __init__(self, path): + self.name, self.stem, self._path = path.name, path.stem, path + + def read_text(self, encoding="utf-8"): + if self.name == "main.py": + return main_files.source() + return self._path.read_text(encoding=encoding) + + +def _app_py(): + split = set(main_files.files()) - {"main.py"} + return [_AppFile(f) for f in sorted(APP.glob("*.py")) + if f.name not in split] # UX-08 2d: a refusal is counted as it is SHOWN. A placeholder that names a @@ -318,7 +339,7 @@ def notification_calls(tree): bad_calls, n_calls = [], 0 -for f in sorted(APP.glob("*.py")): +for f in _app_py(): for call in notification_calls(ast.parse(f.read_text(encoding="utf-8"))): n_calls += 1 payload = call.args[2] if len(call.args) > 2 else None @@ -368,13 +389,13 @@ def _module_constants(tree): _MODCONST = {f.stem: _module_constants(ast.parse(f.read_text(encoding="utf-8"))) - for f in sorted(APP.glob("*.py"))} + for f in _app_py()} def collect(): """(kind, where, text) for every user-facing literal of a ratcheted kind.""" out = [] - for f in sorted(APP.glob("*.py")): + for f in _app_py(): if f.name == "messages.py": continue tree = ast.parse(f.read_text(encoding="utf-8")) @@ -543,7 +564,7 @@ for key, text in messages.hint_samples(): p = row_problems(messages.GREEN, text) check(f"hint {text!r}{' - ' + '; '.join(p) if p else ''}", not p) _calls, _bare = 0, [] -for f in sorted(APP.glob("*.py")): +for f in _app_py(): for node in ast.walk(ast.parse(f.read_text(encoding="utf-8"))): if (isinstance(node, ast.Call) and ast.unparse(node.func).endswith("log_event")): @@ -1012,7 +1033,7 @@ def _static(spec): the named file (placeholders count as one word, as in part 3).""" fname, kind, name = spec out = [] - for f in ([APP / fname] if fname != "*" else sorted(APP.glob("*.py"))): + for f in ([_AppFile(APP / fname)] if fname != "*" else _app_py()): tree = ast.parse(f.read_text(encoding="utf-8")) for node in ast.walk(tree): if kind == "call" and isinstance(node, ast.Call) and node.args \ @@ -1201,7 +1222,7 @@ def scan_log_lines(files, text=None, data=None, long_ok=None, long_seen=None): _FILES = [(f.name, ast.parse(f.read_text(encoding="utf-8"))) - for f in sorted(APP.glob("*.py"))] + for f in _app_py()] _long_seen = set() _unknown, _stale, _bad = scan_log_lines(_FILES, long_seen=_long_seen) for k, sample, p in _bad: @@ -1415,7 +1436,7 @@ def _captured(logger_name, fn): def _level_of(fname, words_in_line): """The level of the log call in `fname` whose literal holds the words.""" - tree = ast.parse((APP / fname).read_text(encoding="utf-8")) + tree = ast.parse(_AppFile(APP / fname).read_text(encoding="utf-8")) for node in ast.walk(tree): if isinstance(node, ast.Call) and node.args \ and re.search(r"_LOG\.\w+$", ast.unparse(node.func)): @@ -1561,7 +1582,7 @@ check(f"a socket Home Assistant closed is a plain ConnectionResetError, which " # words. The keys are found in the code: each `Claim.set(source, ..., reason)` # through main.why_key(), plus main.py's own refusal to split ("unsplit"). _SOURCES = {} -for _f in sorted(APP.glob("*.py")): +for _f in _app_py(): _tree = ast.parse(_f.read_text(encoding="utf-8")) _consts = _module_constants(_tree) for _n in ast.walk(_tree): @@ -1781,7 +1802,7 @@ check(f"...and in the action log, which an installer reads at the default " and any(e.endswith(messages.NO_REASON_SAVED) for e in _rs_ev)) # UX-11 item 4: the action log's clamp count and the shadow-off entry. -_main_src = (APP / "main.py").read_text(encoding="utf-8") +_main_src = _AppFile(APP / "main.py").read_text(encoding="utf-8") check("the action log counts 'engagements' with a real plural and the " "shadow-off entry is plain, not 'engagement(s)' or 'SHADOW MODE OFF -'", "engagement(s)" not in _main_src and "SHADOW MODE OFF" not in _main_src diff --git a/goodwe_controller/test_modes.py b/goodwe_controller/test_modes.py index 7f31f9b..026d16a 100644 --- a/goodwe_controller/test_modes.py +++ b/goodwe_controller/test_modes.py @@ -32,6 +32,7 @@ sys.path.insert(0, os.path.join(HERE, "tools")) import opt11_identity as rig # noqa: E402 from named_absence import nth, present # noqa: E402 +import main_files # noqa: E402 import app.main as app_main # noqa: E402 from app import homeview, messages, packmode # noqa: E402 from app.control import MODE_BOUND # noqa: E402 @@ -62,24 +63,24 @@ def store_with(**modes): def spy_claims(): """Record every claim list main.py's own `resolve` is handed.""" - seen, real = [], app_main.resolve + seen, real = [], main_files.live.resolve def _spy(claims, **kw): seen.append(list(claims)) return real(claims, **kw) - app_main.resolve = _spy - return seen, lambda: setattr(app_main, "resolve", real) + main_files.live.resolve = _spy + return seen, lambda: setattr(main_files.live, "resolve", real) def spy_bounds(): """Record the `bound` main.py hands `compute()` on every cycle.""" - seen, real = [], app_main.compute + seen, real = [], main_files.live.compute def _spy(*a, **kw): seen.append(kw.get("bound", "absent")) return real(*a, **kw) - app_main.compute = _spy - return seen, lambda: setattr(app_main, "compute", real) + main_files.live.compute = _spy + return seen, lambda: setattr(main_files.live, "compute", real) def flat(t): diff --git a/goodwe_controller/test_modes_p4.py b/goodwe_controller/test_modes_p4.py index 698be67..fb16d90 100644 --- a/goodwe_controller/test_modes_p4.py +++ b/goodwe_controller/test_modes_p4.py @@ -37,6 +37,7 @@ sys.path.insert(0, os.path.join(HERE, "tools")) logging.disable(logging.WARNING) from named_absence import nth, present # noqa: E402 +import main_files # noqa: E402 import opt11p3_replay as rp # noqa: E402 from app import arbitrage, dayplan, homeview, modetrade, packmode # noqa from app import priceruns, solarfc, validate # noqa: E402 @@ -777,7 +778,7 @@ check("battery modes off: the forecast sensor is never read", READS[0] == 0) print("7. the Batteries screen and the Simple view") rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0}, hours=0.6) -rp.m.datetime = datetime +main_files.live.datetime = datetime sb = homeview.solar_block(c, c.solar_fc.at) check("the forecast line names the forecast, what is counted and when", sb["line"].startswith("Solar still to come today:") @@ -812,14 +813,14 @@ check("a lost forecast says so: no forecast, Leftover solar solar only", c._charge_latch = latch # the latch ends at its end time; this replay ran in September, so ask at a # moment inside it -_real_dt = rp.m.datetime -rp.m.datetime = type("_DT", (datetime,), {"now": classmethod( +_real_dt = main_files.live.datetime +main_files.live.datetime = type("_DT", (datetime,), {"now": classmethod( lambda cls, tz=None: latch[0] - timedelta(hours=1))}) check("OPT-34: with a charge under way (B charged in this run), the level " "stays: plan_targets() gives it (the line: test_opt34.py)", latch is not None and c.plan_targets() is not None and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B]) -rp.m.datetime = _real_dt +main_files.live.datetime = _real_dt check("the Why line wins over 'saves its power' only while holding", homeview.battery_why(modes_on=True, mode="leftover_solar", off=False, reading=True, phase="idle", shadow=False, diff --git a/goodwe_controller/test_opt27.py b/goodwe_controller/test_opt27.py index 7f3809d..3670f12 100644 --- a/goodwe_controller/test_opt27.py +++ b/goodwe_controller/test_opt27.py @@ -46,6 +46,7 @@ logging.disable(logging.WARNING) import opt11p3_replay as rp # noqa: E402 from named_absence import first, nth, present # noqa: E402 +import main_files # noqa: E402 import app.main as m # noqa: E402 from app import homeview, modetrade, solarfc # noqa: E402 from app.main import Controller # noqa: E402 @@ -404,14 +405,14 @@ def drive27(o, sensors, *, pre, start, minutes=20, restart=None, async def go(): c, hass = build(store) - real_dt, real_hv = m.datetime, homeview.datetime + real_dt, real_hv = main_files.live.datetime, homeview.datetime class _DT(real_dt): @classmethod def now(cls, tz=None): return clock[0] if tz is None else clock[0].astimezone(tz) - m.datetime = homeview.datetime = _DT + main_files.live.datetime = homeview.datetime = _DT task = None try: for t_pre in pre: @@ -477,7 +478,7 @@ def drive27(o, sensors, *, pre, start, minutes=20, restart=None, await task except asyncio.CancelledError: pass - m.datetime, homeview.datetime = real_dt, real_hv + main_files.live.datetime, homeview.datetime = real_dt, real_hv return c return asyncio.run(go()), rows, pre_rows diff --git a/goodwe_controller/test_opt34.py b/goodwe_controller/test_opt34.py index fdb4698..3db53fc 100644 --- a/goodwe_controller/test_opt34.py +++ b/goodwe_controller/test_opt34.py @@ -61,6 +61,7 @@ sys.path.insert(0, os.path.join(HERE, "tools")) logging.disable(logging.WARNING) import opt11p3_replay as rp # noqa: E402 +import main_files # noqa: E402 import app.main as m # noqa: E402 from app import homeview, solarfc # noqa: E402 from app.main import Controller # noqa: E402 @@ -348,14 +349,14 @@ def run_night(spec): async def go(): cur = store - real_dt, real_hv = m.datetime, homeview.datetime + real_dt, real_hv = main_files.live.datetime, homeview.datetime class _DT(real_dt): @classmethod def now(cls, tz=None): return clock[0] if tz is None else clock[0].astimezone(tz) - m.datetime = homeview.datetime = _DT + main_files.live.datetime = homeview.datetime = _DT try: # TEST-37: built under the scenario's clock, not the real one: # Controller.__init__ checks a stored latch's end against now(). @@ -420,7 +421,7 @@ def run_night(spec): out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc) out["latch"] = getattr(c, "_charge_latch", None) is not None finally: - m.datetime, homeview.datetime = real_dt, real_hv + main_files.live.datetime, homeview.datetime = real_dt, real_hv return out return asyncio.run(go()) @@ -722,7 +723,7 @@ from types import SimpleNamespace # noqa: E402 from app import modetrade, priceruns # noqa: E402 CLOCK = [at(4, 1, 0)] -_dt0 = m.datetime +_dt0 = main_files.live.datetime class _FixedDT(_dt0): @@ -731,7 +732,7 @@ class _FixedDT(_dt0): return CLOCK[0] if tz is None else CLOCK[0].astimezone(tz) -m.datetime = homeview.datetime = _FixedDT +main_files.live.datetime = homeview.datetime = _FixedDT _tmp = tempfile.mkdtemp(prefix="o34-") lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0, {"a": 40.0, "b": 40.0}), @@ -858,7 +859,7 @@ lc._charge_latch = (at(4, 6, 10), {A: 80.0}, at(4, 1, 0)) lt, _s = latch_step(None, resting, None, now=at(4, 6, 5), runs=r3) check("F4: a run that starts AT the bell (06:00-09:00) does carry it on, " "to its end + one slot", lt is not None and lt[0] == at(4, 9, 15)) -m.datetime = homeview.datetime = _dt0 +main_files.live.datetime = homeview.datetime = _dt0 print() if fails: diff --git a/goodwe_controller/test_opt36.py b/goodwe_controller/test_opt36.py index 331f6ed..1b1d3da 100644 --- a/goodwe_controller/test_opt36.py +++ b/goodwe_controller/test_opt36.py @@ -49,6 +49,7 @@ logging.disable(logging.WARNING) from named_absence import nth, present # noqa: E402 import ux54_scenarios as S # noqa: E402 import opt11p3_replay as rp # noqa: E402 +import main_files # noqa: E402 import app.main as m # noqa: E402 from app import dayplan, homeview, modetrade, packmode # noqa: E402 from app import priceruns, solarfc, uitext # noqa: E402 @@ -662,14 +663,14 @@ def run_night(spec): for d in ("2026-10-31", "2026-11-01", "2026-11-02")}) async def go(): - real_dt, real_hv = m.datetime, homeview.datetime + real_dt, real_hv = main_files.live.datetime, homeview.datetime class _DT(real_dt): @classmethod def now(cls, tz=None): return clock[0] if tz is None else clock[0].astimezone(tz) - m.datetime = homeview.datetime = _DT + main_files.live.datetime = homeview.datetime = _DT try: for t_pre in EVENING: clock[0] = t_pre @@ -722,7 +723,7 @@ def run_night(spec): out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc) out["latch"] = getattr(c, "_charge_latch", None) is not None finally: - m.datetime, homeview.datetime = real_dt, real_hv + main_files.live.datetime, homeview.datetime = real_dt, real_hv return out return asyncio.run(go()) diff --git a/goodwe_controller/test_packshare.py b/goodwe_controller/test_packshare.py index 561e9ad..b4e3893 100644 --- a/goodwe_controller/test_packshare.py +++ b/goodwe_controller/test_packshare.py @@ -34,6 +34,8 @@ import sys from datetime import datetime, timedelta, timezone from math import inf from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent / "tools")) +import main_files # noqa: E402 from app import arbitrage, packshare from named_absence import first, nth, present @@ -1132,8 +1134,7 @@ check("...and misfiling it ON THE SITE is caught and REFUSED, not consumed. A " # main.py's own AST, the way test_peakguard.py part 12i pins the arguments of # `arbitrage.decide()`, and for the same reason: a call site that stopped # passing it is invisible to every fixture. -_main_src = (Path(__file__).resolve().parent / "app" / "main.py").read_text( - encoding="utf-8") +_main_src = main_files.source() _resolve_calls = [n for n in ast.walk(ast.parse(_main_src)) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "resolve"] @@ -1158,9 +1159,7 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE # cycle's value, and logs. What it does NOT grade is the sentence it logs or # that a real cycle reaches it; that is stated here rather than implied. _cycle_src = first( - f for cls in ast.walk(ast.parse( - (Path(__file__).resolve().parent / "app" / "main.py") - .read_text(encoding="utf-8"))) + f for cls in ast.walk(main_files.tree()) if isinstance(cls, ast.ClassDef) and cls.name == "Controller" for f in cls.body if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle") diff --git a/goodwe_controller/test_peak_margin.py b/goodwe_controller/test_peak_margin.py index 19f09d9..97aae45 100644 --- a/goodwe_controller/test_peak_margin.py +++ b/goodwe_controller/test_peak_margin.py @@ -22,6 +22,7 @@ import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "tools")) +import main_files # noqa: E402 import safety79_identity as rig # noqa: E402 from named_absence import nth, present # noqa: E402 @@ -207,9 +208,8 @@ check("...and the user-facing peak notice built from the decision's limit " # ============================================================================ # print("\n4. each call site in main.py") -with open(os.path.join(HERE, "app", "main.py"), encoding="utf-8") as f: - _src = f.read() -_tree = ast.parse(_src) +_src = main_files.source() +_tree = main_files.tree(_src) def calls(name): diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 42e2f52..f64701b 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -135,6 +135,9 @@ from app.arbiter import ( ) from app.control import Tuning, compute, peak_at_risk from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w +import os # noqa: E402 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402 +import main_files # noqa: E402 from app import main as app_main from app.inverter import Inverter, build_roster from app.main import Controller @@ -1295,7 +1298,7 @@ print(" the wiring's own mutations - a default install, maintenance charging") # `peak_at_risk()` answers False forever. That is the whole point - it is the # configuration on which the old suspension could not fire. MAINT_CHARGING = {"phase": "charge"} -_mainsrc = (HERE / "app" / "main.py").read_text(encoding="utf-8") +_mainsrc = main_files.source() def maint_cycle(tag, want_floor_w=LIVE_MAX_W * 0.3, **kw): @@ -2709,7 +2712,7 @@ CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak", # silent gap in the scan. CLAIM_BUILDERS = {"peak_claim": "peak", "arb_claim": "arbitrage"} -_main_tree = ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8")) +_main_tree = main_files.tree() _cycle_def = first(( f for cls in ast.walk(_main_tree) @@ -2762,7 +2765,7 @@ def cycle_claims(run): call site and nowhere else. """ seen = [] - real = app_main.resolve + real = main_files.live.resolve def _spy(claims, **kw): # ⚠️ `**kw` PASSES THE ROSTER THROUGH UNREAD, AND THAT IS THE WHOLE @@ -2776,11 +2779,11 @@ def cycle_claims(run): seen.append(list(claims)) return real(claims, **kw) - app_main.resolve = _spy + main_files.live.resolve = _spy try: out = run() finally: - app_main.resolve = real + main_files.live.resolve = real return out, (seen[-1] if seen else []) @@ -4977,7 +4980,11 @@ check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide() # written, at every roster length, in every configuration, and with no fixture # required. _APP_SRC = {p.stem: p.read_text(encoding="utf-8") - for p in sorted((HERE / "app").glob("*.py"))} + for p in sorted((HERE / "app").glob("*.py")) + if p.name == "main.py" or p.name not in main_files.files()} +# MAINT-21: main.py and the files split from it, the mixins read as Controller. +_APP_SRC["main"] = re.sub(r"^class \w+Mixin:", "class Controller:", + main_files.source(), flags=re.M) _ROSTER_OPTION = "inverters" # the ONE seed. Everything else is closure. _WRAPPERS = {"list", "tuple", "sorted", "enumerate", "zip", "reversed", "filter", "iter", "set", "dict"} diff --git a/goodwe_controller/test_price_history.py b/goodwe_controller/test_price_history.py index 33827f9..ce04bff 100644 --- a/goodwe_controller/test_price_history.py +++ b/goodwe_controller/test_price_history.py @@ -118,6 +118,8 @@ from app.static_tariff import resolve_tariff UTC = timezone.utc HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "tools")) +import main_files # noqa: E402 # PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every # point these fixtures build, so no period has ended and the whole series counts. @@ -2015,8 +2017,8 @@ check("...so calling it without one is a TypeError, not a quiet regression", transport=transport_for(_body_today), now=NOW)), TypeError)) -_MAIN = open(os.path.join(_APP, "main.py"), encoding="utf-8").read() -_main_tree = ast.parse(_MAIN) +_MAIN = main_files.source() +_main_tree = main_files.tree(_MAIN) _refresh = first((n for n in ast.walk(_main_tree) if isinstance(n, ast.AsyncFunctionDef) and n.name == "run_tariff_refresh"), "run_tariff_refresh in main.py") diff --git a/goodwe_controller/test_price_service.py b/goodwe_controller/test_price_service.py index bfe94ae..6b0d1c6 100644 --- a/goodwe_controller/test_price_service.py +++ b/goodwe_controller/test_price_service.py @@ -44,6 +44,8 @@ import tempfile from datetime import date, datetime, timedelta, timezone from named_absence import nth +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402 +import main_files # noqa: E402 from app import main as _main from app import messages, price as P, price_chain as C from app.price import ( @@ -686,8 +688,8 @@ def controller(clock, o, tmp=None): return ctl -_REAL_DT, _REAL_GET = _main.datetime, P.https_get -_real_resolve = _main.resolve_tariff +_REAL_DT, _REAL_GET = main_files.live.datetime, P.https_get +_real_resolve = main_files.live.resolve_tariff class Wired: @@ -701,7 +703,7 @@ class Wired: self.refreshes = 0 def __enter__(self): - _main.datetime = FakeDT + main_files.live.datetime = FakeDT P.https_get = self.net W = self @@ -709,12 +711,12 @@ class Wired: r = await _real_resolve(*a, **k) W.refreshes += 1 return r - _main.resolve_tariff = counting + main_files.live.resolve_tariff = counting return self def __exit__(self, *exc): - _main.datetime, P.https_get = _REAL_DT, _REAL_GET - _main.resolve_tariff = _real_resolve + main_files.live.datetime, P.https_get = _REAL_DT, _REAL_GET + main_files.live.resolve_tariff = _real_resolve async def refresh(self, now=None): if now is not None: diff --git a/goodwe_controller/test_pv.py b/goodwe_controller/test_pv.py index ad71ba7..090575a 100644 --- a/goodwe_controller/test_pv.py +++ b/goodwe_controller/test_pv.py @@ -692,7 +692,13 @@ def touches(src: str) -> bool: or "from .pv import" in src -_touch = sorted(f.name for f in APP if touches(f.read_text(encoding="utf-8"))) +sys.path.insert(0, str(HERE / "tools")) +import main_files # noqa: E402 + +# MAINT-21: the files split from app/main.py are counted as main.py. +_SPLIT = set(main_files.files()) - {"main.py"} +_touch = sorted({"main.py" if f.name in _SPLIT else f.name for f in APP + if touches(f.read_text(encoding="utf-8"))}) print(f" {len(APP)} modules under app/; TEL-11 material is in: {_touch}") check(f"PRECONDITION: the scan found the whole package, not a subset - " f"{len(APP)} modules", len(APP) >= 25 and "control.py" in diff --git a/goodwe_controller/test_refusal.py b/goodwe_controller/test_refusal.py index a396727..04ddf02 100644 --- a/goodwe_controller/test_refusal.py +++ b/goodwe_controller/test_refusal.py @@ -142,8 +142,11 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None): **LIVE), hass, _Store(), _Pub(), stop=asyncio.Event()) c.auto = True c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"] + import os # noqa: E402 + sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402 + import main_files # noqa: E402 import app.main as m - real, real_dt = m.compute, m.datetime + real, real_dt = main_files.live.compute, main_files.live.datetime clock = [0.0] class _DT(real_dt): @@ -153,9 +156,9 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None): def now(cls, tz=None): return BASE + timedelta(seconds=clock[0]) - m.datetime = _DT + main_files.live.datetime = _DT if old: - m.compute = lambda *a, **k: real(*a, **dict(k, refused=None)) + main_files.live.compute = lambda *a, **k: real(*a, **dict(k, refused=None)) rows, trips, was = [], 0, False try: t = 0.0 @@ -174,7 +177,7 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None): hass.cmd, c.i_w, c.loop_reason, c.batt_refused)) t += TICK finally: - m.compute, m.datetime = real, real_dt + main_files.live.compute, main_files.live.datetime = real, real_dt return rows, trips, c return asyncio.run(go()) diff --git a/goodwe_controller/test_refusal_units.py b/goodwe_controller/test_refusal_units.py index 4366a7a..a6e689b 100644 --- a/goodwe_controller/test_refusal_units.py +++ b/goodwe_controller/test_refusal_units.py @@ -50,6 +50,9 @@ from app import control from app.control import PROBE_EVERY_S, PROBE_S, PROBE_W from app.main import Controller from app.arbiter import P_LOOP, P_PEAK, P_SAFETY, Claim, resolve +import os # noqa: E402 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402 +import main_files # noqa: E402 import app.main as m fails = [] @@ -177,7 +180,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None, c.auto = True if old: _off(c) - real_dt = m.datetime + real_dt = main_files.live.datetime clock = [0.0] class _DT(real_dt): @@ -185,7 +188,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None, def now(cls, tz=None): return BASE + timedelta(seconds=clock[0]) - m.datetime = _DT + main_files.live.datetime = _DT rows = [] trips = {u: 0 for u in UNITS} was = {u: False for u in UNITS} @@ -211,7 +214,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None, db=c.inverters[1].delivery_wd.tripped)) t += dt finally: - m.datetime = real_dt + main_files.live.datetime = real_dt return rows, trips, c return asyncio.run(go()) diff --git a/goodwe_controller/test_rehearse.py b/goodwe_controller/test_rehearse.py index ded905a..4387c72 100644 --- a/goodwe_controller/test_rehearse.py +++ b/goodwe_controller/test_rehearse.py @@ -73,6 +73,7 @@ def check(name, cond): print("1. the harness's own preconditions") # --------------------------------------------------------------------------- # sys.path.insert(0, str(HERE / "tools")) +import main_files # noqa: E402 import rehearse_roster as RR # noqa: E402 check("the tool refuses rather than passing when it cannot run: its four exit " @@ -210,6 +211,11 @@ def mutant_tree(edits) -> str: dest / "tools" / "predeploy_check.py") shutil.copyfile(HERE / "config.yaml", dest / "config.yaml") for rel, old, new in edits: + if rel == "app/main.py": + # MAINT-21: app/main.py stands for the files split from it. + where = [n for n in main_files.files() if (dest / "app" / n) + .read_text(encoding="utf-8").count(old) == 1] + rel = f"app/{where[0]}" if len(where) == 1 else rel path = dest / rel text = path.read_text(encoding="utf-8") hits = text.count(old) diff --git a/goodwe_controller/test_safety107.py b/goodwe_controller/test_safety107.py index e3f7516..e58c71f 100644 --- a/goodwe_controller/test_safety107.py +++ b/goodwe_controller/test_safety107.py @@ -35,6 +35,7 @@ logging.disable(logging.WARNING) import opt11_identity as rig # noqa: E402 import safety79_identity as s79 # noqa: E402 import app.main as app_main # noqa: E402 +import main_files # noqa: E402 from app import control # noqa: E402 from app.arbiter import Claim, P_ARBITRAGE # noqa: E402 from app.main import Controller # noqa: E402 @@ -61,7 +62,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True, (a writer above the loop), or None.""" log, rows, now = [], [], [0.0] real_power, real_tick = rig._Hass.power, rig.TICK - real_log, real_resolve, real_c = (Controller.log_event, app_main.resolve, + real_log, real_resolve, real_c = (Controller.log_event, main_files.live.resolve, rig.Controller) def power(self, u): @@ -108,7 +109,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True, extra.update(s79.P1, peak_cap_w=cap) db = os.path.join(tempfile.mkdtemp(prefix="s107-"), "tariff.db") rig._Hass.power, rig.TICK = power, tick - Controller.log_event, app_main.resolve = log_event, resolve + Controller.log_event, main_files.live.resolve = log_event, resolve rig.Controller = functools.partial(real_c, peak_db_path=db) try: _, c = rig.run(n, extra=extra, dur=dur, load=house, @@ -117,7 +118,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True, store=s79.store_charging(n) if maint else rig._Store()) finally: rig._Hass.power, rig.TICK = real_power, real_tick - Controller.log_event, app_main.resolve = real_log, real_resolve + Controller.log_event, main_files.live.resolve = real_log, real_resolve rig.Controller = real_c return rows, log, c diff --git a/goodwe_controller/test_safety118.py b/goodwe_controller/test_safety118.py index 30058a6..e9cb1a9 100644 --- a/goodwe_controller/test_safety118.py +++ b/goodwe_controller/test_safety118.py @@ -17,8 +17,11 @@ Mutating a module this file grades? Read MUTATION.md first (purge __pycache__). import asyncio import logging -from pathlib import Path +import os # noqa: E402 +import sys # noqa: E402 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402 +import main_files # noqa: E402 from app import main as _main from app import messages, pv, validate from app.main import Controller @@ -217,7 +220,7 @@ check(f"one read of the forecast entity per read_inputs() ({h.reads})", print("4. maintenance headroom and the charge-only clamp use watts") seen = [] -_real = _main.maintenance_charge_floor +_real = main_files.live.maintenance_charge_floor def spy(charge_w, fc, cap): @@ -226,7 +229,7 @@ def spy(charge_w, fc, cap): return out -_main.maintenance_charge_floor = spy +main_files.live.maintenance_charge_floor = spy try: def cycle(fc, **kw): c, h = make(fc, store={"phase": "charge"}, @@ -264,7 +267,7 @@ try: check("no unit: forecast unused, clamp in place, as with the option empty", clamp(st(6000)) is False) finally: - _main.maintenance_charge_floor = _real + main_files.live.maintenance_charge_floor = _real print("5. validator and strings") def notes(unit, key="peak_forecast_entity"): @@ -367,7 +370,8 @@ check("pv.unit_fault: None for a good unit and for a missing reading (has_readin pv.unit_fault(st(1, "kW")) is None and pv.unit_fault(None) is None and pv.unit_fault(st("unavailable")) is None and pv.unit_fault(st(1)) == "" and pv.unit_fault(st(1, "kWh")) == "kWh") -src = (Path(__file__).parent / "app" / "main.py").read_text(encoding="utf-8") +# MAINT-21: main.py and the files split from it (the forecast read is in ctl_inputs). +src = main_files.source() check("the size rule is gone from main.py", "abs(raw) < 50" not in src) print() diff --git a/goodwe_controller/test_safety119.py b/goodwe_controller/test_safety119.py index 069ddc6..91e1204 100644 --- a/goodwe_controller/test_safety119.py +++ b/goodwe_controller/test_safety119.py @@ -59,6 +59,7 @@ logging.disable(logging.WARNING) import opt11_identity as rig # noqa: E402 import safety79_identity as s79 # noqa: E402 import safety84_identity as s84 # noqa: E402 +import main_files # noqa: E402 import app.main as app_main # noqa: E402 from app import messages # noqa: E402 from app.main import Controller # noqa: E402 @@ -413,8 +414,8 @@ _rg = {t: never(t, hw, seed=1, delay=0) for t, hw in RIG.items()} _rg_old = {t: never(t, hw, fix=False, seed=1, delay=0) for t, hw in RIG.items()} # SAFETY-119 as merged (bee7431) at 5 s: a settle of 12 cycles, budget 8. -_rg_119 = patched(app_main, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)( - lambda: patched(app_main, "ROLLING_BOUND_CYCLES", 8)( +_rg_119 = patched(main_files.live, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)( + lambda: patched(main_files.live, "ROLLING_BOUND_CYCLES", 8)( lambda: never(5.0, 5100.0, seed=1, delay=0))) for _t in _rg: print(f" rig shape, {_t:.0f} s meter, house {RIG[_t]:.0f} W: per " @@ -464,10 +465,10 @@ check("...every command the same as without the fix; without it the false " # the rig's shape (1 s, house 5050 W, seed 1). rig.TICK = 2.0 _q25 = (run(house(25), delay=2), - patched(app_main, "ROLLING_BOUND_CYCLES", 8)( + patched(main_files.live, "ROLLING_BOUND_CYCLES", 8)( lambda: run(house(25), delay=2))) rig.TICK = 1.0 -_m14 = patched(app_main, "ROLLING_BOUND_CYCLES", 14)( +_m14 = patched(main_files.live, "ROLLING_BOUND_CYCLES", 14)( lambda: never(1.0, 5050.0, seed=1, delay=0)) _m11 = never(1.0, 5050.0, seed=1, delay=0) print(f" 2 s, quarter edge, seed 25: {notes(_q25[0][1])}, budget 8 " @@ -480,7 +481,7 @@ check("...the budget's margins: 11 is silent at 2 s seed 25 and warns every " and every_quarter(_m11[1]) and not every_quarter(_m14[1])) check("the rule is the one the sweep chose. Changing it means re-running " "the sweep in DECISIONS/SAFETY-121-bound-every-quarter.md", - app_main.HANDBACK_CYCLES == 4 and app_main.ROLLING_BOUND_CYCLES == 11 + main_files.live.HANDBACK_CYCLES == 4 and main_files.live.ROLLING_BOUND_CYCLES == 11 and app_main.ROLLING_S == 900.0 and app_main.SHORT_SPAN_S == 22.0) # A start, or a blind cycle, is not a hand-back: the loop at its rail after diff --git a/goodwe_controller/test_safety123.py b/goodwe_controller/test_safety123.py index 2708594..00827cd 100644 --- a/goodwe_controller/test_safety123.py +++ b/goodwe_controller/test_safety123.py @@ -37,6 +37,7 @@ sys.path.insert(0, os.path.join(HERE, "tools")) logging.disable(logging.WARNING) import opt11_identity as rig # noqa: E402 +import main_files # noqa: E402 import app.main as M # noqa: E402 from app import dayplan, modetrade, packmode, priceruns, solarfc # noqa: E402 from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa @@ -492,13 +493,13 @@ for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00", "2027-03-28T00:00:05+00:00"): now = datetime.fromisoformat(iso) c, hass = controller(MODES["Leftover solar"], now) - real_dt = M.datetime + real_dt = main_files.live.datetime class _DT(real_dt): @classmethod def now(cls, tz=None, _n=now): return _n if tz is None else _n.astimezone(tz) - M.datetime = _DT + main_files.live.datetime = _DT try: async def one(): await c.read_inputs(now_mono=0.0) @@ -510,7 +511,7 @@ for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00", except Slow: ok = False finally: - M.datetime = real_dt + main_files.live.datetime = real_dt check(f"a whole control cycle at {iso[:16]}Z (three forecast sensors a " "day) returns with a day plan", ok) diff --git a/goodwe_controller/test_safety125.py b/goodwe_controller/test_safety125.py index 0769255..9ca5fa2 100644 --- a/goodwe_controller/test_safety125.py +++ b/goodwe_controller/test_safety125.py @@ -48,6 +48,7 @@ logging.disable(logging.WARNING) import opt11_identity as rig # noqa: E402 import opt11p3_replay as rp # noqa: E402 import safety84_identity as s84 # noqa: E402 +import main_files # noqa: E402 import app.main as m # noqa: E402 import app.p1 as p1mod # noqa: E402 from named_absence import first, nth, present # noqa: E402 @@ -164,14 +165,14 @@ def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None, c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0 c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0 c.auto = True - real = m.datetime, homeview.datetime, p1mod.time + real = main_files.live.datetime, homeview.datetime, p1mod.time clock, simt = [t0], [0.0] class _DT(real[0]): @classmethod def now(cls, tz=None): return clock[0] if tz is None else clock[0].astimezone(tz) - m.datetime = homeview.datetime = _DT + main_files.live.datetime = homeview.datetime = _DT p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0]) real_load = _price.load_prices, _st.load_prices _price.load_prices = _st.load_prices = _no_fetch @@ -223,7 +224,7 @@ def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None, await task except asyncio.CancelledError: pass - m.datetime, homeview.datetime, p1mod.time = real + main_files.live.datetime, homeview.datetime, p1mod.time = real _price.load_prices, _st.load_prices = real_load return c c = asyncio.run(go()) diff --git a/goodwe_controller/test_safety126.py b/goodwe_controller/test_safety126.py index 5d3275c..87834da 100644 --- a/goodwe_controller/test_safety126.py +++ b/goodwe_controller/test_safety126.py @@ -43,6 +43,7 @@ sys.path.insert(0, os.path.join(HERE, "tools")) import opt11p3_replay as rp # noqa: E402 from named_absence import first, nth, present # noqa: E402 +import main_files # noqa: E402 import app.main as m # noqa: E402 import app.p1 as p1mod # noqa: E402 from app import homeview, messages, peakguard, tariff # noqa: E402 @@ -179,14 +180,14 @@ def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46, c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0 c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0 c.auto = True - real = m.datetime, homeview.datetime, p1mod.time + real = main_files.live.datetime, homeview.datetime, p1mod.time clock, simt = [t0], [0.0] class _DT(real[0]): @classmethod def now(cls, tz=None): return clock[0] if tz is None else clock[0].astimezone(tz) - m.datetime = homeview.datetime = _DT + main_files.live.datetime = homeview.datetime = _DT p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0]) real_load = _price.load_prices, _st.load_prices _price.load_prices = _st.load_prices = _no_fetch @@ -237,7 +238,7 @@ def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46, await task except asyncio.CancelledError: pass - m.datetime, homeview.datetime, p1mod.time = real + main_files.live.datetime, homeview.datetime, p1mod.time = real _price.load_prices, _st.load_prices = real_load return c out["c"] = asyncio.run(go()) diff --git a/goodwe_controller/test_safety85.py b/goodwe_controller/test_safety85.py index ed70653..9e4798f 100644 --- a/goodwe_controller/test_safety85.py +++ b/goodwe_controller/test_safety85.py @@ -38,6 +38,7 @@ logging.disable(logging.WARNING) import opt11_identity as rig # noqa: E402 import safety79_identity as s79 # noqa: E402 import safety84_identity as s84 # noqa: E402 +import main_files # noqa: E402 import app.main as app_main # noqa: E402 from app import clamp, messages # noqa: E402 from app.main import Controller # noqa: E402 @@ -202,7 +203,7 @@ check("⭐ N=2, A in its HOLD and B, which the loop drives, gives nothing: the " check("...it starts no earlier than the saturation cycles allow (3 x 5 s)", all(e.split(" ", 1)[0] >= "11:10:10" for c in (_c1, _c2) for e in c.events if "OUTPUT_CLAMP" in e)) -_quiet = patched(app_main, "delivery_short", lambda *a: False)( +_quiet = patched(main_files.live, "delivery_short", lambda *a: False)( lambda: run(1, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200))) check("non-vacuous: without the delivery rule the N=1 drain that gives " "nothing is silent (the loop's clamp never reaches its rail here)", @@ -214,7 +215,7 @@ check("a dip of two cycles is not an alarm", warned(_dip[1]) == (0, 0, 0)) print("\n3. control coming back from maintenance does not jump") END = {"maintenance_hold_min": 10} _bounds = [] -_real_compute = app_main.compute +_real_compute = main_files.live.compute def _spy_compute(*a, **kw): @@ -222,11 +223,11 @@ def _spy_compute(*a, **kw): return _real_compute(*a, **kw) -app_main.compute = _spy_compute +main_files.live.compute = _spy_compute try: _h2 = run(2, "hold", 4500.0, 20000, dur=900.0, extra=END) finally: - app_main.compute = _real_compute + main_files.live.compute = _real_compute _h2_old = no_bound(lambda: run(2, "hold", 4500.0, 20000, dur=900.0, extra=END)) _h1 = run(1, "hold", 4500.0, 20000, dur=900.0, extra=END) @@ -380,8 +381,8 @@ def lag_runs(): _lag = lagging(lag_runs) -_lag_old = patched(app_main, "SHORT_CYCLES", 1)( - lambda: patched(app_main, "SHORT_SPAN_S", 0.0)( +_lag_old = patched(main_files.live, "SHORT_CYCLES", 1)( + lambda: patched(main_files.live, "SHORT_SPAN_S", 0.0)( lambda: lagging(lag_runs))) _lag_dead = lagging(lambda: run(1, "drain", 1000.0, 20000, dur=900.0, cut=gone(600, 900))) @@ -432,7 +433,7 @@ def both_ways(asked, given, margin): _tp = taper(charge_runs) -_tp_old = patched(app_main, "delivery_short", both_ways)( +_tp_old = patched(main_files.live, "delivery_short", both_ways)( lambda: taper(charge_runs)) print(f" a charge that tapers: {[warned(c) for _r, c in _tp]}, grading " f"charges too {[warned(c) for _r, c in _tp_old]}") diff --git a/goodwe_controller/test_safety87.py b/goodwe_controller/test_safety87.py index 71c70c6..d2be66c 100644 --- a/goodwe_controller/test_safety87.py +++ b/goodwe_controller/test_safety87.py @@ -38,6 +38,7 @@ import types HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) sys.path.insert(0, os.path.join(HERE, "tools")) +import main_files # noqa: E402 logging.disable(logging.WARNING) import opt11_identity as rig # noqa: E402 @@ -703,8 +704,11 @@ check("⭐ the reserve alarm is graded on the real limit, not the aim: a " _runs_src = {} for _f in ("main.py", "homeview.py"): - with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh: - _src = _fh.read() + if _f == "main.py": + _src = main_files.source() + else: + with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh: + _src = _fh.read() for _part in _src.split('out.append({')[1:]: if '"runs": True' in _part.split("})", 1)[0]: _runs_src.setdefault(_f, []).append( diff --git a/goodwe_controller/test_safety91.py b/goodwe_controller/test_safety91.py index cd7ce12..6f477bb 100644 --- a/goodwe_controller/test_safety91.py +++ b/goodwe_controller/test_safety91.py @@ -127,16 +127,17 @@ def without_fix(fn): """release/1.0 8c438ab: held only for the peak, a drain stays pinned, the quarter's edge is not rolled over.""" real = (Controller._held_back, Controller._drain_rise) + import main_files # noqa: E402 import app.main as m - real_nq = m.next_quarter + real_nq = main_files.live.next_quarter Controller._held_back = lambda self: self._peak_reserve() Controller._drain_rise = lambda self, claims, results: None - m.next_quarter = lambda p, max_hold_s: p + main_files.live.next_quarter = lambda p, max_hold_s: p try: return fn() finally: Controller._held_back, Controller._drain_rise = real - m.next_quarter = real_nq + main_files.live.next_quarter = real_nq def cmd(r): diff --git a/goodwe_controller/test_safety94.py b/goodwe_controller/test_safety94.py index 512834c..bae8300 100644 --- a/goodwe_controller/test_safety94.py +++ b/goodwe_controller/test_safety94.py @@ -37,6 +37,7 @@ logging.disable(logging.CRITICAL) import safety94_rig as rig # noqa: E402 import app.inverter as app_inverter # noqa: E402 +import main_files # noqa: E402 import app.main as app_main # noqa: E402 from app import control # noqa: E402 from app.freshness import BOUNDS, Freshness # noqa: E402 @@ -59,19 +60,19 @@ class Before94(Freshness): def without_fix(fn): """Run `fn` with the gate's old clock and the old refusal evidence.""" - real_f, real_c = app_inverter.Freshness, app_main.compute - real_p = app_main.REFUSED_PROGRESS_W + real_f, real_c = app_inverter.Freshness, main_files.live.compute + real_p = main_files.live.REFUSED_PROGRESS_W real_l = app_main.Controller._readings_late # SAFETY-128: that tree had no late-readings gate either. app_main.Controller._readings_late = lambda self, standing_w: None app_inverter.Freshness = Before94 - app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None)) - app_main.REFUSED_PROGRESS_W = float("inf") + main_files.live.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None)) + main_files.live.REFUSED_PROGRESS_W = float("inf") try: return fn() finally: - app_inverter.Freshness, app_main.compute = real_f, real_c - app_main.REFUSED_PROGRESS_W = real_p + app_inverter.Freshness, main_files.live.compute = real_f, real_c + main_files.live.REFUSED_PROGRESS_W = real_p app_main.Controller._readings_late = real_l @@ -315,7 +316,7 @@ check("compute(): a frozen loop at SETTLE_CYCLES reading -20 W engages with " and control.compute(**args, gave_nothing=False).refused == 0) # The evidence restarts on progress towards the ask, not on jitter. -print(f" REFUSED_PROGRESS_W {app_main.REFUSED_PROGRESS_W:.0f} W, span " +print(f" REFUSED_PROGRESS_W {main_files.live.REFUSED_PROGRESS_W:.0f} W, span " f"{app_main.REFUSED_SPAN_S:.0f} s") @@ -356,7 +357,7 @@ check("a command that swaps direction every cycle is never evidence, and a " not any(flap) and 1 in away and away.index(1) == 22) check("REFUSED_PROGRESS_W is under the band's width and over the live " "readings' rest wander (25 W)", - 25.0 < app_main.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W) + 25.0 < main_files.live.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W) print() if fails: diff --git a/goodwe_controller/test_safety95.py b/goodwe_controller/test_safety95.py index acc1965..a1fdab7 100644 --- a/goodwe_controller/test_safety95.py +++ b/goodwe_controller/test_safety95.py @@ -36,6 +36,7 @@ logging.disable(logging.WARNING) import opt11_identity as rig # noqa: E402 import safety79_identity as s79 # noqa: E402 +import main_files # noqa: E402 from app import main as app_main, packmode # noqa: E402 from app.arbiter import Claim, P_MAINTENANCE, P_SAFETY # noqa: E402 from app.main import Controller # noqa: E402 @@ -149,13 +150,13 @@ def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None, for i in c.inverters: i.dev_min, i.dev_max = -5000.0, 5000.0 c.auto = True - real_dt, clock = app_main.datetime, [0.0] + real_dt, clock = main_files.live.datetime, [0.0] class _DT(real_dt): @classmethod def now(cls, tz=None): return rig.BASE + timedelta(seconds=clock[0]) - app_main.datetime = _DT + main_files.live.datetime = _DT rows = [] try: t = 0.0 @@ -182,7 +183,7 @@ def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None, else g.floor_w)) t += 2.0 finally: - app_main.datetime = real_dt + main_files.live.datetime = real_dt return rows, c return asyncio.run(go()) diff --git a/goodwe_controller/test_sma10_two_idle_states.py b/goodwe_controller/test_sma10_two_idle_states.py index b395333..d338938 100644 --- a/goodwe_controller/test_sma10_two_idle_states.py +++ b/goodwe_controller/test_sma10_two_idle_states.py @@ -32,6 +32,7 @@ from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE / "tools")) +import main_files # noqa: E402 fails = [] @@ -336,7 +337,7 @@ def run(coro): return asyncio.run(coro) -_real_time = M.time +_real_time = main_files.live.time class _FakeTime: @@ -891,9 +892,9 @@ check("the units that raised, failed or hung are NOT marked handed back, so " for i in (1, 2, 4))) fb_raise.mode = "ok" FT.t = _real_time.monotonic() + 11.0 # one heartbeat later -M.time = FT +main_files.live.time = FT run(c.send_heartbeats(60_000.0, 10.0)) -M.time = _real_time +main_files.live.time = _real_time check("...and the next idle write (a heartbeat later) retries the one that " "raised (now answers): released, and the healthy unit is not released a " "second time", @@ -955,7 +956,7 @@ for mode in ("false", "raise", "hang"): # the cadence and the log, over N failed retries on a clock we move c, h, f = failing_site("false") -M.time = FT +main_files.live.time = FT FT.t = 5000.0 _cap.clear() run(c.handle_action("auto_toggle")) # Stop: try 1 (forced) @@ -997,7 +998,7 @@ _cap.clear() run(c.handle_action("auto_toggle")) # Stop, fails again check("a NEW failure streak is logged again, once", len([1 for lv, m in _cap if lv >= logging.ERROR]) == 1) -M.time = _real_time +main_files.live.time = _real_time # shutdown with a failed release: the one 0 W still goes out inside the bound c, h, f = failing_site("hang") @@ -1098,7 +1099,7 @@ check("the two new Last-event hints exist and are plain", .endswith("trying again")) # --- 3j. the call sites ------------------------------------------------------- -src = (HERE / "app" / "main.py").read_text(encoding="utf-8") +src = main_files.source() ss = src.splitlines() @@ -1115,7 +1116,8 @@ check("main.py: the Stop button and shutdown() call command_site_release(); " "the two maintenance-abort paths keep command_site_zero() (a third call " "is the all-GoodWe branch inside command_site_release itself)", len(rel) == 2 and len(zer) == 3 - and "async def command_site_release" in "\n".join(ss[zer[1] - 30:zer[1]])) + and any("async def command_site_release" in "\n".join(ss[i - 30:i]) + for i in zer)) # ============================================================================= print() diff --git a/goodwe_controller/test_split_patches.py b/goodwe_controller/test_split_patches.py new file mode 100644 index 0000000..7bd26bb --- /dev/null +++ b/goodwe_controller/test_split_patches.py @@ -0,0 +1,181 @@ +"""MAINT-21 tripwire: a test must patch a Controller global where it is read. + +The Controller's code lives in app/main.py and in the files split from it +(app/ctl_*.py, app/rows.py). A function reads a module global from its own +file, so `app.main.NAME = fake` changes only the code that is still in +main.py. This file fails when a test or tool still does that: + + 1. it patches an `app.main` attribute that main.py's own functions no + longer read (a stale patch, silently inert), or + 2. it patches an `app.main` attribute that another file also reads (the + patch reaches only part of the code). + +The way to patch is `tools/main_files.py`: `main_files.live.NAME = fake` sets +NAME in every file that reads it and raises when none does. + +Part 3 runs the scan on spoilt sources and the patch helper on the real tree. +Read MUTATION.md for the habit this follows. `python3 test_split_patches.py` +""" + +import ast +import glob +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(HERE, "tools")) +import main_files # noqa: E402 + +fails = [] +ALIASES = {"m", "M", "main", "app_main", "_main", "_main_mod", "main_mod"} + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +def main_aliases(tree): + """Names bound to the module app.main in one file.""" + al = set() + for n in ast.walk(tree): + if isinstance(n, ast.Import): + al.update(a.asname for a in n.names + if a.name == "app.main" and a.asname) + elif isinstance(n, ast.ImportFrom) and n.module == "app": + al.update(a.asname or a.name for a in n.names if a.name == "main") + for n in ast.walk(tree): + if (isinstance(n, ast.Assign) and isinstance(n.value, ast.Name) + and n.value.id in al): + al.update(t.id for t in n.targets if isinstance(t, ast.Name)) + # `for mod in (app_main, homeview): mod.datetime = fake` + if (isinstance(n, ast.For) and isinstance(n.target, ast.Name) + and isinstance(n.iter, (ast.Tuple, ast.List)) + and any(isinstance(e, ast.Name) and e.id in al + for e in n.iter.elts)): + al.add(n.target.id) + return al + + +def flat(targets): + """The assignment targets, tuples and lists taken apart.""" + for t in targets: + if isinstance(t, (ast.Tuple, ast.List)): + yield from flat(t.elts) + else: + yield t + + +def patches(src): + """[(line, name)]: every attribute of app.main a file assigns, or hands to + setattr / a patching helper as `(app.main alias, "name", value)`.""" + tree = ast.parse(src) + al = main_aliases(tree) + out = [] + for n in ast.walk(tree): + targets = [] + if isinstance(n, ast.Assign): + targets = n.targets + elif isinstance(n, (ast.AugAssign, ast.AnnAssign)): + targets = [n.target] + for t in flat(targets): + # `app_main.NAME = x`, or `rig.m.NAME = x` where rig did + # `import app.main as m` + if isinstance(t, ast.Attribute) and ( + (isinstance(t.value, ast.Name) and t.value.id in al) + or (isinstance(t.value, ast.Attribute) + and t.value.attr in ALIASES)): + out.append((n.lineno, t.attr)) + if (isinstance(n, ast.Call) and len(n.args) >= 3 + and isinstance(n.args[0], ast.Name) and n.args[0].id in al + and isinstance(n.args[1], ast.Constant) + and isinstance(n.args[1].value, str) + and ast.unparse(n.func) != "getattr"): + out.append((n.lineno, n.args[1].value)) + return out + + +def problems(src, where="", readers=main_files.reading): + """Messages for each patch in `src` that the rule above rejects.""" + out = [] + for line, name in patches(src): + who = readers(name) + if "main" not in who: + out.append(f"{where}:{line} patches main.{name}, which main.py " + f"does not read" + (f" (read by {who})" if who else "")) + elif who != ["main"]: + out.append(f"{where}:{line} patches main.{name}, which " + f"{[w for w in who if w != 'main']} also read: use " + f"main_files.live.{name}") + return out + + +# --------------------------------------------------------------------------- # +print("1. no test and no tool patches a main global the code reads elsewhere") +sites = bad = 0 +for path in sorted(glob.glob(os.path.join(HERE, "test_*.py")) + + glob.glob(os.path.join(HERE, "tools", "*.py"))): + if os.path.basename(path) == "test_split_patches.py": + continue + with open(path, encoding="utf-8") as fh: + src = fh.read() + found = problems(src, os.path.relpath(path, HERE)) + sites += len(patches(src)) + for msg in found: + print(" " + msg) + bad += len(found) +check(f"{sites} patches of an app.main attribute found, {bad} stale or partial", + sites > 0 and bad == 0) + +# --------------------------------------------------------------------------- # +print("\n2. the scan is not blind: spoilt sources are caught") +HEAD = "import app.main as m\n" +check("main. is stale", + len(problems(HEAD + "m.no_such_global_anywhere = 1\n")) == 1) +check("main.datetime is partial (the clock is read in more than one file)" + if len(main_files.reading("datetime")) > 1 else + "main.datetime (one reader here, so nothing to catch yet)", + len(problems(HEAD + "m.datetime = 1\n")) == + (1 if len(main_files.reading("datetime")) > 1 else 0)) +check("the same through setattr, a helper and an alias", + len(problems(HEAD + "k = m\nsetattr(k, 'no_such_global_anywhere', 1)\n" + "patched(m, 'no_such_global_anywhere', 1)(f)\n")) == 2) +check("a patch through main_files.live is not flagged", + problems(HEAD + "import main_files\n" + "main_files.live.datetime = 1\n") == []) +check("a name only main reads may still be patched on main", + problems(HEAD + "m.load_options = 1\n") == []) + +# --------------------------------------------------------------------------- # +print("\n3. main_files.live sets a global in every file that reads it") +import importlib # noqa: E402 + +live = main_files.live +for name in ("datetime", "compute", "_mono"): + readers = main_files.reading(name) + mods = [importlib.import_module("app." + r) for r in readers] + before = [getattr(m, name) for m in mods] + sentinel = object() + setattr(live, name, sentinel) + try: + hit = all(getattr(m, name) is sentinel for m in mods) + read_back = getattr(live, name) is sentinel + finally: + for m, old in zip(mods, before): + setattr(m, name, old) + check(f"{name}: set in all {len(mods)} reading file(s) {readers}, and " + f"read back", bool(mods) and hit and read_back) +try: + live.no_such_global_anywhere = 1 + raised = False +except AttributeError: + raised = True +check("a name no file reads raises instead of patching nothing", raised) + +print() +if fails: + print(f"FAILED: {len(fails)}") + sys.exit(1) +print("all ok") diff --git a/goodwe_controller/test_static_tariff.py b/goodwe_controller/test_static_tariff.py index 2537ac3..378a73d 100644 --- a/goodwe_controller/test_static_tariff.py +++ b/goodwe_controller/test_static_tariff.py @@ -81,6 +81,8 @@ from app.static_tariff import ( from app.validate import validate_startup_config HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE / "tools")) +import main_files # noqa: E402 UTC = timezone.utc # PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every @@ -893,7 +895,7 @@ check("the new module holds no option-defaults dict of its own, so this ticket " check("...and it reads the option through price.price_option(), the framework's " "single read path, rather than through opts.get(key, )", "price_option(opts" in _SRC and 'opts.get("static_profile"' not in _SRC) -_MAIN = (HERE / "app" / "main.py").read_text(encoding="utf-8") +_MAIN = main_files.source() check("...and main.py grew no `o.get(\"static_profile\", )` fallback " "either - it calls static_status(o, now), and the option is read once, " "inside the framework", diff --git a/goodwe_controller/test_tariff24.py b/goodwe_controller/test_tariff24.py index f5b74a4..abf6110 100644 --- a/goodwe_controller/test_tariff24.py +++ b/goodwe_controller/test_tariff24.py @@ -40,6 +40,7 @@ logging.disable(logging.WARNING) import opt11p3_replay as rp # noqa: E402 from named_absence import first, nth, present # noqa: E402 +import main_files # noqa: E402 import app.main as m # noqa: E402 from app import homeview, modetrade, packmode # noqa: E402 from app import price as _price, static_tariff as _st # noqa: E402 @@ -196,7 +197,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE, for i in c.inverters: i.dev_min, i.dev_max = -5000.0, 5000.0 c.auto = True - real_dt, real_hv = m.datetime, homeview.datetime + real_dt, real_hv = main_files.live.datetime, homeview.datetime clock = [start] class _DT(real_dt): @@ -204,7 +205,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE, def now(cls, tz=None): return clock[0] if tz is None else clock[0].astimezone(tz) - m.datetime = homeview.datetime = _DT + main_files.live.datetime = homeview.datetime = _DT _real_load = _price.load_prices, _st.load_prices _price.load_prices = _st.load_prices = _no_fetch started = task_started(o) @@ -246,7 +247,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE, await task except asyncio.CancelledError: pass - m.datetime, homeview.datetime = real_dt, real_hv + main_files.live.datetime, homeview.datetime = real_dt, real_hv _price.load_prices, _st.load_prices = _real_load return c, started diff --git a/goodwe_controller/test_tariff24_weather.py b/goodwe_controller/test_tariff24_weather.py index 5d69662..10c6322 100644 --- a/goodwe_controller/test_tariff24_weather.py +++ b/goodwe_controller/test_tariff24_weather.py @@ -47,6 +47,7 @@ logging.disable(logging.WARNING) import opt11p3_replay as rp # noqa: E402 from named_absence import first, nth, present # noqa: E402 +import main_files # noqa: E402 import app.main as m # noqa: E402 from app import arbitrage, homeview, modetrade, solarfc # noqa: E402 from app.main import Controller # noqa: E402 @@ -175,7 +176,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, # forecast IS lost, so it runs with a hold that never covers # (test_opt27.py tests the cover). c.fc_hold = _NoHold() - real_dt, real_hv = m.datetime, homeview.datetime + real_dt, real_hv = main_files.live.datetime, homeview.datetime clock = [t0] class _DT(real_dt): @@ -183,7 +184,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, def now(cls, tz=None): return clock[0] if tz is None else clock[0].astimezone(tz) - m.datetime = homeview.datetime = _DT + main_files.live.datetime = homeview.datetime = _DT pts = None if points is None else points task = None if pts is None: @@ -248,7 +249,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, await task except asyncio.CancelledError: pass - m.datetime, homeview.datetime = real_dt, real_hv + main_files.live.datetime, homeview.datetime = real_dt, real_hv return c return asyncio.run(go()), rows diff --git a/goodwe_controller/test_ux54_dst.py b/goodwe_controller/test_ux54_dst.py index 850873b..288ce97 100644 --- a/goodwe_controller/test_ux54_dst.py +++ b/goodwe_controller/test_ux54_dst.py @@ -36,6 +36,7 @@ logging.disable(logging.WARNING) import ux54_scenarios as S # noqa: E402 import app.main as M # noqa: E402 +import main_files # noqa: E402 from app import arbitrage, dayplan, dayview, homeview, solarfc # noqa: E402 from app import tariff # noqa: E402 from app.daycurve import SolarToday # noqa: E402 @@ -90,7 +91,8 @@ class FakeDT(datetime): return NOW[0].astimezone(tz) if tz else NOW[0].replace(tzinfo=None) -for _mod in (M, homeview, dayview): # what reads the wall clock +main_files.live.datetime = FakeDT # the Controller's clock +for _mod in (homeview, dayview): # the other readers of the clock _mod.datetime = FakeDT TZ_MODULES = (tariff, dayplan, homeview, solarfc) diff --git a/goodwe_controller/test_validate.py b/goodwe_controller/test_validate.py index ea9c5df..6cfff7c 100644 --- a/goodwe_controller/test_validate.py +++ b/goodwe_controller/test_validate.py @@ -1028,6 +1028,11 @@ check("...and the drain SET is not clamped either, so the twin key the card " _srcs = {name: open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app", name), encoding="utf-8").read() for name in ("main.py", "validate.py", "maintenance.py")} +# MAINT-21: main.py stands for itself and the files split from it. +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), + "tools")) +import main_files # noqa: E402 +_srcs["main.py"] = main_files.source() _literals = {name: re.findall( r'"(maintenance_[a-z_]+)"\s*,\s*(-?\d+(?:\.\d+)?)\s*[,)]', src) for name, src in _srcs.items()} diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 7389bf3..0d9e172 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -1161,9 +1161,12 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS " # SAFETY-34: a Core-ONLY restart. The add-on process lives on (measured on # TEST-23), HA's memory is wiped, so the SAME process must re-assert the card. +import os # noqa: E402 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402 +import main_files # noqa: E402 import app.main as _main_mod # noqa: E402 -_real_mono, _clock = _main_mod._mono, [_main_mod._mono()] -_main_mod._mono = lambda: _clock[0] +_real_mono, _clock = main_files.live._mono, [main_files.live._mono()] +main_files.live._mono = lambda: _clock[0] try: _h3 = _CircHass(a_w=0.0, b_w=0.0) _b.hass = _h3 @@ -1173,7 +1176,7 @@ try: asyncio.run(_b.check_circulating()) _late = [c for c in _h3.calls if c[0] == "persistent_notification"] finally: - _main_mod._mono = _real_mono + main_files.live._mono = _real_mono check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise - no " "storm on the event bus", len(_early) == 0) check("SAFETY-34: a Core-only restart wipes the card and the add-on process " diff --git a/goodwe_controller/tools/main_files.py b/goodwe_controller/tools/main_files.py new file mode 100644 index 0000000..4ef7056 --- /dev/null +++ b/goodwe_controller/tools/main_files.py @@ -0,0 +1,98 @@ +"""MAINT-21: the Controller lives in app/main.py and the files split from it. + +A test that reads `app/main.py` for a call, a comment or a def reads +`source()` instead; a test that replaces a global that `main` used to read +calls `patch_global()`, which sets it in each file whose code reads it. A name +no file reads is an error: a stale patch cannot pass unnoticed. +""" +import ast +import glob +import importlib +import os +import symtable + +APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "app") + + +def files(): + """main.py, rows.py and ctl_*.py, in that order, as bare file names.""" + split = ["rows.py"] + sorted(os.path.basename(p) + for p in glob.glob(os.path.join(APP, "ctl_*.py"))) + return ["main.py"] + [f for f in split + if os.path.exists(os.path.join(APP, f))] + + +def text(name): + with open(os.path.join(APP, name), encoding="utf-8") as fh: + return fh.read() + + +def source(): + """All the files' text, one after the other.""" + return "\n".join(text(f) for f in files()) + + +def tree(src=None): + """`ast.parse(source())` with every `*Mixin` class folded into + `Controller`, so `Controller` has all its methods. Positions refer to + `source()`.""" + src = source() if src is None else src + mod = ast.parse(src) + ctl = next(n for n in mod.body + if isinstance(n, ast.ClassDef) and n.name == "Controller") + keep = [] + for n in mod.body: + if isinstance(n, ast.ClassDef) and n.name.endswith("Mixin"): + ctl.body.extend(n.body) + else: + keep.append(n) + mod.body = keep + return mod + + +def _function_reads(table, acc): + for s in table.get_symbols(): + if (table.get_type() == "function" and s.is_global() + and s.is_referenced()): + acc.add(s.get_name()) + for child in table.get_children(): + _function_reads(child, acc) + + +def reading(name): + """The bare module names (`main`, `ctl_cycle` ...) whose functions read + `name` as a global.""" + out = [] + for f in files(): + acc = set() + _function_reads(symtable.symtable(text(f), f, "exec"), acc) + if name in acc: + out.append(f[:-3]) + return out + + +class _Live: + """The globals the Controller's code reads, wherever they are read. + + `live.NAME = value` sets NAME in every file whose functions read it; + `live.NAME` reads it. A name no file reads is an AttributeError, so a + patch that changes nothing cannot pass unnoticed. + """ + + def __getattr__(self, name): + mods = reading(name) + if not mods: + raise AttributeError(f"no file under app/ reads the global " + f"{name!r}") + return getattr(importlib.import_module("app." + mods[0]), name) + + def __setattr__(self, name, value): + mods = reading(name) + if not mods: + raise AttributeError(f"no file under app/ reads the global " + f"{name!r}: a patch of it changes nothing") + for stem in mods: + setattr(importlib.import_module("app." + stem), name, value) + + +live = _Live() diff --git a/goodwe_controller/tools/maint21_same_ast.py b/goodwe_controller/tools/maint21_same_ast.py new file mode 100644 index 0000000..7564a42 --- /dev/null +++ b/goodwe_controller/tools/maint21_same_ast.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""MAINT-21: prove that splitting app/main.py moved code and changed none. + + python3 goodwe_controller/tools/maint21_same_ast.py [BASE_REF] [--docstrings] + +Every module-level def, class and assignment of app/main.py at BASE_REF +(default release/1.0), and every method of its `Controller`, is looked up by +qualified name in the working tree: app/main.py, app/rows.py and the mixin +classes of app/ctl_*.py (a mixin's methods count as `Controller.`). +Each must be found exactly once with an identical `ast.dump()`, docstrings +removed unless --docstrings. Allowed extras: the `Controller` class header, +`_LOG = logging.getLogger("goodwe")` in a module that logs, and import lines. +Prints one line per file and `OK` or the differences. The diff against +BASE_REF may touch only goodwe_controller/app, DECISIONS, test_*.py and +tools. Run from the repository root on a clean checkout (untracked files are +read, but the diff check only sees tracked ones). Exit 1 on any difference. +""" +import ast +import collections +import glob +import os +import re +import subprocess +import sys + +APP = "goodwe_controller/app" +MIXIN = re.compile(r"^[A-Z][A-Za-z]*Mixin$") + + +def git(*a): + return subprocess.run(["git", *a], check=True, capture_output=True, + text=True).stdout + + +def bare(n, docstrings): + if not docstrings: + for x in ast.walk(n): + if isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef, + ast.ClassDef)) and x.body: + e = x.body[0] + if (isinstance(e, ast.Expr) and isinstance(e.value, ast.Constant) + and isinstance(e.value.value, str)): + x.body = x.body[1:] or [ast.Pass()] + return ast.dump(n) + + +def key_of(n): + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + return n.name + if isinstance(n, ast.Assign): + return "=" + ",".join(ast.unparse(t) for t in n.targets) + if isinstance(n, ast.AnnAssign): + return "=" + ast.unparse(n.target) + return "?" + ast.unparse(n)[:60] + + +def units(src, docstrings, mixins): + """{qualified name: [dump, ...]} for one file's text.""" + out = collections.defaultdict(list) + tree = ast.parse(src) + for n in tree.body: + if isinstance(n, (ast.Import, ast.ImportFrom)): + continue + if (isinstance(n, ast.Expr) and isinstance(n.value, ast.Constant) + and isinstance(n.value.value, str)): + continue + if isinstance(n, ast.ClassDef) and ( + n.name == "Controller" or (mixins and MIXIN.match(n.name))): + for m in n.body: + out["Controller." + key_of(m)].append(bare(m, docstrings)) + continue + out[key_of(n)].append(bare(n, docstrings)) + return out + + +def main(argv): + docstrings = "--docstrings" in argv + argv = [a for a in argv if not a.startswith("--")] + base = argv[0] if argv else "release/1.0" + bad = 0 + print(f"base {base} = {git('rev-parse', base).strip()}") + for f in git("diff", "--name-only", base).split(): + if not re.match(r"^goodwe_controller/(app/[^/]+|DECISIONS/[^/]+|" + r"test_[^/]+\.py|tools/[^/]+)$", f): + print(f"FAIL: diff touches {f}") + bad += 1 + old = units(git("show", f"{base}:{APP}/main.py"), docstrings, False) + files = ["main.py", "rows.py"] + sorted( + os.path.basename(p) for p in glob.glob(f"{APP}/ctl_*.py")) + where = collections.defaultdict(list) # key -> [(file, dump)] + for fn in files: + p = f"{APP}/{fn}" + if not os.path.exists(p): + continue + for k, dumps in units(open(p, encoding="utf-8").read(), docstrings, + True).items(): + for d in dumps: + where[k].append((fn, d)) + found = collections.Counter() + problems = collections.defaultdict(list) + for k, dumps in old.items(): + if k == "Controller": + continue + got = where.get(k, []) + if k == "=_LOG": # one per module that logs, all the same + got = got[:1] * len(dumps) if all( + d == dumps[0] for _, d in got) else [] + if len(got) != len(dumps): + problems["?"].append(f"{k}: {len(dumps)} in base, " + f"{len(got)} in head") + continue + for (fn, d), want in zip(got, dumps): + if d != want: + problems[fn].append(f"{k}: AST differs") + else: + found[fn] += 1 + for k, got in where.items(): + if k not in old and k != "=_LOG": + for fn, _ in got: + problems[fn].append(f"{k}: not in base") + for fn in files: + if not os.path.exists(f"{APP}/{fn}"): + continue + if problems.get(fn): + print(f"DIFFERS {fn}") + for line in problems[fn]: + print(" ", line) + bad += len(problems[fn]) + else: + print(f"OK {fn} {found[fn]} units identical") + for line in problems.get("?", []): + print("DIFFERS ", line) + bad += 1 + total = sum(found.values()) + print(f"base units {sum(len(v) for k, v in old.items() if k != 'Controller')}" + f", identical in head {total}") + print("OK: code moved, none changed" if not bad else f"FAIL: {bad} problem(s)") + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/goodwe_controller/tools/opt11_identity.py b/goodwe_controller/tools/opt11_identity.py index f775816..4bf29be 100644 --- a/goodwe_controller/tools/opt11_identity.py +++ b/goodwe_controller/tools/opt11_identity.py @@ -26,6 +26,7 @@ from datetime import datetime, timedelta, timezone sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import main_files # noqa: E402 import app.main as m # noqa: E402 from app.main import Controller # noqa: E402 @@ -157,7 +158,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house, for i in c.inverters: i.dev_min, i.dev_max = -5000.0, 5000.0 c.auto = True - real_dt = m.datetime + real_dt = main_files.live.datetime clock = [0.0] class _DT(real_dt): @@ -165,7 +166,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house, def now(cls, tz=None): return BASE + timedelta(seconds=clock[0]) - m.datetime = _DT + main_files.live.datetime = _DT rows = [] try: t = 0.0 @@ -186,7 +187,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house, on_cycle(c, t) t += TICK finally: - m.datetime = real_dt + main_files.live.datetime = real_dt return rows, c return asyncio.run(go()) diff --git a/goodwe_controller/tools/opt11p3_replay.py b/goodwe_controller/tools/opt11p3_replay.py index a9eac56..589c22f 100644 --- a/goodwe_controller/tools/opt11p3_replay.py +++ b/goodwe_controller/tools/opt11p3_replay.py @@ -41,6 +41,7 @@ from datetime import datetime, timedelta, timezone HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, HERE) +import main_files # noqa: E402 import app.main as m # noqa: E402 from app.main import Controller # noqa: E402 from app.p1 import make_sample, split_signed # noqa: E402 @@ -348,7 +349,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2, if patch is not None: patch(c) c.auto = True - real_dt = m.datetime + real_dt = main_files.live.datetime clock = [start] class _DT(real_dt): @@ -356,7 +357,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2, def now(cls, tz=None): return clock[0] if tz is None else clock[0].astimezone(tz) - m.datetime = _DT + main_files.live.datetime = _DT plan_hour = None try: t = 0.0 @@ -416,7 +417,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2, check(c, hass, now, pr) t += tick finally: - m.datetime = real_dt + main_files.live.datetime = real_dt return c c = asyncio.run(go()) return stats, c diff --git a/goodwe_controller/tools/safety103_plant.py b/goodwe_controller/tools/safety103_plant.py index 122f939..544b0df 100644 --- a/goodwe_controller/tools/safety103_plant.py +++ b/goodwe_controller/tools/safety103_plant.py @@ -22,6 +22,7 @@ sys.path.insert(0, HERE) sys.path.insert(0, os.path.join(HERE, "tools")) import opt11_identity as rig # noqa: E402 +import main_files # noqa: E402 from app import main as app_main, packmode # noqa: E402 from app.main import Controller # noqa: E402 from app.store import unit_key # noqa: E402 @@ -127,13 +128,13 @@ def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0, for i in c.inverters: i.dev_min, i.dev_max = -5000.0, 5000.0 c.auto = True - real_dt, clock = app_main.datetime, [0.0] + real_dt, clock = main_files.live.datetime, [0.0] class _DT(real_dt): @classmethod def now(cls, tz=None): return rig.BASE + timedelta(seconds=clock[0]) - app_main.datetime = _DT + main_files.live.datetime = _DT rows = [] try: t = 0.0 @@ -161,7 +162,7 @@ def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0, reason=c.loop_reason)) t += tick finally: - app_main.datetime = real_dt + main_files.live.datetime = real_dt return rows, c return asyncio.run(go()) diff --git a/goodwe_controller/tools/safety128_rig.py b/goodwe_controller/tools/safety128_rig.py index 3d97eec..1e8898d 100644 --- a/goodwe_controller/tools/safety128_rig.py +++ b/goodwe_controller/tools/safety128_rig.py @@ -33,6 +33,7 @@ from datetime import datetime, timedelta, timezone sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import main_files # noqa: E402 import app.main as m # noqa: E402 from app.main import Controller # noqa: E402 from app.p1 import make_sample, split_signed # noqa: E402 @@ -222,7 +223,7 @@ class Rig: i.dev_min, i.dev_max = -MAXW[i.name[-1]], MAXW[i.name[-1]] c.auto = True self.c = c - real_dt = m.datetime + real_dt = main_files.live.datetime clock = [self.start] class _DT(real_dt): @@ -230,7 +231,7 @@ class Rig: def now(cls, tz=None): return clock[0] if tz is None else clock[0].astimezone(tz) - m.datetime = _DT + main_files.live.datetime = _DT floor = float(self.o.get("cycle_floor_s", 15)) last_grid, last_cycle, last_n = object(), None, None try: @@ -260,7 +261,7 @@ class Rig: sample(self, c, t) t += 1.0 finally: - m.datetime = real_dt + main_files.live.datetime = real_dt return c return asyncio.run(go()) diff --git a/goodwe_controller/tools/safety78_identity.py b/goodwe_controller/tools/safety78_identity.py index 06af703..8815a2f 100644 --- a/goodwe_controller/tools/safety78_identity.py +++ b/goodwe_controller/tools/safety78_identity.py @@ -33,6 +33,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import opt11_identity as rig # noqa: E402 import opt11p3_identity as p3 # noqa: E402 import safety79_identity as s79 # noqa: E402 +import main_files # noqa: E402 import app.main as m # noqa: E402 from app import packmode # noqa: E402 from app.store import unit_key # noqa: E402 @@ -76,7 +77,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None): if modes: extra.update(p3.ARB, battery_modes_enabled=True) seen, extra_rows = [], [] - real_resolve = m.resolve + real_resolve = main_files.live.resolve def spy(claims, **kw): seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi, @@ -96,7 +97,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None): db = os.path.join(tempfile.mkdtemp(prefix="s78-"), "tariff.db") real = rig.Controller rig.Controller = functools.partial(real, peak_db_path=db) - m.resolve = spy + main_files.live.resolve = spy try: rows, c = rig.run(n, extra=extra, dur=dur, load=load, soc=s79.soc, cut=lambda u, t: False, @@ -105,7 +106,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None): store=store_cheap(n) if modes else None) finally: rig.Controller = real - m.resolve = real_resolve + main_files.live.resolve = real_resolve return [r + (x,) for r, x in zip(rows, extra_rows)], c diff --git a/goodwe_controller/tools/safety81_sim.py b/goodwe_controller/tools/safety81_sim.py index a0accfc..c3dcc12 100644 --- a/goodwe_controller/tools/safety81_sim.py +++ b/goodwe_controller/tools/safety81_sim.py @@ -29,6 +29,7 @@ from datetime import timedelta sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import opt11_identity as rig # noqa: E402 +import main_files # noqa: E402 import app.main as m # noqa: E402 from app import packmode # noqa: E402 from app.main import Controller # noqa: E402 @@ -140,13 +141,13 @@ def run(load, *, margin=50, dur=2700.0, rating=2000.0, cap=3500.0, for i in c.inverters: i.dev_min, i.dev_max = -5000.0, 5000.0 c.auto = True - real_dt, clock = m.datetime, [0.0] + real_dt, clock = main_files.live.datetime, [0.0] class _DT(real_dt): @classmethod def now(cls, tz=None): return rig.BASE + timedelta(seconds=clock[0]) - m.datetime = _DT + main_files.live.datetime = _DT rows, late = [], random.Random(8100) errors = [] @@ -173,7 +174,7 @@ def run(load, *, margin=50, dur=2700.0, rating=2000.0, cap=3500.0, rows.append((t, c.target, round(plant.hist[-1][1], 6))) t += TICK finally: - m.datetime = real_dt + main_files.live.datetime = real_dt logging.getLogger("goodwe").removeHandler(h) return plant, c, rows, errors plant, c, rows, errors = asyncio.run(go()) diff --git a/goodwe_controller/tools/safety84_identity.py b/goodwe_controller/tools/safety84_identity.py index 87730c0..ff1675c 100644 --- a/goodwe_controller/tools/safety84_identity.py +++ b/goodwe_controller/tools/safety84_identity.py @@ -23,6 +23,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import opt11_identity as rig # noqa: E402 import safety79_identity as s79 # noqa: E402 +import main_files # noqa: E402 import app.main as m # noqa: E402 from app import tariff # noqa: E402 @@ -71,7 +72,7 @@ def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None, """Rows (opt11_identity's fields plus every claim, the guard and the phases) and the controller. `month_peak` banks that peak this month.""" seen, extra_rows = [], [] - real_resolve = m.resolve + real_resolve = main_files.live.resolve def spy(claims, **kw): seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi, @@ -93,7 +94,7 @@ def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None, "2026-09-02T10:00:00+00:00")).connection.commit() real = rig.Controller rig.Controller = functools.partial(real, peak_db_path=db) - m.resolve = spy + main_files.live.resolve = spy try: rows, c = rig.run(n, extra=dict(s79.P1, **OPT, **(extra or {})), dur=dur, load=load, soc=soc or s79.soc, @@ -102,7 +103,7 @@ def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None, store=store or s79.store_charging(n)) finally: rig.Controller = real - m.resolve = real_resolve + main_files.live.resolve = real_resolve return [r + (x,) for r, x in zip(rows, extra_rows)], c diff --git a/goodwe_controller/tools/safety94_rig.py b/goodwe_controller/tools/safety94_rig.py index f832c5e..9e4bc65 100644 --- a/goodwe_controller/tools/safety94_rig.py +++ b/goodwe_controller/tools/safety94_rig.py @@ -25,6 +25,7 @@ from datetime import datetime, timedelta, timezone sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import main_files # noqa: E402 import app.main as m # noqa: E402 from app.main import Controller # noqa: E402 @@ -169,7 +170,7 @@ def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None, for i in c.inverters: i.dev_min, i.dev_max = -5000.0, 5000.0 c.auto = True - real_dt = m.datetime + real_dt = main_files.live.datetime clock = [0.0] class _DT(real_dt): @@ -177,7 +178,7 @@ def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None, def now(cls, tz=None): return BASE + timedelta(seconds=clock[0]) - m.datetime = _DT + main_files.live.datetime = _DT out = {"stale": [], "refused": [], "rows": [], "c": c} was = {i.name: False for i in c.inverters} was_ref = {i.name: 0 for i in c.inverters} @@ -213,7 +214,7 @@ def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None, dict(plant.reg), [mm.phase for mm in c.maints])) t += 1.0 finally: - m.datetime = real_dt + main_files.live.datetime = real_dt return out return asyncio.run(go())