"""GoodWe RS485 Controller add-on - entry point and orchestration. It reads the house's net grid power from Home Assistant, decides how hard the battery should charge or discharge to hold that at zero, and writes the figure to an ESP32 that puts it on the inverter's RS485 EMS port. Monthly it runs a full low->full cycle so the BMS can balance cells and recalibrate. THE ONE FACT BEHIND EVERY FAILSAFE: the inverter holds the last command it understood FOREVER (measured: 5 kW of discharge held for 113 s after a controller died). So when in doubt this process stops writing and the hardware takes the battery to zero. Never "hold the last value to be safe". layer 1 the ESP32 watchdog: no refresh for ~30 s -> 0 W, and it KEEPS WRITING it. This add-on's heartbeat feeds it. layer 2 wind-down before a firmware update, in the ESP32. 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.""" import asyncio import contextlib import dataclasses import functools import json import logging import math import os import signal import time from datetime import datetime, timedelta, timezone import aiohttp from . import arbitrage from .arbiter import ( ACTUATOR_SITE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_MODE, P_PEAK, P_SAFETY, SET, Claim, Resolution, actuator_for, pre_resolve, 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 .control import Tuning, compute, maintenance_charge_floor, peak_at_risk from .delivery import ( DELIVERY_OPTION_DEFAULTS, DeliveryConfig, UnitDelivery, account as delivery_account, delinquent, ) from .circulation import ( CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog, circulating_w, opposing, ) from .clamp import ( REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, delivery_short, is_commandable, loop_clamp_note, shortfall_note, write_clamp_note, ) from . import freshness from . import lateness from . import dayview, homeview from .daycurve import SolarToday from . import handback from . import messages from .freeze import ( FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w, ) from .drivers import GoodWeEsphomeDriver from .hass import CORE_API, OUTAGE, HomeAssistant # `LINK_STALE_S` is re-exported deliberately: `live_units()` and two checks # read `app.main.LINK_STALE_S`. from .inverter import (LINK_STALE_S, UNIT_A, build_roster, roster_entries, roster_names) from .maintenance import ( CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance, FAIL_BACKOFF_DAYS, MaintResult, arbitrate as maint_arbitrate, site_zone, ) from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON, MqttPublisher, announced_units, mode_configs, optional_keys, parse_command, unit_slugs, unit_state_key) from . import peakguard from . import packshare from . import pv as pv_read from . import shadow from . import uitext from .p1 import P1Ingest, build_source, is_enabled from .price import Absence, Uncertainty, brussels, price_option, provider_configured from .price import required_spread as price_required_spread from .price import uncertainty_of as price_uncertainty_of from .price_chain import (PriceWatch, fixed_tariff_beside_service, gate_for, uses_chain) from .price_history import PriceHistory from .static_tariff import resolve_tariff, static_gated, static_status from .tariff import PeakStore, PeakTracker, next_quarter from .store import migrate_maint_keys, migrate_unit_keys from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse, validate_startup_config, validate_unit_ranges) from .watchdog import ( AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS, SignWatchdog, SignWatchdogConfig, loop_is_following, ) from . import setuponly from . import web 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 _LOG = logging.getLogger("goodwe") # `UNIT_A` lives in app/inverter.py and is re-exported here because the roster # does and four checks read `app.main.UNIT_A`. `build_roster()` is the one # function CFG-04 changes. __all__ = ["UNIT_A", "Controller", "amain", "main"] HAND_BACK_WORST_S = RELEASE_BUDGET_S + FALLBACK_BUDGET_S # SAFETY-34: a Core-only restart wipes persistent_notification without # restarting this add-on, so a latched alarm is re-asserted this often. REANNOUNCE_S = 300.0 def tariff_refresh_wanted(opts: dict) -> bool: """Does this install need the background tariff refresh? (TARIFF-10, TARIFF-24) Yes with a price feed, trading armed, or battery modes on. It decides whether prices are looked up, never whether energy moves. DECISIONS/TARIFF-10-fetching-is-not-trading.md and DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.""" return (provider_configured(opts) or arbitrage.enabled(opts) or packmode.enabled(opts)) def load_options() -> dict: try: with open(OPTIONS_PATH, "r", encoding="utf-8") as fh: return json.load(fh) except (OSError, json.JSONDecodeError) as err: _LOG.error("cannot read %s (%s) - using defaults", OPTIONS_PATH, err) 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-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 _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).""" try: until = datetime.fromisoformat(data["until"]) born = datetime.fromisoformat(data.get("born") or data["until"]) levels = {str(n): float(v) for n, v in data["levels"].items()} except (TypeError, KeyError, ValueError, AttributeError): return None if until.tzinfo is None or born.tzinfo is None or not levels or not all( math.isfinite(v) and 0.0 <= v <= 100.0 for v in levels.values()): return None if now is not None and now >= until: return 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 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-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) class Controller(InputsMixin, WritesMixin): 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", site_tz=None): # UX-15: a read-only HomeAssistant belongs to setup-only mode, which # builds no controller. if getattr(hass, "read_only", False) is True: raise ValueError("a controller cannot run on a read-only " "HomeAssistant (setup-only mode)") self.o = opts self.hass = hass 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. 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. 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. 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. 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. self.site_sign_cfg = SignWatchdogConfig( min_w=float(opts.get("site_sign_disagree_w", SIGN_OPTION_DEFAULTS["site_sign_disagree_w"])), hold_s=float(opts.get("site_sign_disagree_s", SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])), ) # SAFETY-42: the window a unit may be short of its share before it is # named. No second key: the watts half is `Tuning.saturation_w`, read # at runtime (`delivery.MIN_W_IS_SATURATION_W`, CFG-03). self.delivery_cfg = delivery_cfg = DeliveryConfig( 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. _roster_names = roster_names(opts) self.store_migration = migrate_unit_keys( store, _roster_names[0], _roster_names) self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg) # SMA-10: which units hand back decides what the page says about Stop, # 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. if roster_entries(opts): self.log_event( "roster: %d inverters from `inverters:` - %s. The flat " "soc_entity / batt_entity / batt_invert / max_w keys are NOT " "read in this shape; each entry carries its own." % (len(self.inverters), ", ".join(f"{i.name} {i.max_w:.0f} W" for i in self.inverters)), hint=messages.hint("roster", n=len(self.inverters))) 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. 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. 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)), step_w=int(opts.get("step_w", 10)), saturation_w=float(opts.get("saturation_w", 500)), saturation_cycles=int(opts.get("saturation_cycles", 3)), # 0 / unset means "follow max_w", the recommended value. Read the # note in control.py before raising it above max_w: every watt # above the rail is unwind latency. 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. # DECISIONS/MAIN-orchestration.md. self.maint_migration = migrate_maint_keys( store, self.inverters[0].name, [inv.name for inv in self.inverters]) _LOG.info("maintenance history: %s", self.maint_migration.explain()) if not self.maint_migration.ok: # Not a disarmed guard (history, not a latch), but it is the # difference between a pack recalibrated on schedule and one # silently not (12), so it is said where an operator looks. self.log_event("maintenance history did not move over cleanly: " + self.maint_migration.explain(), hint=messages.hint("maint_history")) # MAINT-05: one config, N machines: the `maintenance_*` options # describe the PROCEDURE (same on every pack); the SCHEDULE and HISTORY # are per pack, in the store. A per-entry config block would be a # second shape for CFG-04 and a fifth place for `maintenance_charge_w` # to disagree with config.yaml; two procedures would be a roster-schema # card. _maint_cfg = MaintConfig.from_options(opts) _one_pack = len(self.inverters) <= 1 self.maints = [ Maintenance( _maint_cfg, store, # `None` at a roster of one is the FLAT layout, not a missing # name (6.2 identity): it keeps a one-inverter state file # byte-identical (`maintenance.UnitHistory`, # `store.migrate_maint_keys()`). unit=None if _one_pack else inv.name, name=inv.name, # The rollback view, and only ever the first pack's: the flat # keys mean `inverters[0]` on every install that has them. mirror_flat=(not _one_pack and i == 0), tz=site_tz) # MAINT-10: the start hour's zone, from amain() for i, inv in enumerate(self.inverters)] # SAFETY-02: one watchdog per actuator, each restored from ITS OWN # namespaced store keys (a trip on B latches B, A keeps commanding). # Restored, because a latch held only in memory is cleared by the # restart an operator tries first. Graded on every start: a # disagreement with the flat value re-arms by hand and shouts (the # fail-safe direction is ON). DECISIONS/MAIN-orchestration.md. self._grade_store_migration(store) for inv in self.inverters: if inv.sign_wd.tripped: # UX-11: the plain sentence and the code; the saved reason with # figures is on the DEBUG line. _LOG.error("%s stays at 0 W after the restart because its " "power went the wrong way, so find out why before " "you start control: %s.", inv.name, messages.reason_code(inv.sign_wd.reason)) _LOG.debug("Evidence for %s: %s.", inv.name, inv.sign_wd.reason or "(reason not recorded)") # UX-11: `events` starts empty and DEBUG is off by default, so # the saved figures go on the action log too. saved = inv.sign_wd.reason or messages.NO_REASON_SAVED self.log_event(f"FAILSAFE {inv.name}, saved before the " f"restart: {saved}", hint=messages.hint("stop_restored", unit=inv.name)) # SAFETY-42 + SAFETY-34: a latch that does NOT act, unlike SAFETY-02's # and SAFETY-31's (which command 0 W). It is restored so the ALARM # survives (2026-09-02: an outage unseen for 6 h 25 m with the detector # correct); `delivery.py` argues reporting rather than acting. # `_delivery_announced` is per process (as `_circ_announced`): a # restart clears the flag and keeps the latch. self._delivery_announced = set() for inv in self.inverters: if inv.delivery_wd.tripped: _LOG.error( "%s is still marked as not delivering after the restart " "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 or "(reason not recorded)") saved = inv.delivery_wd.reason or messages.NO_REASON_SAVED self.log_event(f"NOT DELIVERING {inv.name}, saved before the " f"restart: {saved}", hint=messages.hint("not_delivering", unit=inv.name)) # SAFETY-30: the SITE half of the sign check, one per site, armed only # at N>=2: the SUM of the standing commands against P1. At a roster of # one it is constructed and never evaluated (arming it would duplicate # `inverters[0].sign_wd`: two latches, two banners, a false-positive # record spent twice); `cycle()` enforces it. Restored from the store # with FLAT keys (store.py). DECISIONS/SAFETY-30-per-unit-sign.md. self.site_sign_wd = SignWatchdog( self.site_sign_cfg, tripped=bool(store.data.get("site_sign_tripped", False)), reason=str(store.data.get("site_sign_reason", "") or "")) if self.site_sign_wd.tripped: _LOG.error( "After the restart every inverter stays at 0 W, as the total " "power went the wrong way, so find out why before starting " "control: %s.", messages.reason_code(self.site_sign_wd.reason)) _LOG.debug("Evidence for the site: %s.", self.site_sign_wd.reason or "(reason not recorded)") self.log_event("FAILSAFE site, saved before the restart: " + (self.site_sign_wd.reason or messages.NO_REASON_SAVED), hint=messages.hint("site_direction")) # The per-unit detectors are told which question they ask here as well # as in `cycle()`: `checks()` and the status page read # `inv.sign_wd.cfg` before the first cycle. self._arm_sign_watchdogs() # SAFETY-18: the frozen-but-fresh meter detector (freeze.py). NOT # restored from the store: it does not act, so it does not latch; a # restart re-derives it within one `meter_freeze_s`. self.freeze_wd = FreezeWatchdog( FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300)))) # SAFETY-31: one per site, RESTORED (unlike SAFETY-18): a trip files a # P_SAFETY SET that stops every inverter, and a cleared latch would # restart straight into the loop it stopped. SITE-level keys, not # namespaced: circulating power is defined over the roster (store.py). self.circ_wd = CirculationWatchdog( CirculationConfig( min_w=float(opts.get("circulation_w", CIRC_OPTION_DEFAULTS["circulation_w"])), hold_s=float(opts.get("circulation_hold_s", CIRC_OPTION_DEFAULTS["circulation_hold_s"]))), tripped=bool(store.data.get("circ_tripped", False)), reason=str(store.data.get("circ_reason", "") or "")) # SAFETY-34: has the durable alarm been put back in front of the # operator since this process started? `persistent_notification` does # not survive the HA restart the store does; `run_control()` re-raises # it once before the first cycle. self._circ_announced = False # SAFETY-34: when each latched alarm was last put in front of HA, by # notification_id (REANNOUNCE_S). self._announced_mono = {} if self.circ_wd.tripped: _LOG.error( "After the restart every inverter stays at 0 W, as the " "inverters worked against each other, so find out why before " "starting control: %s.", messages.reason_code(self.circ_wd.reason)) _LOG.debug("Evidence for the site: %s.", self.circ_wd.reason or "(reason not recorded)") self.log_event("CIRCULATING POWER, saved before the restart: " + (self.circ_wd.reason or messages.NO_REASON_SAVED), hint=messages.hint("circulating")) # SAFETY-03: the output clamp's alarm (clamp.py). No config of its own: # the bound is `max_w` (already validated against the device rating), # and a second key would be another place a limit must be kept in step # (CFG-03). The anti-flood cadence is a constant, like # freeze.RAIL_FRAC. self.clamp_wd = ClampWatchdog() # P1 ingestion (TEL-01). `meter_source: off` keeps the original # single-entity `meter_entity` path, so an existing install is # unchanged until it opts in. self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)), max_age_s=float(opts.get("meter_max_age_s", 30))) self.p1_enabled = is_enabled(opts) # TARIFF-01: the capacity-tariff peak, measured from the meter, banked # in SQLite, restored across a restart. An INSTRUMENT: it files no # claim and clamps nothing (SAFETY-07 reads it). No new option # (`peak_cap_w`) and no second peak entity: `peak_forecast_entity` # stays the input to maintenance_charge_floor(), and repointing it # would change what moves energy in a maintenance charge. # DECISIONS/SAFETY-07-peak-guard.md. self.peak_store = PeakStore(peak_db_path) self.peak = PeakTracker(self.peak_store, cap_w=float(opts.get("peak_cap_w", 3500))) self.peak.restore(self.p1.averager) _LOG.info("Month peak store: %s", self.peak.restore_state) # FCST-04: the 8.3 carry-forward store (published prices of the last # few Brussels days) so unprinted hours can be filled from yesterday # and FLAGGED. An instrument, not a strategy: carrying widens what the # optimiser SEES, never what it may act on. ⚠️ The windowing DOES move # energy: hours ALREADY ELAPSED leave the spread, so a past cheap night # no longer sets the day's cheapest hour; that caveat must always # travel with the guarantee (`resolve_tariff()`, DOCS.md, CHANGELOG). # DECISIONS/FCST-04-the-day-that-is-carried.md. self.price_history = PriceHistory(price_history_path) _LOG.info("Price history: %s", self.price_history.words) # SAFETY-07: the guard is stateless (peakguard.py); the only state here # is what an EDGE needs. The ceiling has no config of its own # (`peak_cap_w`); SAFETY-24's floor has exactly two keys, defaults from # `peakguard.PEAK_OPTION_DEFAULTS`: no numeric fallback in main.py that # config.yaml has not shipped. DECISIONS/SAFETY-07-peak-guard.md. self.peak_guard = None # SAFETY-79: the guard aims this far under `peak_cap_w`; the bill and # every report keep the real cap (`self.peak.cap_w`). self.peak_margin_w = float(opts.get( "peak_margin_w", peakguard.PEAK_OPTION_DEFAULTS["peak_margin_w"])) self._peak_engaged = False self._peak_saturated = False # SAFETY-81: what the alarm is graded on, beside the guard's decision. self._peak_proj = None self._peak_batt_prev = None # SAFETY-90: what a meter gap is counted at (`_fill_peak_gaps()`). self._gap_seen_w = None self._gap_w = None self._gap_block = None self._gap_filled_s = 0.0 self._gap_held_ws = 0.0 self._gap_pre = None self._unseen = None # A held sample TARIFF-01 restored from before this restart: the # averager credits it max_hold_s into the restart, at 0 W batteries. _held = self.p1.averager.held_since self._run_pre = (None if _held is None else (_held, self.p1.averager.held_net_w)) self._unseen_held_ws = 0.0 self.peak_gap_s = 0.0 self.peak_unmeasured_s = 0.0 self._peak_alarm_block = None self._peak_high_since = None self._peak_rolled = False # OPT-15 self._peak_floor_limited = False # SAFETY-87: the reserve alarm's hold, as SAFETY-81's above. self._reserve_batt_prev = None self._reserve_high_since = None # SAFETY-126: `_peak_landing()`'s last battery reading and latch. self._land_batt_prev = None self._peak_needed = False self._maint_shape_suspended = False # OPT-11: battery modes, off by default (SAFETY-10 section 1). Each # battery's mode and on/off switch live in the store, not in options, # so a change applies at once (`pack_use`, `pack_mode`). self.modes_on = packmode.enabled(opts) # The units whose mode fault is logged already (edge-logged). self._mode_faults = set() # OPT-11 P3: the price runs (`priceruns.RunPlan` or None), the key they # were built for, this tick's roles (`modetrade.Tick`), batteries that # left a run keyed by the run, and whether last tick filed a mode trade # (a run edge reseeds). self.mode_runs = None self._runs_key = None # TARIFF-24: the end of the run that was under way while a forecast # was read; until then losing the forecast does not widen the gap. self._wx_free_to = None self.mode_tick = None # UX-53: why the trade gate refused a grid charge this tick (`peak`, # `not_worth`, `not_covered`), or None when it allowed one. Home reads # it; nothing in control does. self.mode_gate = None self._run_done = {} self._mode_trade_was = False self._charge_blocked_q = None self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter self._ref_cheap = None # OPT-15: a run edge the loop holds at E: (E, step sign, until, the # last battery reading). self._edge = None # OPT-11 P4/OPT-17: the solar forecast and day plan, only with battery # modes on AND a forecast for today set; each day may name several # entities, summed (`solarfc.entities()`); `fc_entity` is the first, # None means no forecast. self.fc_entities = (solarfc.entities(opts) if self.modes_on else ()) self.fc_tomorrow_entities = solarfc.entities(opts, tomorrow=True) self.fc_entity = self.fc_entities[0] if self.fc_entities else None # OPT-17: the members that are not usable, as `solarfc.read_sum()` # gives them, for the status page and the action log. self.solar_fc_gaps = () self.solar_trust_pct = opts.get("solar_trust_pct", dayplan.DEFAULT_TRUST_PCT) self.solar_fc = None self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None else solarfc.MISSING) self._fc_read_mono = None self.fc_hold = solarfc.Hold( (getattr(store, "data", None) or {}).get(FC_HOLD_KEY)) self.day_plan = None self._plan_targets = None # OPT-34: `(until, {name: level %})` of a night charge under way. self._charge_latch = _load_latch( (getattr(store, "data", None) or {}).get(CHARGE_LATCH_KEY), datetime.now(timezone.utc)) self.house_profile = None if self.fc_entity is not None: self.house_profile = dayplan.HouseProfile( (getattr(store, "data", None) or {}).get(HOUSE_PROFILE_KEY)) # OPT-11 P2: (topic, reason) of MQTT commands refused and logged once. self._mqtt_refused = set() self._mqtt_refused_more = 0 # [(pack name, why)] for packs due and waiting their turn. Empty at a # roster of one. self._maint_deferred = [] # SAFETY-78: names of draining packs that cannot give power now, so the # "waits" line logs once. self._drain_waits = set() # SAFETY-91: last cycle's held-back batteries (`_held_back()`). self._held_was = frozenset() # SAFETY-93: the batteries placed in their maintenance charge last # cycle, and those whose charge ended since the loop last ran. self._charging_was = frozenset() self._charge_ended = frozenset() # SAFETY-95: the same for a maintenance drain. self._draining_was = frozenset() self._drain_ended = frozenset() # SAFETY-95: batteries settling after their drain ended; meanwhile # the loop's error counts every battery at its command. self._drain_tail = set() # SAFETY-103: per battery, the range its power can be in since its # reading last changed (that reading and every command since), and # the house point held while a drain's tail settles. self._read_span = {} self._drain_house = None # SAFETY-96: last cycle's commandable batteries, this cycle's start # point per battery where a reseed moved it, and batteries settling # after a step the loop did not make ({name: since, monotonic}). self._live_was = None self._loop_start = {} self._settle = {} # Set by amain() once the transport is built, so the status page can # show what only the transport knows (homewizard_local's unchanged_s). # None when P1 is off or the transport has no such counter. # (DECISIONS/MAIN-orchestration-archive.md) self.p1_source = None # ARB-03: which actuators this site has. The roster is `self.inverters` # and `units` is its names, DERIVED (a property, not a field): a # settable tuple beside a roster of one Inverter would be two actuators # and one writer. At N=1 (6.2) the grid connection and the only # inverter are ONE actuator and every claim routes to the site; a # second entry makes per-inverter claims stop being site claims (the # ALLOC-01 seam). DECISIONS/MAIN-orchestration.md. # live state self.auto = bool(store.data.get("auto", opts.get("auto_start", False))) # SAFETY-10: what the decision path reached on the last shadow cycle, # in W, or None. Kept apart from `self.target` (STANDING ON THE WIRE: # held at `shadow.WIRE_W`, re-seeded each shadow cycle): different # facts, and folding them would show an operator a number nothing is # doing. self.shadow_would_w = None # SAFETY-47: was the number above SIZED FROM THE BILLED-QUARTER METER # on the cycle it was recorded? Recorded where the decision was taken, # not re-derived at render (the page runs on its own task). False, not # None, before the first shadow cycle: it is a claim about a number. self.shadow_sized_from_meter = False # Edge state for the action log. `_shadow_was` is None until the first # cycle so an install that comes up IN shadow mode says so; coming up # LIVE is silent (`_shadow_settle()`). self._shadow_was = None self._shadow_winner = None self.target = 0.0 self.sat_count = 0 self.i_w = 0.0 # the loop's integrator, carried between cycles # SAFETY-52: the direction the battery refuses (+1 / -1 / 0), carried # like `i_w`, and when the refusal (or its last probe) started. self.batt_refused = 0 self.batt_refused_at = None self._now_mono = None self._refusal_auto = False # TEL-10: what the last cycle's loop did, for publication; seeded to # the values the "stopped or blind" branch of `cycle()` writes. self.loop_i_w = 0.0 self.loop_i_lead = 0.0 self.loop_reason = "not-running" self.loop_w = None # what the loop asked for last cycle, or None # TEL-11: the site's PV production is INSTRUMENTATION and nothing reads # it: not `inputs_ok`, the allocator, the arbiter, a claim or a # watchdog. Wiring it in would change behaviour and destroy the # measurement (`tools/tel11_pv_trace.py`). `None` is a reading that # does not exist and is seeded as one (`app/pv.py`). self.pv_w = None # Why there is no reading, in words the page prints, or on a good read # the UNIT the value came in. Never empty. self.pv_why = ("not configured" if not self.pv_roster else "not read yet") # TEL-12: every configured entity's own answer `(entity, watts, why)` # in roster order, so `checks()` can name the missing sensor. Empty # before the first read. self.pv_parts = [] # Did the grid-following loop win the LAST arbitration? SAFETY-02 asks # it about the command on the wire, so it is recorded at arbitration. # Starts False. self.following = False self.reason = "starting" self.why_words = "Starting" # UX-11: has this meter episode's WARNING been logged? See read_inputs. self._meter_quiet_logged = False # ...when the last WARNING was, and whether this episode logged one. self._meter_quiet_warned = None self._meter_quiet_loud = False # ALLOC-01: the allocation record from the last resolution, or None. # REPLACED every cycle, never merged (a record from a tick whose # envelopes moved is a cached envelope). SAFETY-28 gates the write path # on it and DEV-02 reads `shares` off it. self.allocation = None # SAFETY-28: the tick the record above belongs to, incremented ONCE PER # RESOLUTION, not per loop iteration. A cycle with NO resolution (the # "inputs missing, not yet stale" hold) does not bump it: the heartbeat # re-affirms `self.target` meanwhile, and bumping would make that # heartbeat unauthorised and drop the site to 0 W. The record is # SUPERSEDED, never expired. DECISIONS/MAIN-orchestration.md. self.alloc_tick = 0 # What the last cycle's allocator dropped, so the log line is # edge-triggered. Starts empty. self._dropped_last = () # OPT-09: the last cycle's `unmet_w`, so its line is edge-triggered (a # second-by-second line would flush the 40-row action log). 0 is "every # watt placed", the boot state. self._unmet_last = 0 # The site measurement, the only one left on this object: `soc` and # `batt` live on each `Inverter`; `grid` is P1, one meter, one # connection. self.grid = None self.peak_fc = None # SAFETY-118: the unit fault last logged for the peak forecast (None = # no fault), so the warning comes once and again only if it changes. self._peak_fc_fault = None self.cheap = False self._price_gated = False # MAINT-13: is a price gate set up # PRICE-01: which step of the price chain the prices come from (logged # once per change), the last published series (so a gap can say since # when), and the gap itself: True while a refresh has finished and no # published price covers now. Notified once per gap, taken down when it # ends. self.price_watch = PriceWatch() self._last_published = None self.price_gap = False self.price_gap_since = None self._price_gap_announced = False self._fixed_note_said = False self._gated_key = None # MAINT-13: (options, hour) of the cache # TARIFF-03: one line of prose for the status page saying which tariff # window we are in and why it is or is not cheap. self.tariff_words = "No fixed tariff set" # OPT-04: the tariff plan the BACKGROUND task last resolved, and when. # cycle() reads these and never awaits a fetch (TARIFF_REFRESH_S). # `None` means no refresh has completed: not "no prices" and certainly # not "free". self.tariff_plan = None self.tariff_at = None self.arb = None # TARIFF-11: WHEN the standing arbitrage decision was taken, or None. # `self.arb is None` does NOT mean "trading is off": it also means # cycle() returned before the step, so `self.arb` is None (startup) or # the PREVIOUS cycle's decision (input hold). See `arbitrage_row()`. self.arb_at = None # SAFETY-45/47: the billed-quarter charging headroom the arbitrage step # was handed, or None. CARRIED, not recomputed for # `arbitrage.sized_from_meter()`: `self.peak_guard` is a different # cycle's by the time the page renders. self.arb_headroom_w = None # SAFETY-125: a grid charge sized to the quarter's room this cycle. self.room_charge = False # UX-13 P1: today's prices as the Home chart has seen them. Display # only; nothing reads it back into a decision. self.home_prices = homeview.DayPrices() # UX-54: the site's measured solar per hour, for the Forecast tab. # In memory, display only; nothing reads it back into a decision. self.solar_today = SolarToday() # TARIFF-11: when the most recent cycle() STARTED. `arb_at < cycle_at` # is the derived statement "the latest cycle did not reach the # arbitrage step". self.cycle_at = None self._arb_action = None self.inputs_bad_since = None self.answer = lateness.Answer() # SAFETY-128 self._late_logged = self._late_zero = False # SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged, # note, over_rating)` (SAFETY-40 splits the last out). Site-level: the # integrator that wound is the one SITE integrator; a clamp bit a value # written to ONE device (`clamp_last` is on the Inverter). self.loop_clamped: tuple[bool, str, bool] = (False, "", False) # UX-08: the numbers of a loop-side over-rating, for the notification. self.loop_clamp_facts: tuple = () # SAFETY-85: this cycle's loop Decision, each battery's recent standing # commands, and how many cycles in a row a battery set by another owner # gave less than asked. self._loop_decision = None self._asked_hist: dict = {} self._short_run = 0 # SAFETY-119: did another owner set the wire last cycle; the window # after a hand-back (monotonic s, loop cycles in a row); monotonic s of # the bound cycles hidden in the last ROLLING_S (a stop does not clear # them). self._wire_lost = False self._handback = None self._hidden = () self.stopping = False # SAFETY-61. time.monotonic() at the end of the last run_control() pass. self.loop_pass_mono = 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 what the page shows under the buttons (UX-08).""" stamp = datetime.now(timezone.utc).strftime("%H:%M:%S") self.events.insert(0, f"{stamp} {msg}") del self.events[40:] 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: """The actuator names, DERIVED from the roster. Never assignable (see ARB-03 in `__init__`).""" return tuple(inv.name for inv in self.inverters) @property def maint_index(self) -> int: """Which roster slot holds the site's ONE maintenance slot: the pack mid-cycle if any, otherwise the first (always 0 at a roster of one).""" for i, m in enumerate(self.maints): if m.phase in POWER_MOVING: return i return 0 @property def maint(self): """The `Maintenance` the site's one maintenance slot belongs to (MAINT-05): a VIEW over `self.maints` (one FSM per roster entry). Site-level surfaces (`phase` MQTT value, banner, start/abort buttons) want the one that is recalibrating; `maintenance.arbitrate()` guarantees at most one is out of IDLE. None active gives `maints[0]`, as at a roster of one on every tick (AC 6, test_maintenance.py part 9).""" return self.maints[self.maint_index] @property def maint_unit(self): """The pack the site's one maintenance slot is currently on: whichever holds the slot, by `maint_index`, not roster order (MAINT-05 removed the flat `inverters[0]`). validate.py rule 6 moved with it: it grades `maintenance_charge_w` against the weakest cap on the roster, like rule 5.""" return self.inverters[self.maint_index] def _maint_button(self, now): """The pack an operator's one maintenance button acts on: the pack holding the slot, so "abort" stops the running cycle and "start" restarts it; when all are idle, the longest-waiting (`arbitrate()` clause 2), so forced and scheduled starts choose the same pack.""" if self.maints[self.maint_index].phase in POWER_MOVING: return self.maints[self.maint_index] return max(enumerate(self.maints), key=lambda p: (p[1].waited_days(now), -p[0]))[1] def inverter(self, name: str): """The roster entry called `name`, or None.""" for inv in self.inverters: if inv.name == name: return inv return None def _can_write(self, name: str) -> bool: """Is there a roster entry called `name` WITH a setpoint entity?""" inv = self.inverter(name) return inv is not None and inv.has_writer() def _only(self): """The sole inverter, or an error naming why the caller cannot have one. 6.2's identity (one inverter means site and unit are the same thing) is false at N>=2, so every N=1 view refuses loudly instead of answering for "the first one".""" if len(self.inverters) != 1: raise AttributeError( f"this site has {len(self.inverters)} inverters, so there is no " f"single per-device value to read off the Controller. §6.2's " f"identity holds only at N=1. Read it off the roster entry: " f"`for inv in controller.inverters: inv.`") return self.inverters[0] # Compatibility views, as properties not fields: the scalars removed by # ARB-03/DEV-02 survive as read/write views of the SOLE inverter so an N=1 # install and every replay stay byte-identical (AC 8), and they REFUSE at # N>=2 so a consumer not yet made per-instance fails on the line that reads # it. NOTHING INSIDE THIS FILE MAY READ THEM: test_inverter.py scans this # module's AST and goes red on a `self.` inside any `Controller` # method. @property def unit(self) -> str: return self._only().name @property def soc(self): return self._only().soc @soc.setter def soc(self, value): self._only().soc = value @property def batt(self): return self._only().batt @batt.setter def batt(self, value): self._only().batt = value @property def dev_min(self): return self._only().dev_min @dev_min.setter def dev_min(self, value): self._only().dev_min = value @property def dev_max(self): return self._only().dev_max @dev_max.setter def dev_max(self, value): self._only().dev_max = value @property def last_write(self): return self._only().last_write @last_write.setter def last_write(self, value): self._only().last_write = value @property def last_write_at(self): return self._only().last_write_at @property def last_write_ok(self): return self._only().last_write_ok @property def clamp_last(self): return self._only().clamp_last @property def sign_wd(self): return self._only().sign_wd # SAFETY-30: which comparison each sign detector is making def _arm_sign_watchdogs(self) -> None: """Tell every sign detector which of SAFETY-30's two questions it asks; a pure function of `len(self.inverters)`. At ONE inverter (6.2, AC 8) the unit's detector is armed against THE METER with the shipped pair and the site detector is not armed. At TWO OR MORE each unit is armed against ITS OWN measured power (`Inverter.batt`) under the per-unit pair, and the site detector against the meter under the site pair. Called at the top of EVERY `cycle()` (fixtures append an `Inverter` after construction); `arm()` touches no timer or latch. DECISIONS/MAIN-orchestration.md.""" one = len(self.inverters) <= 1 for inv in self.inverters: inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg, AGAINST_METER if one else AGAINST_OWN_POWER) self.site_sign_wd.arm(self.site_sign_cfg, AGAINST_METER) # The site aggregates, each named for the question it answers. Each picks a # DIFFERENT reduction (the wrong one drives a pack below its own floor); # the reason is written at each definition. @property def site_max_w(self) -> float: """The supervised bound on the SITE target: what the roster can deliver. SUM: each unit's own `supervised` claim still binds its share, and bounding the site loop at ONE unit's rating would waste 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 unit's own `min(max_w, |dev_min|, |dev_max|)` (the peak guard's `achievable_w` and the freeze detector's rail). Summing the per-unit conjunction makes B's rating bind B (AC 4). An unread device range 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 None, so a bound is summed only when EVERY unit has answered: one silent unit makes it unknown, not smaller.""" vals = [getattr(inv, which) for inv in self.inverters] if not vals or any(v is None for v in vals): return None return float(sum(float(v) for v in vals)) @property def site_batt_w(self): """SUM. The battery power the SITE loop regulates against. Opposite to the rest here (ALLOC-01 review): `compute()`'s anti-windup test `abs(prev_w - actual_w) > saturation_w` compares the SITE target, so one unit's power at N=2 trips the saturation freeze permanently (up to 510 W of split-dependence at gain 0.2 on the two-master rig; with the sum every split is bit-identical, test_alloc.py part 8).""" vals = [inv.batt for inv in self.inverters] if not vals or any(v is None for v in vals): return None return float(sum(float(v) for v in vals)) @property def site_circulating_w(self): """SAFETY-31. `sum(|B_i|) - |sum(B_i)|` over MEASURED per-unit power: `inv.batt`, NOT `self.allocation`. The allocation record already has sign-coherence (`REFUSE_SIGN`), so a record-derived sensor reads zero by construction and can only confirm the allocator agrees with itself; every fault this card covers is a divergence between command and hardware (TEST-19 rig: one unit wedged, record `ok` at -1250 W each, record-derived 0 W, this 2000 W; HW-01's 6 h 25 m). None when any unit has not answered, never zero (circulation.py): zero would report health from an absence. 0.0 at a roster of one (AC 5).""" return circulating_w([inv.batt for inv in self.inverters]) @property def pack_floor_soc(self): """MINIMUM. The SoC a discharge PERMISSION is graded against: the EMPTIEST pack (an average lets one be driven below its floor). None if any unit did not answer (`peakguard.decide()` reads None as floor-limited). Not the right reduction for a charge ceiling (the FULLEST pack); nothing files a per-pack ceiling yet (CFG-04/SPIKE-04).""" vals = [inv.soc for inv in self.inverters] if not vals or any(v is None for v in vals): return None return float(min(float(v) for v in vals)) @property def packs(self) -> list: """SAFETY-32. Each pack as its own THREE numbers in roster order, beside `pack_floor_soc` (the site's one verdict, the emptiest pack). EVERY unit, live or not, as `inputs_ok` does.""" 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 _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 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) 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 _LOG.info(handback.log_shutdown(handback.current()) or "shutting down - commanding 0 W on every inverter") await self.command_site_release() self.mqtt.close() # -- 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 class ControlTaskDied(RuntimeError): """The control task ended while nothing had asked it to stop.""" def mode_announcement(opts, store) -> tuple: """OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up. The config topics announced are kept in the store, so turning modes off removes exactly those (`mqtt.mode_configs()`); an install that never turned modes on gets nothing, and its store is not written. (DECISIONS/MAIN-orchestration-archive.md)""" modes = packmode.MODES if packmode.enabled(opts) else () stored = store.data.get(MODE_CONFIGS_KEY) keep, retract = mode_configs(roster_names(opts), modes, stored) if keep and keep != stored: store.set(MODE_CONFIGS_KEY, keep) return modes, retract def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event, died: list) -> None: """Done-callback on the control task: log its death now and wake amain() to wind down and exit (SAFETY-61, see its DECISIONS record).""" if stop.is_set() or controller.stopping: return # the normal wind-down, or run_control()'s refusal path exc = None if task.cancelled() else task.exception() _LOG.critical("Control stopped unexpectedly (%s), so %s and the add-on " "exits with code %d.", "cancelled" if task.cancelled() else (repr(exc) if exc else "returned"), handback.task_died_state(handback.current()), EXIT_CONTROL_DIED, exc_info=exc) died.append(exc) stop.set() DOCS_LINK_TIMEOUT_S = 3 async def docs_link_facts(session, token: str) -> dict: """`{slug, ha_version}` from the Supervisor, each None when unreadable. Asked together with a 3 s budget: a link may never hold up control start. (DECISIONS/MAIN-orchestration-archive.md)""" async def ask(url, field): """`(value, why)`: the field, or None and a short reason (UX-09).""" try: async with session.get(url, timeout=DOCS_LINK_TIMEOUT_S, headers={ "Authorization": f"Bearer {token}"}) as r: body = await r.json(content_type=None) data = body.get("data", body) if isinstance(body, dict) else {} value = data.get(field) if isinstance(data, dict) else None return value, (None if value else f"no {field} in its answer") except Exception as err: # noqa: BLE001 - a link, never a stop # A timeout's own text is empty, so the type is always named. text = str(err).strip() return None, (f"{type(err).__name__}: {text}" if text else type(err).__name__) (slug, why_slug), (ha, why_ha) = await asyncio.gather( ask("http://supervisor/addons/self/info", "slug"), ask("http://supervisor/info", "homeassistant")) out = {"slug": slug, "ha_version": ha} # ONE line, whatever happened: the link is a convenience, not a fault. if slug and ha: _LOG.info("Documentation link: add-on %s, Home Assistant %s", slug, ha) else: missed = [f"{what} ({why})" for what, why in (("add-on name", why_slug), ("Home Assistant version", why_ha)) if why] _LOG.info("Documentation link: the Supervisor gave no %s, so the " "link is guessed or left out.", " and no ".join(missed)) return out async def ha_time_zone(session, token: str) -> tuple: """MAINT-10: `(zone, why)` - Home Assistant's configured time zone, or None and a short reason. Same 3 s budget as the docs link: never holds up start.""" try: async with session.get(f"{CORE_API}/config", timeout=DOCS_LINK_TIMEOUT_S, headers={"Authorization": f"Bearer {token}"}) as r: body = await r.json(content_type=None) zone = body.get("time_zone") if isinstance(body, dict) else None return (zone, "") if zone else (None, "no time_zone in its answer") except Exception as err: # noqa: BLE001 - a fallback exists, never a stop text = str(err).strip() return None, f"{type(err).__name__}: {text}" if text else type(err).__name__ _UTC_NAMES = {"UTC", "ETC/UTC", "GMT", "ETC/GMT", "UCT", "ETC/UCT", "UNIVERSAL", "ETC/UNIVERSAL", "ZULU", "ETC/ZULU"} async def maint_site_tz(session, token: str): """MAINT-10: the zone the maintenance start hour is read in, logged once.""" zone, why = await ha_time_zone(session, token) tz, name, source, why = site_zone(zone, why, os.environ.get("TZ")) if source == "ha": _LOG.info("Maintenance start hour is local time in %s, from Home " "Assistant.", name) elif source == "tz" and name.strip().upper() in _UTC_NAMES: # The Supervisor sets TZ to its copy of HA's zone, or to UTC if it has # none: HA did not answer here, and UTC may not be the site's clock. _LOG.warning("Maintenance start hour uses UTC, because Home Assistant " "did not answer. Restart the add-on when it is up.") _LOG.debug("maintenance time zone: %s", why) elif source == "tz": _LOG.info("Maintenance start hour is local time in %s, from the " "add-on's time zone. Home Assistant gave none: %s.", name, why) else: _LOG.warning("Maintenance start hour uses UTC, because no time zone " "was found. Set the time zone in Home Assistant.") _LOG.debug("maintenance time zone: %s", why) return tz async def amain() -> None: opts = load_options() logging.basicConfig( level=getattr(logging, str(opts.get("log_level", "info")).upper(), logging.INFO), format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", ) _LOG.info("GoodWe RS485 Controller starting") from .store import Store store = Store() async with aiohttp.ClientSession() as session: hass = HomeAssistant(session) # Startup self-check: prove we can actually reach the Core API before # anything tries to control an inverter with it. A 401 here is a # permissions/token problem, not a configuration mistake. # (DECISIONS/MAIN-orchestration-archive.md) import os as _os _tok = _os.environ.get("SUPERVISOR_TOKEN", "") _LOG.info("supervisor token: %s (%d chars); env has: %s", "present" if _tok else "MISSING", len(_tok), ",".join(sorted(k for k in _os.environ if "TOKEN" in k.upper())) or "none") try: async with session.get("http://supervisor/core/api/", headers={"Authorization": f"Bearer {_tok}"}, timeout=10) as _r: _LOG.info("core api probe: HTTP %s %s", _r.status, (await _r.text())[:80]) except Exception as _e: # noqa: BLE001 _LOG.error("core api probe failed: %s", _e) # UX-09: the Details link in every notification needs this add-on's # slug and Home Assistant's version (see messages.docs_url()); both are # best effort. (DECISIONS/MAIN-orchestration-archive.md) messages.set_site(**await docs_link_facts(session, _tok)) # ⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a # configuration validate.py rejects (specs §1.5). It sits HERE, before # the broker, web server and any task; (None, None) from an ESPHome not # 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). _sp = str(opts.get("setpoint_entity", "") or "").strip() _drv = GoodWeEsphomeDriver(setpoint_entity=_sp).bind(hass) _dev_min = _dev_max = None if _sp: _dev_min, _dev_max = await _drv.limits() _LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max) _problems = validate_startup_config(opts, _dev_min, _dev_max) # UX-15: a new install names no inverter. When that is the ONLY refusal # it serves the setup wizard in setup-only mode, which builds nothing # that commands and returns when stopped; any other refusal still exits # 78. DECISIONS/UX-15-setup-only-mode.md if _problems and setuponly.applies(opts, _problems): await setuponly.serve( opts, HomeAssistant(session, read_only=True), store, port=INGRESS_PORT) return if _problems: refuse(_problems) raise ConfigRefused(_problems) # ⚠️ Status publishing must NEVER be able to stop the controller: # caught broadly and on purpose (a paho 1.x/2.x change once crashed the # add-on and took the control loop with it). # (DECISIONS/MAIN-orchestration-archive.md) broker = None try: # OPT-11 P2: each battery's switch and mode list, only while # battery modes are on (`mode_announcement()`). _modes, _retract = mode_announcement(opts, store) broker = await hass.mqtt_service() pub = MqttPublisher( broker.get("host") if broker else None, broker.get("port", 1883) if broker else 1883, broker.get("username") if broker else None, broker.get("password") if broker else None, omit=() if is_enabled(opts) else ("p1_age",), # TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE # OPTION - `Controller.publish()` gates the row on this same # call; an install with no PV roster announces exactly what it # did before. (DECISIONS/MAIN-orchestration-archive.md) include=optional_keys(opts), # TEL-07. ⚠️ THE NAMES, NOT A ROSTER: `build_roster()` here # would build a SECOND set of `Inverter`s off the Store; # `roster_names()` is the naming rule on its own. # (DECISIONS/MAIN-orchestration-archive.md) units=roster_names(opts), modes=_modes, retract=_retract, ) except Exception as err: # noqa: BLE001 _LOG.warning("MQTT unavailable (%s) - continuing without status entities", err) class _NoMqtt: enabled = False def publish(self, *a, **k): pass def close(self): pass pub = _NoMqtt() stop = asyncio.Event() controller = Controller(opts, hass, store, pub, stop=stop, site_tz=await maint_site_tz(session, _tok)) if callable(getattr(pub, "bind", None)): pub.bind(asyncio.get_running_loop(), controller.mqtt_command) runner = await web.start(controller, port=INGRESS_PORT) loop = asyncio.get_running_loop() for sig in (signal.SIGTERM, signal.SIGINT): with contextlib.suppress(NotImplementedError): loop.add_signal_handler(sig, stop.set) # SAFETY-61: the control task's death ends the process, after the # wind-down below. See DECISIONS/SAFETY-61-control-task-death.md. died: list = [] control = asyncio.create_task(controller.run_control()) control.add_done_callback(functools.partial( control_task_done, controller=controller, stop=stop, died=died)) tasks = [control] # OPT-04. The tariff refresh runs as its OWN task (`resolve_tariff()` # may fetch over HTTPS, and the tick feeds the ESP32 heartbeat). # Started for a provider, armed trading or battery modes # (`tariff_refresh_wanted()`). ⚠️ IT DOES NOT ARM ANYTHING: # `arbitrage.enabled()` in cycle() is the only gate on trading. # (DECISIONS/MAIN-orchestration-archive.md) if tariff_refresh_wanted(opts): tasks.append(asyncio.create_task(controller.run_tariff_refresh())) # P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven # off the control loop: telegrams arrive every ~5 s and the loop would # decimate them, so the 15-minute average - the capacity-tariff billing # unit - would be computed from a fraction of the data. p1_source = build_source(opts, controller.p1, session, broker) if p1_source is not None: controller.p1_source = p1_source # so checks() can report on it tasks.append(asyncio.create_task(p1_source.run())) await stop.wait() await controller.shutdown() for task in tasks: task.cancel() for task in tasks: if task is control and died: continue # already logged by control_task_done() with contextlib.suppress(asyncio.CancelledError): await task await runner.cleanup() _LOG.info("stopped") if died: raise ControlTaskDied(died[0]) # The late-arriving-device refusal from run_control(). Re-raised only # now, so the wind-down above still ran and the inverter is at 0 W. if controller.refused: raise ConfigRefused(controller.refused) def main() -> None: try: asyncio.run(amain()) except KeyboardInterrupt: pass except ConfigRefused: # ⚠️ No traceback, on purpose: the banner refuse() printed says # everything. 78 is sysexits EX_CONFIG, so a script can tell a bad # config from a bug. (DECISIONS/MAIN-orchestration-archive.md) raise SystemExit(78) except ControlTaskDied: # No second traceback: control_task_done() logged it at the death. raise SystemExit(EXIT_CONTROL_DIED) if __name__ == "__main__": main()