"""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 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"] # DEV-02: the per-write budget: every write is dispatched concurrently, each # bounded by this, so one hung `hass.call()` cannot eat a sibling's layer-1 # budget. DERIVED: `heartbeat_s` max 25 s + 4 < # `validate.FIRMWARE_INGEST_VETO_S` (30 s), graded by test_inverter.py; below # hass.py's 10 s HTTP timeout on purpose (abandon, re-issue next tick; # `last_write` moves only on an ACCEPTED write). # DECISIONS/MAIN-orchestration.md. WRITE_BUDGET_S = 4.0 # SMA-10: the bound for handing a unit back (`driver.release()`); units are # released concurrently and one still running at the bound is abandoned. # DECISIONS/SMA-10-two-idle-states.md. RELEASE_BUDGET_S = WRITE_BUDGET_S # SMA-10: the one fallback write after a failed release (default 0 W). Worst # case RELEASE_BUDGET_S + FALLBACK_BUDGET_S = 6 s, inside the Supervisor's 10 s. FALLBACK_BUDGET_S = 2.0 HAND_BACK_WORST_S = RELEASE_BUDGET_S + FALLBACK_BUDGET_S # SAFETY-137: how long a LAST write waits for a write already on its way to the # same unit; half the release budget, so a GoodWe unit's worst case is # HAND_BACK_WORST_S. DECISIONS/SAFETY-137-last-write-is-zero.md. SETTLE_BUDGET_S = RELEASE_BUDGET_S / 2 # SAFETY-34: a Core-only restart wipes persistent_notification without # restarting this add-on, so a latched alarm is re-asserted this often. REANNOUNCE_S = 300.0 # UX-11: a quiet meter logs at most one WARNING this often (on `ha_signed` a # healthy meter can go 42-97 s between changes). METER_QUIET_REPEAT_S = 600.0 _mono = time.monotonic # patched by the checks; asyncio keeps the real one # OPT-04: the background tariff refresh. `static_tariff.resolve_tariff()` may # do an HTTPS fetch, and a supplier's TLS timeout on the control tick could # starve the ESP32 heartbeat into layer 1. So the tick NEVER awaits a fetch: it # reads `self.tariff_plan`, which this task assigns. # The cadence is not about how often prices change (day-ahead publishes once, # 14-16 h CET; 30 min picks it up inside the hour with 48 fetches a day). # DECISIONS/MAIN-orchestration.md. TARIFF_REFRESH_S = 1800.0 # The age bound is a different question from the refresh: a failed refresh # still stores a plan (the 11.4 static fallback, provisional). The bound covers # the refresh task dying and a stale plan standing as current. Three missed # refreshes, for `p1.max_age_s`'s reason. TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S def tariff_refresh_wanted(opts: dict) -> bool: """Does this install need the background tariff refresh? (TARIFF-10, 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-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an # evening restart keeps the night plan's figure. FC_HOLD_KEY = "solar_fc_hold" # OPT-34: the level of a night grid charge that has started, so it does not # drop when the forecast goes (`Controller._latch_charge()`). CHARGE_LATCH_KEY = "night_charge_latch" # OPT-34: a latch outlives its last cheap run by one price slot, since the next # run is planned at the slot edge. CHARGE_LATCH_GRACE_S = 900.0 FORECAST_READ_S = 60.0 def _load_latch(data, now=None): """`(until, {name: level %}, born)` from the store, or None if it is not a well-formed latch or has ended (OPT-34).""" 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 why_raised(resolution, claims) -> bool: """F2 (OPT-11 P1 test): did the limit that bound the site target push it ABOVE what the winner asked (the peak floor asking for more power)? Then the Why line must not say "held back". False when nothing bound it.""" if not resolution.bound_by or resolution.contradiction: return False asked = next((c.value for c in sorted( (c for c in claims if c.kind == SET and c.actuator == ACTUATOR_SITE and c.source == resolution.winner), key=lambda c: c.priority, reverse=True)), None) return asked is not None and resolution.target_w > float(asked) def pack_energy_of(inv): """OPT-09. ONE roster entry as the view `packshare` consumes. One builder for two callers (`pack_energy` needs the roster, `cycle()` one entry): written twice they would drift. A module function so inverter.py need not import `packshare`.""" return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor, inv.capacity_kwh, inv.max_w) # SAFETY-73: the evidence must also span this long, not only SETTLE_CYCLES # cycles: a 1 s meter makes 12 cycles only 11 s, shorter than a healthy battery # reading's lag, and both inverters were refused after ordinary load steps. REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S # SAFETY-94: evidence restarts when the reading has moved this far toward the # ask. A refusing battery reads flat; live readings wander under 25 W at rest; # a first-order lag gains at most 0.4 tau x 100 W across the band, so the # evidence cannot span REFUSED_SPAN_S unless the lag is over 54 s. REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2 # SAFETY-85: a battery another owner set is graded against the least it was # told over the plant's settle time, so a battery still settling is not short. SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S # SAFETY-119/121: after a hand-back the loop's bound is hidden for # HANDBACK_CYCLES / SHORT_SPAN_S, and across hand-backs on at most # ROLLING_BOUND_CYCLES cycles per ROLLING_S # (DECISIONS/SAFETY-121-bound-every-quarter.md). Without a monotonic clock the # budget never refills. HANDBACK_CYCLES = 4 ROLLING_S = 900.0 ROLLING_BOUND_CYCLES = 11 def _refused_interval(sign: int) -> tuple: """SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way.""" return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf) def why_key(winner: str, claims) -> str: """The key of `messages.WHY_WINNER` for a resolution's winner. "safety" and "failsafe" stand for several causes, so their own claim reason is the key ("stopped", "inputs missing", "sign disagreement", or "unsplit" when this module refuses to split the power).""" if winner == "loop": # SAFETY-52: a loop holding a refused direction says so. return next((c.reason for c in claims if c.source == "loop" and c.reason in (_ctl.REFUSED, _ctl.PROBING)), winner) if winner in ("safety", "failsafe"): return next((c.reason for c in claims if c.source == winner and c.kind == "set"), "no claim was made" if not any( c.kind == "set" for c in claims) else "unsplit") return winner def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float, provider: bool, max_age_s: float) -> str: """The *Tariff* status row: the 30 s tick's sentence AND the provider plan (TARIFF-11): two sources, two clocks, and the row says so. The plan is stamped with its own age from `tariff_at` (not moved on a kept plan, TARIFF-12), up to TARIFF_MAX_AGE_S (5400 s); `age_s` is the caller's measurement. "No plan" is said aloud on a site that configured a provider. DECISIONS/MAIN-orchestration.md.""" # UX-08: plain words, every figure kept. The tick's sentence is "now"; the # plan carries its own age. if plan is None: if not provider: return tick_words return (f"{tick_words}. No price plan: none yet, or the last one is " f"over {max_age_s:.0f} s old") return (f"{tick_words}. Price plan from {age_s:.0f} s ago, for " f"{horizon_h:.0f} hours ahead: {plan.words}") def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now, why: str) -> str: """The *Arbitrage* status row: three states (TARIFF-11): off (not armed), armed with no decision this cycle (`why` is the loop's reason), or a decision with its age. `on` is read from the options, never inferred from `decision is None`: `cycle()` returns at the input hold before `self.arb = None`, so the old row named an option that was ON. The age is always printed, even 0. DECISIONS/MAIN-orchestration.md.""" # UX-08: plain words; the setting is named by its label. if not on: return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not " f"switched on") if decided_at is None: return (f"On, but no decision yet, because control has not got that " f"far. Control says: {why or 'no reason recorded'}") age = (now - decided_at).total_seconds() stale = cycle_at is not None and decided_at < cycle_at head = (f"On, no decision this cycle ({why or 'no reason recorded'}), " f"the last one {age:.0f} s ago: " if stale else f"On, decided {age:.0f} s ago: ") if decision is None: return (f"On, no decision this cycle ({why or 'no reason recorded'}) " f"and none before") return head + decision.explain() class Controller: 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 # helpers; SAFETY-52: a battery that refuses is asked for 0 W def _refusal_probe(self) -> bool: """True while a refused direction may try `PROBE_W` (every 15 min).""" if (not self.batt_refused or self.batt_refused_at is None or self._now_mono is None): return False held = self._now_mono - self.batt_refused_at if held >= _ctl.PROBE_EVERY_S + _ctl.PROBE_S: self.batt_refused_at = self._now_mono return False return held >= _ctl.PROBE_EVERY_S def _refusal_start(self, sign: int, evidence: str) -> None: self.batt_refused = sign self.batt_refused_at = self._now_mono self.log_event(f"BATTERY REFUSES {evidence}", hint=messages.hint("battery_refuses")) _LOG.info("The battery refuses to give power, so the add-on asks 0 W " "and tries %.0f W every %.0f minutes: BATTERY_REFUSES.", _ctl.PROBE_W, _ctl.PROBE_EVERY_S / 60) _LOG.debug("Evidence: BATTERY_REFUSES: %s.", evidence) def _refusal_end(self, why: str) -> None: if not self.batt_refused: return self.batt_refused, self.batt_refused_at = 0, None self.log_event(f"BATTERY REFUSES ENDED: {why}", hint=messages.hint("battery_gives")) _LOG.info("The battery refusal ends, control asks for power again: %s.", why) def _refusal_update(self, decision, asked_w, batt_w, probe, rejudged: int = 0) -> None: """Follow `compute()`'s carried refused direction, and say so. `rejudged`: the refusal held while another writer had the wire, so `compute()` judged it afresh against that writer's ask (SAFETY-107).""" now = decision.refused or 0 if rejudged: if now == rejudged: # Still refused against the ask on the wire: the same episode. # The probe clock restarts: a probe is the loop's ask and is # not on the wire while another writer is. self.batt_refused_at = self._now_mono return gave = (rejudged * (batt_w - self._site_idle_w()) >= _ctl.PROBE_ANSWER_W) self._refusal_end("the battery gives power" if gave else "another writer") if now and not self.batt_refused: self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery " f"gave {batt_w:+.0f} W for " f"{decision.sat_count} cycles") elif self.batt_refused and not now: gave = (self.batt_refused * (batt_w - self._site_idle_w()) >= _ctl.PROBE_ANSWER_W) self._refusal_end("the probe was answered" if gave and probe else "the battery gives power" if gave else "the grid now asks the other direction") def _refusal_from_stale(self, inv, now_mono) -> None: """A stale-reading stop while the loop is frozen on ~0 W is a refusal. Only at one inverter: with several, one empty battery must not stop the site (OPT-09 moves its share).""" t = self.tuning standing = inv.standing_w() if len(self.inverters) > 1: # SAFETY-73: at N>1 the same trip refuses THIS unit only. if ((self.auto or self.shadow) and not inv.refused and inv.ref_count >= t.saturation_cycles and inv.batt_fresh.held is not None and abs(inv.batt_fresh.held - inv.idle_w) < _ctl.REFUSED_BELOW_W and abs(standing) > _ctl.PROBE_W): self._now_mono = now_mono self._unit_refusal_start( inv, 1 if standing > 0 else -1, f"stale reading {inv.batt_fresh.held:+.0f} W while asked " f"{standing:+.0f} W") return if (len(self.inverters) == 1 and (self.auto or self.shadow) and not self.batt_refused and self.sat_count >= t.saturation_cycles and inv.batt_fresh.held is not None and abs(inv.batt_fresh.held - inv.idle_w) < _ctl.REFUSED_BELOW_W and abs(standing) > t.saturation_w): self._now_mono = now_mono self._refusal_start(1 if standing > 0 else -1, f"stale reading {inv.batt_fresh.held:+.0f} W " f"while asked {standing:+.0f} W") # SAFETY-73: SAFETY-52's decisions per inverter at N>1: a unit giving < # REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in that # direction by a P_SAFETY unit limit, so the allocator hands its share on; # probed at PROBE_W every PROBE_EVERY_S, one unit at a time. The ask is # PROBE_W, not `saturation_w`: a unit's share is often small and SAFETY-43 # would name the empty battery as not delivering. # DECISIONS/SAFETY-73-refusal-per-inverter.md. def _unit_refusal_start(self, inv, sign: int, evidence: str) -> None: inv.refused, inv.refused_at = sign, self._now_mono inv.probe_from, inv.ref_count = None, 0 self.log_event(f"BATTERY REFUSES {inv.name}: {evidence}", hint=messages.hint("unit_refuses", unit=inv.name)) _LOG.info("%s: the battery refuses power, so it is asked 0 W and the " "other inverters take over: BATTERY_REFUSES.", inv.name) _LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence) # Only a refusal to GIVE: a full battery that stops taking charge is # above its cut-out level by definition. if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None and float(inv.soc) > float(inv.bms_soc_floor)): # Owner decision 3: a refusal above the configured cut-out level # may mean that level is set wrong. One line, no safety stop. _LOG.warning("%s refuses power above its battery cut-out level, " "check its cut-out level: REFUSES_ABOVE_CUT_OUT.", inv.name) _LOG.debug("Evidence for %s: REFUSES_ABOVE_CUT_OUT: battery level " "%.0f %%, cut-out level %.0f %%.", inv.name, float(inv.soc), float(inv.bms_soc_floor)) def _unit_refusal_end(self, inv, why: str) -> None: if not inv.refused: return inv.refused, inv.refused_at, inv.probe_from = 0, None, None inv.ref_count = 0 self.log_event(f"BATTERY REFUSES ENDED {inv.name}: {why}", hint=messages.hint("unit_gives", unit=inv.name)) _LOG.info("%s: the battery refusal ends, it is asked for power again: " "%s.", inv.name, why) # The site target may stand above what was placed (the refused share # was unmet). Restart it from the watts on the wire, or the reopened # unit gets the parked remainder at once (phase A: -1059 W). placed = float(sum(i.standing_w() for i in self.inverters)) self.target = self.i_w = placed self.sat_count = 0 def _unit_probe(self, inv) -> bool: """True while `inv` may try PROBE_W. One unit probes at a time.""" now = self._now_mono if not inv.refused or inv.refused_at is None or now is None: return False if inv.probe_from is not None: if now - inv.probe_from < _ctl.PROBE_S: return True inv.probe_from, inv.refused_at = None, now return False if now - inv.refused_at < _ctl.PROBE_EVERY_S or any( i.probe_from is not None for i in self.inverters): return False inv.probe_from = now return True def _unit_refusals(self) -> None: """Engage and end each unit's refusal from its own reading.""" for inv in self.inverters: b = inv.batt if inv.refused: # SAFETY-97: given and taken are counted from the idle reading. if (b is not None and inv.refused * (b - inv.idle_w) >= _ctl.PROBE_ANSWER_W): self._unit_refusal_end( inv, "the probe was answered" if inv.probe_from is not None else "the battery gives power") continue # The other direction: the battery takes PROBE_ANSWER_W or more # the other way for SETTLE_CYCLES, counted on its own reading # so one overshoot through zero does not hand its share back. inv.ref_count = (inv.ref_count + 1 if b is not None and -inv.refused * (b - inv.idle_w) >= _ctl.PROBE_ANSWER_W else 0) if inv.ref_count >= _ctl.SETTLE_CYCLES: self._unit_refusal_end( inv, "the battery takes power the other way") continue if inv.rejected_since is not None: # SAFETY-96: its writes are refused, so its board may have gone # to 0 W on its own; that is no refusal by the battery. The # count is kept, as across a gap. continue sign = self._gave_nothing(inv) if sign: self._unit_refusal_start( inv, sign, f"asked {inv.standing_w():+.0f} W, the battery gave " f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles") def _gave_nothing(self, inv) -> int: """The refusal evidence, one cycle of it: the sign `inv` has given under REFUSED_BELOW_W while asked over PROBE_W, one way, for SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1; SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the band is centred on the unit's idle reading.""" b = inv.batt if b is None: return 0 # a gap keeps the count: the gate's trip seeds standing = inv.standing_w() sign = 1 if standing > 0 else -1 # One direction throughout: a command that swaps sign every cycle reads # ~0 through the plant's lag, which is a battery following, not # refusing (TEST-19). if (abs(standing) > _ctl.PROBE_W and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W): # SAFETY-94: a reading still moving towards the ask is a battery # catching up through its lag: start again. if (sign != inv.ref_sign or not inv.ref_count or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W): inv.ref_count, inv.ref_since, inv.ref_from = ( 0, self._now_mono, b) inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES) else: inv.ref_count = 0 inv.ref_sign = sign if inv.ref_count >= _ctl.SETTLE_CYCLES and ( inv.ref_since is None or self._now_mono is None or self._now_mono - inv.ref_since >= REFUSED_SPAN_S): return sign return 0 def _site_idle_w(self) -> float: """SAFETY-97: the site reading at idle, the sum of the units'.""" return float(sum(inv.idle_w for inv in self.inverters)) def _site_refused(self): """Owner decision 4: every unit refuses the same way, so the site law holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law.""" signs = {inv.refused for inv in self.inverters} return signs.pop() if len(signs) == 1 and 0 not in signs else None def log_event(self, msg: str, hint: str | None = None) -> None: """`msg` goes on the action log, the add-on's own technical record. `hint` is `messages.hint()`'s plain words for the same event, and is 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 _readings_late(self, standing_w): """SAFETY-128: update the answer gate; seconds the late verdict has stood, or None. DECISIONS/SAFETY-128-late-readings.md.""" a = self.answer if self._now_mono is None: return None a.observe(self._now_mono, standing_w, self.site_batt_w, grid_w=self.grid, exempt=bool(self.batt_refused) or any( i.refused or ( i.batt is not None and abs(i.standing_w()) >= lateness.ANSWER_W and abs(i.batt - i.idle_w) < lateness.ANSWER_W) for i in self.inverters), idle_w=self._site_idle_w()) if a.late_seen != self._late_logged: self._late_logged = a.late_seen if a.late_seen: self.log_event("readings late - holding", hint=messages.hint("readings_late")) _LOG.warning("Home Assistant readings arrive late, so control " "holds the batteries: READINGS_LATE.") _LOG.debug("READINGS_LATE: the readings show a power request " "%.0f s after it is sent.", a.delay_s) else: self.log_event("readings on time", hint=messages.hint( "readings_back")) _LOG.info("Home Assistant readings are on time again.") return a.late_s(self._now_mono) def _peak_reserve(self) -> frozenset: """SAFETY-87: the batteries that give nothing for the peak this cycle: under their reserve while the others carry a peak floor.""" g = self.peak_guard if g is None or g.able_w is None or not (g.floor_w or 0.0) > 0.0: return frozenset() return frozenset(g.held) def _held_back(self) -> frozenset: """SAFETY-91, N>=2: the batteries at or under their own peak reserve while another can still give (live, not refusing). One held back stays so until its SoC reads more than 1 point above its reserve. DECISIONS/SAFETY-91-held-battery-last.md""" if len(self.inverters) < 2: return frozenset() floor = float(self.o.get( "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) ok, held = peakguard.reserve_split(self.packs, floor) held = set(held) | { p.name for p in ok if p.name in self._held_was and float(p.soc) <= peakguard.pack_floor_pct(p, floor) + 1.0} live = set(self.live_units()) if not held or not any( p.name not in held and inv.name in live and not (inv.refused or 0) > 0 for p, inv in zip(self.packs, self.inverters)): self._held_was = frozenset() else: self._held_was = frozenset(held) return self._held_was def _drain_rise(self, claims, maint_results) -> None: """SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a draining battery's pin becomes a floor at its drain rate, so it rises towards its rating before the held one gives. A site stop still decides alone. DECISIONS/SAFETY-91-held-battery-last.md""" held = self._held_back() if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE and c.priority > P_PEAK for c in claims): return live = set(self.live_units()) for inv, res in zip(self.inverters, maint_results): u = self.actuator_of("maintenance", inv) if res.phase != DRAIN or not res.owns_setpoint \ or inv.name in held or u not in live: continue b = pre_resolve([c for c in claims if c.actuator == u]) p = (None if b.contradiction or b.pinned_by != "maintenance" else allocator.pin_w(b)) if p is None or not p > 0.0: continue for i, c in enumerate(claims): if c.source == "maintenance" and c.kind == SET \ and c.actuator == u: claims[i] = Claim.limit("maintenance", P_MAINTENANCE, lo=p, reason=c.reason, actuator=u) def _fill_peak_gaps(self, proj, now, reading): """SAFETY-90: `proj` with a meter gap's uncounted seconds counted at `peakguard.gap_w()` of the imports seen just before and just after it, and those before this run's first reading likewise at the import seen first (shown as not measured). DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.""" self.peak_gap_s = self.peak_unmeasured_s = 0.0 avg = self.p1.averager since = avg.held_since if proj is None or proj.block_start is None or since is None: return proj if self._unseen is None and self.p1.last is not None: self._unseen = [proj.block_start, max(0.0, ( since - proj.block_start).total_seconds() - avg.elapsed_s), since, None] seen = (None if self.grid is None or self.site_batt_w is None else float(self.grid) + max(0.0, float(self.site_batt_w))) if proj.block_start != self._gap_block: self._gap_block, self._gap_w = proj.block_start, None self._gap_filled_s = self._gap_held_ws = 0.0 cap = float(self.o.get("peak_cap_w", 3500)) ceiling = peakguard.quarter_ceiling_w(cap, reading)[0] unseen_on = (self._unseen is not None and self._unseen[0] == proj.block_start) if unseen_on and self._unseen[3] is None and seen is not None: # While the add-on was down the batteries were at 0 W, so the # seconds before its first reading count like a gap, at the # import seen first; a restored reading's held part is topped up. self._unseen[3] = peakguard.gap_w(ceiling, seen) if self._run_pre is not None: s0, g0 = self._run_pre h = (min(s0 + timedelta(seconds=avg.max_hold_s), self._unseen[2]) - max(s0, proj.block_start)).total_seconds() self._unseen_held_ws = max(0.0, h) * max( 0.0, self._unseen[3] - max(0.0, g0)) kw = dict(tail_s=(now - since).total_seconds(), max_hold_s=avg.max_hold_s, unseen_s=self._unseen[1] if unseen_on else 0.0, unseen_w=(self._unseen[3] if unseen_on and self._unseen[3] is not None else ceiling)) _p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw) if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S: self._gap_w = peakguard.gap_w(ceiling, self._gap_w, self._gap_seen_w, seen) if self._gap_pre is not None: # The averager credited the gap's first max_hold_s at the # reading before it; top that part up to gap_w as well. s0, g0 = self._gap_pre h = (min(s0 + timedelta(seconds=avg.max_hold_s), since) - max(s0, proj.block_start)).total_seconds() self._gap_held_ws += max(0.0, h) * max( 0.0, self._gap_w - max(0.0, g0)) self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s " f"of this 15-minute period, counted at " f"{self._gap_w:.0f} W", hint=messages.hint("peak_gap")) self._gap_filled_s = max(self._gap_filled_s, gap_s) if seen is not None: self._gap_seen_w = seen if self.grid is not None: self._gap_pre = (since, float(self.grid)) out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps( proj, gap_w=self._gap_w if self._gap_w is not None else ceiling, held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on else 0.0), **kw) return out def _peak_over_drain(self, claims, maint_results) -> None: """SAFETY-83: at N=1 a peak floor above the drain rate outranks it: the drain is the site SET at P_MAINTENANCE, above P_PEAK, so the floor did not bind. Refiled at P_MAINTENANCE where it asks for more; P_SAFETY still outranks. N>=2: the `drain` floor and `_drain_over_reserve()`.""" if len(self.inverters) != 1: self._drain_over_reserve(claims, maint_results) return res = maint_results[0] if res.phase != DRAIN or not res.owns_setpoint: return rate = float(res.setpoint_w or 0.0) for i, c in enumerate(claims): if c.source == peakguard.SOURCE and c.lo > rate: claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE) def _held_pin(self, claims, u): """SAFETY-87: what held unit `u` places anyway (its own pin), or None when that pin charges, so the site is never split both ways.""" b = pre_resolve([c for c in claims if c.actuator == u]) p = None if b.contradiction else allocator.pin_w(b) return None if p is not None and p < 0.0 else max(p or 0.0, 0.0) def _drain_over_reserve(self, claims, maint_results) -> None: """SAFETY-87, N>=2: while a battery is held at its peak reserve, a draining battery that may still give is raised from its drain rate towards its rating for the peak floor, after the others. A site stop still decides alone. DECISIONS/SAFETY-87-guard-uses-able-batteries.md""" reserve = self._peak_reserve() floor = max((c.lo for c in claims if c.source == peakguard.SOURCE), default=0.0) if not reserve or not floor > 0.0 or any( c.kind == SET and c.actuator == ACTUATOR_SITE and c.priority > P_PEAK for c in claims): return live = set(self.live_units()) drains, others = [], 0.0 for inv, res in zip(self.inverters, maint_results): u = self.actuator_of("maintenance", inv) if u not in live: continue if inv.name in reserve: p = self._held_pin(claims, u) if p is None: return others += p continue b = pre_resolve([c for c in claims if c.actuator == u]) if b.contradiction: continue p = allocator.pin_w(b) if p is not None and p < 0.0: return # a pack pinned to charge: never split both ways if res.phase == DRAIN and b.pinned_by == "maintenance" \ and p is not None and p > 0.0: drains.append((u, p, allocator.hi_w(b))) others += allocator.hi_w(b) if p is None else p give = floor - others for u, p, hi in drains: if give <= 0.0: break up = max(p, min(hi, p + give)) give -= up - p for i, c in enumerate(claims): if c.source == "maintenance" and c.kind == SET \ and c.actuator == u: claims[i] = dataclasses.replace(c, value=up) def _peak_over_hold(self, claims, maint_results): """SAFETY-86: a peak floor outranks a maintenance HOLD (0 W). N=1: refiled at P_MAINTENANCE as for a drain. N>=2: the holding pack's pin is raised to the floor minus what the others may place, and the floor is returned as the site `hold` ceiling. A site stop still decides alone. DECISIONS/SAFETY-86-peak-over-hold.md.""" hold = {self.actuator_of("maintenance", inv) for inv, res in zip(self.inverters, maint_results) if res.phase == HOLD and res.owns_setpoint} peak = [i for i, c in enumerate(claims) if c.source == peakguard.SOURCE and c.lo > 0.0] if not hold or not peak: return if len(self.inverters) == 1: for i in peak: claims[i] = dataclasses.replace(claims[i], priority=P_MAINTENANCE) return if any(c.kind == SET and c.actuator == ACTUATOR_SITE and c.priority > P_PEAK for c in claims): return live = set(self.live_units()) reserve = self._peak_reserve() # SAFETY-87: they give nothing others = 0.0 for inv in self.inverters: u = self.actuator_of("maintenance", inv) if u in hold or u not in live: continue if inv.name in reserve: # only its own pin, never both ways p = self._held_pin(claims, u) if p is None: return others += p continue b = pre_resolve([c for c in claims if c.actuator == u]) if b.contradiction: continue p = allocator.pin_w(b) if p is not None and p < 0.0: return # a pack pinned to charge: never split both ways others += allocator.hi_w(b) if p is None else p floor = max(claims[i].lo for i in peak) give = floor - others if give <= 0.0: return for i, c in enumerate(claims): if c.source == "maintenance" and c.kind == SET \ and c.actuator in hold: claims[i] = dataclasses.replace(c, value=give) return floor def _charge_headroom(self, floor: float, claims) -> float: """SAFETY-84: a cheap-window charge pulls no more than the quarter's room under the peak aim (`charge_room_w()`), so it yields just enough to the peak guard; at N>=2 nothing under a site stop (its 0 W would need the other pack to give). SAFETY-125: at N=1 too; the stop rule stays N>=2 only. DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md""" if len(self.inverters) > 1 and any( c.kind == SET and c.actuator == ACTUATOR_SITE and c.priority > P_MAINT_SHAPE for c in claims): return 0.0 room = peakguard.charge_room_w(self.peak_guard, self._peak_proj) floor = floor if room is None else min(floor, room) self.room_charge = self.room_charge or floor > 0.0 return floor def _charge_ceiling(self, claims): """SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None: the charging pack's own `hi` (P_MAINT_SHAPE) makes it charge; above that the other pack would have to give (opposite directions, refused, 0 W). Not filed at N=1.""" if len(self.inverters) < 2: return None live = set(self.live_units()) ceiling = 0.0 for inv in self.inverters: u = self.actuator_of("maintenance", inv) b = pre_resolve([c for c in claims if c.actuator == u]) if u in live and not b.contradiction and b.hi < 0.0: ceiling += allocator.hi_w(b) return ceiling if ceiling < 0.0 else None def _maint_stop_off(self, machine, now) -> None: """OPT-11: stop this battery's maintenance cycle because it is off.""" if machine.phase == IDLE: return for msg in machine.abort(now, "battery switched off"): self.log_event(msg, hint=messages.hint("maint_off", unit=machine.name)) @property def why_words(self) -> str: """The page's "Why" line. While control waits for a missing reading the age is counted when the page asks (UX-10), not frozen at the age the last cycle saw.""" if self._why_waiting_since is not None: return messages.why_waiting(max(0.0, ( datetime.now(timezone.utc) - self._why_waiting_since).total_seconds())) return self._why_words @why_words.setter def why_words(self, text: str) -> None: self._why_words = text self._why_waiting_since = None @property def inputs_ok(self) -> bool: """The site meter, and EVERY pack's own two readings: every pack, not the live ones. A unit whose SoC did not answer cannot be graded for the maintenance FSM or the pack floor, and 11.1 rule 1 for a missing input is command zero and keep commanding.""" if self.grid is None or not self.inverters: return False return all(inv.soc is not None and inv.batt is not None for inv in self.inverters) def missing_reading(self) -> str: """UX-11: the first reading `inputs_ok` finds missing, in words.""" if self.grid is None or not self.inverters: return "meter" for inv in self.inverters: of = "" if len(self.inverters) == 1 else f" from {inv.name}" if inv.batt is None: return f"battery power{of}" if inv.soc is None: return f"battery level{of}" return "input" # -- io ------------------------------------------------------------------ @property def pv_roster(self) -> tuple: """Every configured PV production entity, or `()` (TEL-11/TEL-12). ONE reading of the options, `pv.entities()`, the same call `mqtt.optional_keys()` makes, so what is ANNOUNCED, READ and CHECKED cannot disagree; it replaced a `pv_entity` property.""" return pv_read.entities(self.o) async def _read_pv(self) -> None: """This cycle's SITE PV production, or an ABSENCE with a reason (TEL-11/12). `pv.total()` makes "one of three absent" an absence of the SITE figure. It assigns on EVERY call including failing ones (a stale value must not survive a failure), and uses `hass.state()` not `hass.number()` because the UNIT is half of the answer.""" # TEL-12: every entity in ONE gather. Read serially, three slow sensors # would add three 10 s timeouts to a cycle that feeds the ESP32 # heartbeat; telemetry must have no foot in the control path. roster = self.pv_roster states = await asyncio.gather( *(self.hass.state(entity) for entity in roster)) self.pv_w, self.pv_why, self.pv_parts = pv_read.total( list(zip(roster, states)), pv_read.inverted(self.o)) # UX-54: kept for the Forecast tab only. It must never cost a cycle. try: self.solar_today.add(datetime.now(timezone.utc), self.pv_w) except Exception: # noqa: BLE001 _LOG.debug("measured solar not kept", exc_info=True) def _peak_fc_unit_log(self, fault) -> None: """SAFETY-118. One WARNING when the peak forecast's unit cannot be read in watts, and again only if the unit changes; the unit is on DEBUG.""" if fault == self._peak_fc_fault: return self._peak_fc_fault = fault if fault is None: _LOG.info("The 15-minute demand forecast is used again.") elif fault == "": _LOG.warning("The 15-minute demand forecast has no unit, so it " "is not used.") else: _LOG.warning("The 15-minute demand forecast is not in a power " "unit, so it is not used.") _LOG.debug("The 15-minute demand forecast reads in %r.", fault) async def _read_forecast(self) -> None: """OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A failed or stale read is no forecast, never a zero.""" # OPT-17: every member of both days in one gather. ents = self.fc_entities + self.fc_tomorrow_entities states = await asyncio.gather(*(self.hass.state(e) for e in ents)) n = len(self.fc_entities) now = datetime.now(timezone.utc) fc, why, gaps = solarfc.read_sum( list(zip(self.fc_entities, states[:n])), list(zip(self.fc_tomorrow_entities, states[n:])), now, hold=self.fc_hold) if self.fc_hold.changed: self.fc_hold.changed = False self.store.set(FC_HOLD_KEY, self.fc_hold.dump()) # A member is named only in a sum of several; one entity reads as # before. Logged once per episode: on a change, never every read. gaps = tuple(g for g in gaps if len( self.fc_entities if g[0] == "today" else self.fc_tomorrow_entities) > 1) was_kept = bool(self.solar_fc is not None and self.solar_fc.kept) if (fc is None) != (self.solar_fc is None) \ or why != self.solar_fc_why or gaps != self.solar_fc_gaps \ or bool(fc is not None and fc.kept) != was_kept: self.log_event( "solar forecast: " + ( "none (" + str(why) + ")" if fc is None else f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept else "read") + "".join(f", {d} {e} not usable ({w})" for d, _i, e, w in gaps), hint=messages.hint( "forecast_none" if fc is None else "forecast_no_tomorrow" if gaps else "forecast_read")) self.solar_fc, self.solar_fc_why = fc, why self.solar_fc_gaps = gaps async def read_inputs(self, now_mono: float | None = None) -> None: """Every input this cycle needs, and SAFETY-37's freshness verdict. `now_mono` is defaulted rather than read inside `_read_unit()` so ONE instant covers every unit in a gather.""" o = self.o now_mono = freshness.monotonic() if now_mono is None else float(now_mono) self._now_mono = now_mono if self.p1_enabled: # P1 is the only authoritative measurement of what the utility sees # (5.1); when stale this is None -> "inputs missing -> command 0 # W". NO fallback to an inverter-side figure: its AC power # correlates 0.998 with battery power and 0.09 with the real meter # (a controller would regulate against its own output). self.grid = self.p1.net_w else: self.grid = await self.hass.number(o.get("meter_entity", ""), bool(o.get("meter_invert"))) # DEV-02: each inverter's own two readings from its own entities, # CONCURRENTLY across units and sequentially within one (a slow entity # must not delay the heartbeat path). The PV reads ride in the same # gather and an empty PV roster appends nothing (no coroutine). # DECISIONS/MAIN-orchestration.md. _reads = [self._read_unit(inv, now_mono) for inv in self.inverters] if self.pv_roster: _reads.append(self._read_pv()) # OPT-11 P4: the solar forecast, in the same gather (a slow sensor must # not delay control), at most once per FORECAST_READ_S. if self.fc_entity is not None and ( self._fc_read_mono is None or now_mono - self._fc_read_mono >= FORECAST_READ_S or now_mono < self._fc_read_mono): self._fc_read_mono = now_mono _reads.append(self._read_forecast()) await asyncio.gather(*_reads) if o.get("peak_forecast_entity"): # SAFETY-118: read in its own unit like the PV path, one state # read. No unit or a non-power unit: not used, as if the option # were empty. st = await self.hass.state(o["peak_forecast_entity"]) self.peak_fc, _why = pv_read.watts(st) # A missing reading leaves the unit-fault state as it was. if pv_read.has_reading(st): self._peak_fc_unit_log(pv_read.unit_fault(st)) else: self.peak_fc = None self._peak_fc_fault = None now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity") if now_e and avg_e: now_p = await self.hass.number(now_e) avg_p = await self.hass.number(avg_e) self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p) self._price_gated = True self.tariff_words = "Prices from Home Assistant entities" elif uses_chain(o): # PRICE-01. A provider with fallbacks gives the maintenance gate # its own prices: the current 15-minute price against today's # average, as the Home Assistant pair did. No I/O here: the series # is the one the background refresh stored. # (DECISIONS/MAIN-orchestration-archive.md) self._provider_gate(datetime.now(timezone.utc)) else: # TARIFF-03: 9.2 makes a static day/night tariff a first-class # product and its EMS handling TIME-WINDOW SHIFTING; the one # shiftable load is the maintenance cycle's cheap-window grid # top-up. Synchronous and I/O-free on purpose (the provider-backed # plan is the background task's); validate.py refuses # `static_profile` with the price entities, so the two cannot both # be set. _now = datetime.now(timezone.utc) self.cheap, self.tariff_words = static_status(o, _now) self._price_gated = self._static_gated(o, _now) # TARIFF-11: the provider plan reaches the *Tariff* row here, the only # place the two sources meet. `fresh_tariff_plan()` is an attribute # read and an age comparison against TARIFF_MAX_AGE_S: still no fetch # on this path. _tick = datetime.now(timezone.utc) _standing = self.fresh_tariff_plan(_tick) self.tariff_words = tariff_plan_row( _standing, self.tariff_words, age_s=(None if _standing is None else (_tick - self.tariff_at).total_seconds()), horizon_h=float(price_option(o, "price_horizon_h")), provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S) # UX-11: the red meter row says "the add-on log shows why", so a quiet # meter gets one WARNING per episode (at most one per # METER_QUIET_REPEAT_S; the rest DEBUG). Log only: no notification, # decides nothing. if self.p1_enabled: if (self.p1.stale and not self._meter_quiet_logged and self.p1.published_age_s > self.p1.max_age_s): self._meter_quiet_logged = True now = _mono() self._meter_quiet_loud = ( self._meter_quiet_warned is None or now - self._meter_quiet_warned >= METER_QUIET_REPEAT_S) if self._meter_quiet_loud: self._meter_quiet_warned = now _LOG.warning("No meter reading for over %s, so control " "waits and then sets the battery to 0 W.", messages.seconds(self.p1.max_age_s)) else: _LOG.debug("No meter reading for over %s again.", messages.seconds(self.p1.max_age_s)) elif not self.p1.stale and self._meter_quiet_logged: self._meter_quiet_logged = False if self._meter_quiet_loud: _LOG.info("Meter readings are back.") else: _LOG.debug("Meter readings are back.") self.answer.watch(now_mono, [(inv.name, inv.standing_w(), inv.batt) for inv in self.inverters], # SAFETY-128 sum(inv.standing_w() for inv in self.inverters), self.grid) if self.inputs_ok: if self.inputs_bad_since is not None: self.log_event("inputs recovered", hint=messages.hint("inputs_back")) self.inputs_bad_since = None elif self.inputs_bad_since is None: self.inputs_bad_since = datetime.now(timezone.utc) def _driver(self, inv): """`inv`'s driver, wired to this Controller's Home Assistant on first use (SMA-2: bound here, so a unit added later and a swapped `self.hass` are followed). DECISIONS/SMA-2-driver-seam.md.""" drv = inv.driver if not drv.bound(): drv.bind_getter(lambda: self.hass) return drv async def _read_unit(self, inv, now_mono: float) -> None: """One inverter's own SoC and battery power, from ITS OWN entities. SAFETY-37: the POWER reading goes through the freshness gate and the SoC does not, deliberately: `batt` feeds SAFETY-30's per-unit sign check and the delivery check, which a frozen register would fool. The standing command is asked of the unit, not recomputed.""" # SMA-2: the driver reads SoC, then power, as this method always did. inv.soc, raw, _extra = await self._driver(inv).read() was_stale = inv.batt_fresh.stale was_releases = inv.batt_fresh.releases inv.batt_fresh.submit(raw, inv.standing_w(), now_mono) # `inv.batt` is the GATED value: a stale register is a MISSING input, # so the site holds for `stale_input_s` then commands 0 W and keeps # commanding (11.1 rule 1). inv.batt = inv.batt_fresh.reading() # SAFETY-97: learn this unit's idle reading, only while control runs # and nothing says the unit is refusing, stale or faulty. if inv.idle.observe( inv.batt, inv.standing_w(), now_mono, bool(self.auto) and inv.batt is not None and not inv.batt_fresh.stale and not inv.refused and not self.batt_refused and not inv.sign_wd.tripped): inv.store_set("idle_w", round(inv.idle.value, 1)) if inv.batt_fresh.stale and not was_stale: self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason, hint=messages.hint("battery_stuck", unit=inv.name)) self._refusal_from_stale(inv, now_mono) # SAFETY-56/57: the stale-reading line names two explanations once, # in `%s`. The escape clause "clears itself when the register # reports a different value" was false for the state this line # announces (the 0 W it announces is what stops the value # changing), so SAFETY-57 rewrote it and SAFETY-56 the diagnosis. # DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md. _LOG.error("%s: battery power reading stopped changing, set to " "0 W until it changes, or %.0f s after the command " "drops below %.0f W: %s.", inv.name, inv.batt_fresh.bound.release_s, inv.batt_fresh.bound.move_w, messages.reason_code(inv.batt_fresh.reason)) _LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason) _LOG.debug("%s: the limits are unchanged for %.0f s while asked " "to move: a command over %.0f W that moved over %.0f W.", inv.name, inv.batt_fresh.bound.unchanged_s, inv.batt_fresh.bound.move_w, inv.batt_fresh.bound.move_w) elif inv.batt_fresh.releases > was_releases: # SAFETY-57: reported, never silent: a latch that lets go on a # timer and says nothing cannot be audited, and this one withdraws # a SITE STOP. `release_note()` says nothing has been learned about # the register. self.log_event(f"STALE WITHDRAWN {inv.name}", hint=messages.hint("battery_back", unit=inv.name)) # Not "recovered": nothing is known about the reading yet, so the # line gives the value it holds and the window it is rechecked over # (SAFETY-57). _LOG.warning("%s: the command stayed below %.0f W for %.0f s, so " "battery power reading %+.0f W is used again and " "rechecked over %.0f s.", inv.name, inv.batt_fresh.bound.move_w, inv.batt_fresh.bound.release_s, inv.batt_fresh.held, inv.batt_fresh.bound.unchanged_s) _LOG.debug("SAFETY-57: %s", inv.batt_fresh.release_note(inv.name)) @property def shadow(self) -> bool: """SAFETY-10. Is the battery being left alone right now? A property, not a field (as `self.units`): the cycle, the write and the status page ask at three moments, and the write gate asks THIS, not a parameter passed down.""" return shadow.engaged(self.o, control_started=self.auto) def shadow_latches(self) -> tuple: """The failsafe latches that are UP, named, for the shadow banner: a standing latch is standing when the controller stops being inert, so an operator who turns shadow off is not handed a battery that will not move with no reason shown.""" out = [] if self.circ_wd.tripped: out.append("circulating power") if self.site_sign_wd.tripped and len(self.inverters) > 1: out.append("site sign watchdog") for inv in self.inverters: if inv.sign_wd.tripped: out.append(f"{inv.name} sign watchdog") return tuple(out) def write_refusal(self, value: float, inv=None) -> str | None: """Why `value` may NOT be written to `inv` right now, or None if it may. SAFETY-28: a number reaches the wire only if THE SINGLE ALLOCATOR placed it, on this tick, for THIS unit (`Allocation.authorises()`); a second writer must file a Claim. 0 W is ALWAYS authorised and tested first, reading nothing: the failsafe writes run while the allocator has REFUSED, so a gate asking permission to command zero would deadlock the failsafe. It tests `value == 0.0`, not a flag the caller sets. DEV-02: the unit is an ARGUMENT (the record holds one share per unit); the default is for N=1 callers and REFUSES at N>=2. DECISIONS/MAIN-orchestration.md.""" if value == 0.0: return None inv = self._only() if inv is None else inv rec = self.allocation if rec is None: return ("no allocation record exists yet - nothing has authorised " f"{value:+.0f} W on {inv.name}") return rec.authorises(inv.name, value, self.alloc_tick) async def write_setpoint(self, value: float, *, force: bool = False, unit=None, last: bool = False) -> None: """Put `value` on ONE inverter, or with `unit=None` on all. `unit=None` is for a number that is not a share (stop button, `shutdown()`, `maint_abort()`: 0 W means the same to every inverter). Writes are concurrent, each under its own budget (DEV-02). `last`: SAFETY-137.""" if unit is None: targets = [i for i in self.inverters] if len(targets) > 1 and is_commandable(value) and float(value) != 0.0: # Loud, and it commands zero rather than the value asked for: # fail toward inaction, as every other refusal here. self.log_event( f"refusing to broadcast {float(value):+.0f} W to " f"{len(targets)} inverters - that is the ARCH-01 fault; " f"commanding 0 W", hint=messages.hint("all_blocked")) _LOG.error("Refused to send %+.0f W to all %s at once, so " "every inverter is set to 0 W. This is a software " "fault.", float(value), messages.count(len(targets), "inverter")) _LOG.debug("ARCH-01: a site target handed to each unit is " "supplied by each of them, so the gain at the meter " "becomes N times the per-unit gain. Only 0 W may be " "written site-wide, and shares come from the " "allocator.") value = 0.0 else: targets = [unit] if not targets: return await asyncio.gather(*(self._write_unit(inv, value, force=force, last=last) for inv in targets)) async def _await_inflight(self, inv, budget_s: float) -> None: """Wait, at most `budget_s`, for writes already on their way to `inv`; one still running is left to its own WRITE_BUDGET_S. SAFETY-137.""" pending = [t for t in inv.inflight if not t.done()] if pending: await asyncio.wait(pending, timeout=budget_s) async def _release_last(self, inv) -> bool: """`release()` once any write in flight to `inv` has finished (at most SETTLE_BUDGET_S): the release is the LAST thing the unit hears.""" await self._await_inflight(inv, SETTLE_BUDGET_S) return await self._driver(inv).release() async def _hand_back(self, inv, *, force: bool = False) -> bool: """SMA-10. Give ONE unit back: exactly one `release()`, then nothing. True when handed back. Never raises; a failure is retried on the next idle write. ⚠️ `released` is set before the first await (one release per unit, not two). ⚠️ It is not a write: no `note_write`, no `last_write`. DECISIONS/SMA-10-two-idle-states.md.""" if inv.released: return True if not inv.driver.configured(): return False # After a failure the next try waits one heartbeat; Stop and shutdown # (`force`) always try. now_m = time.monotonic() if not force and now_m < inv.release_retry_at: return False inv.released = True why = "" try: ok = bool(await _bounded(self._release_last(inv), RELEASE_BUDGET_S)) except (asyncio.TimeoutError, TimeoutError): ok, why = False, f"took over {RELEASE_BUDGET_S:.0f} s" except Exception as err: # noqa: BLE001 - one unit never stops the rest ok, why = False, f"{type(err).__name__}: {err}" if ok: inv.last_write = None if inv.release_fails: _LOG.info("Handing back %s worked again after %d failed " "tries.", inv.name, inv.release_fails) inv.release_fails = 0 inv.release_retry_at = 0.0 inv.release_zero_sent = False self.log_event(f"{inv.name}: handed back", hint=messages.hint("handed_back", unit=inv.name)) return True inv.released = False inv.release_fails += 1 inv.release_retry_at = now_m + float(self.o.get("heartbeat_s", 10)) _LOG.debug("Handing back %s failed (try %d): %s", inv.name, inv.release_fails, why) if inv.release_fails == 1: # one line per failure streak _LOG.error("Handing back %s failed, so it is sent 0 W once and " "handing back is tried again.", inv.name) self.log_event(f"{inv.name}: hand-back failed", hint=messages.hint("hand_back_failed", unit=inv.name)) if not inv.release_zero_sent: # ⚠️ ONCE: a 0 W re-sent on every try would reset the inverter's own # fallback timer for ever. inv.release_zero_sent = True try: if await _bounded(self._driver(inv).release_fallback(), FALLBACK_BUDGET_S): inv.last_write = 0.0 except Exception as err: # noqa: BLE001 _LOG.debug("The fallback write to %s failed: %r", inv.name, err) return False async def _write_unit(self, inv, value: float, *, force: bool = False, last: bool = False) -> None: if not inv.driver.configured(): return # SMA-10: a unit that hands back is released once and not written while # the add-on is idle (shutdown, stopped, shadow). ⚠️ A safety stop is # NOT idle: it falls through to the 0 W write. Control starting again # re-arms the unit; `stopping` never does. # DECISIONS/SMA-10-two-idle-states.md. if inv.driver.hands_back: if self.stopping or self.shadow or not self.auto: await self._hand_back(inv) return if inv.released or inv.release_fails or inv.release_zero_sent: # Control has the unit again: forget the hand-back and any # failed streak. inv.released = False inv.last_write = None inv.release_fails = 0 inv.release_retry_at = 0.0 inv.release_zero_sent = False entity = inv.driver.actuator_id() # for the log line below # SAFETY-03: the clamp is on the ONLY path to the wire (cycle, # heartbeat, stop button, shutdown(); no optimizer, maintenance or # manual path routes around it). A non-finite value is loss of input, # not an out-of-range one (11.1): coerced to 0 W. if not is_commandable(value): self.log_event(f"non-finite setpoint {value!r} - commanding 0 W", hint=messages.hint("bad_value")) _LOG.error("refusing to write a non-finite setpoint %r - commanding " "0 W and continuing to command. This is a bug upstream, " "not a limit.", value) value = 0.0 # SAFETY-28: the refusal is HERE, above the clamp and below the # non-finite coercion (a nan is loss of input, not an unauthorised # number), and asked about `inv`, not the site. # DECISIONS/MAIN-orchestration.md. refusal = self.write_refusal(value, inv) if refusal is not None: self.log_event( f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W", hint=messages.hint("write_blocked", unit=inv.name)) _LOG.error("Refused to send %+.0f W to %s, so it is set to 0 W " "instead, which is a software fault. The reason: %s.", value, inv.name, refusal) value = 0.0 # This unit's own three numbers: `inv.max_w` (supervised cap on THIS # setpoint entity) and `inv.dev_min/dev_max` (what THIS device # reported). Read off `self.o` they were one inverter's rating applied # to every inverter. result = clamp_to_rating(value, inv.max_w, inv.dev_min, inv.dev_max) inv.clamp_last = result if result.engaged: # EVERY engagement is logged, never deduplicated: the action log # (15) is a different surface from the deduplicated notification. # The count says how hard the loop pushed. self.log_event(f"{inv.name}: {result.describe()}", hint=messages.hint("write_cut", unit=inv.name)) value = result.applied_w # SAFETY-10: shadow mode runs the whole decision path and commands # nothing, and the suppression is HERE, the last thing before the wire, # in the one method every write reaches (so no call site routes round # it). A substitution, not a return, like the two refusals above; below # SAFETY-28's gate and SAFETY-03's clamp; not a second writer (no # claim, no transport call). if self.shadow and value != shadow.WIRE_W: # UX-10: DEBUG, because it repeats every cycle in shadow mode; the # INFO line when shadow comes on says it once. _LOG.debug("Shadow mode: would send %+.0f W to %s, and holds " "%+.0f W. Nothing was sent to the inverter.", value, inv.name, shadow.WIRE_W) value = shadow.WIRE_W # SAFETY-137: `last` (the Stop and shutdown 0 W) waits, bounded, for a # write already on its way; once `stopping`, every other write is dropped # here with no await before the write is made. # DECISIONS/SAFETY-137-last-write-is-zero.md. if last: await self._await_inflight(inv, SETTLE_BUDGET_S) elif self.stopping: _LOG.debug("Shutting down, so %+.0f W to %s is not sent.", value, inv.name) return changed = (inv.last_write is None or value != inv.last_write) if not (changed or force): return # BOUNDED, and the budget is AC 6: one hung transport would hold the # coroutine, the whole loop and every OTHER inverter's heartbeat. An # abandoned write is re-issued next tick (`last_write` moves only on an # ACCEPTED write). try: _w = self._driver(inv).write(value) # SMA-10 / SAFETY-137: kept so a release or last 0 W can wait for it. _w = asyncio.ensure_future(_w) inv.inflight.add(_w) _w.add_done_callback(inv.inflight.discard) ok = await asyncio.wait_for(_w, timeout=WRITE_BUDGET_S) except (asyncio.TimeoutError, TimeoutError): ok = False _LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was " "dropped, so it is sent again next cycle.", value, inv.name, entity, WRITE_BUDGET_S) self.log_event(f"{inv.name}: write timed out after " f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick", hint=messages.hint("write_slow", unit=inv.name)) now = datetime.now(timezone.utc) inv.note_write(bool(ok), now) if ok: inv.last_write = value inv.last_write_at = now # -- the cycle ----------------------------------------------------------- def actuator_of(self, source: str, inv=None) -> str: """Which actuator a claim from `source` is filed on this cycle. `inv` is the roster entry the claim is ABOUT; left out it is the first inverter (what every per-inverter claim meant at N=1). The classification is not here: `arbiter.CLAIM_SCOPE` decides.""" return actuator_for(source, (inv or self.inverters[0]).name, self.units) def live_units(self) -> tuple: """Which inverters may be commanded THIS TICK, for the allocator. The allocator DROPS what this does not list and the survivors carry the whole site target, so it is reported (`Allocation.dropped`). Answered per tick, never cached (a master goes stale). It answers ONE question: can this controller put a number on this unit's wire. Deliberately not "its sensors are missing". At a roster of ONE, False is a 0 W site.""" now = datetime.now(timezone.utc) live = [] for inv in self.inverters: ok, why = inv.is_live(now) if ok: live.append(inv.name) else: # Level-logged at DEBUG only: `Allocation.dropped` is the # operator-facing surface (edge-logged in cycle()); a line # every second would flush the 40-row action log. _LOG.debug("%s is not commandable this tick: %s", inv.name, why) return tuple(live) def liveness_notes(self) -> list: """Why each unit is or is not commandable, for the status page. SAFETY-37's stale-reading line rides here too: a different fault, a different remedy (a dropped unit is carried by the survivors; a stale power register is a missing input).""" now = datetime.now(timezone.utc) return [n for n in [x for inv in self.inverters for x in (inv.link_note(now), inv.stale_note(), inv.refusal_note())] if n] def _provider_gate(self, now: datetime) -> None: """PRICE-01: `cheap`, the gate and the gap from the standing plan. No price for now is a closed gate, so the charge waits rather than run unpriced; the gap flag stays False until the first refresh has finished. (DECISIONS/MAIN-orchestration-archive.md)""" plan = self.fresh_tariff_plan(now) published = None if plan is None else plan.published if published is not None: self._last_published = published gate = gate_for(published, now) self.cheap, self._price_gated = gate.cheap, True self.tariff_words = gate.words # Owner, 2026-10-14: the words say 'maintenance waits', so they show # only while the maintenance charge is on. self.price_gap = ((not gate.covered) and self.tariff_at is not None and MaintConfig.from_options(self.o).enabled) last = self._last_published if gate.covered or last is None: self.price_gap_since = None elif last.covers(now): # The last series still covers now, but no refresh has made it # current for TARIFF_MAX_AGE_S: the refresh task has stopped. The # gap began when the plan was retired. self.price_gap_since = ( None if self.tariff_at is None else self.tariff_at + timedelta(seconds=TARIFF_MAX_AGE_S)) else: self.price_gap_since = last.last_end_before(now) def _static_gated(self, o: dict, now: datetime) -> bool: """`static_gated()`, worked out once an hour: it builds the same month-long plan as `static_status()` (about 20 ms with a profile), and whether a fixed tariff is set up does not change from tick to tick (options change only with a restart).""" key = (id(o), now.strftime("%Y-%m-%d %H")) if key != self._gated_key: self._gated_key, self._gated = key, static_gated(o, now) return self._gated def _charge_gate_open(self, inv) -> bool: """MAINT-13: may this pack charge now, for the maintenance timeout? Closed only when a price gate exists, it is shut, and the pack is not really taking charge. (DECISIONS/MAIN-orchestration-archive.md)""" if not self._price_gated or self.cheap: return True batt = inv.batt return batt is not None and float(batt) <= -MAINT_CHARGING_W async def cycle(self) -> None: """Gather claims, resolve precedence, write the winner. Nothing here decides who wins (arbiter.py does, by one rule); this method only states what each strategy wants. (DECISIONS/MAIN-orchestration-archive.md)""" now = datetime.now(timezone.utc) # BEFORE the first `return` of this method on purpose (TARIFF-11): it # stamps that a cycle RAN, a different fact from the decisions it may # reach. self.cycle_at = now # Each pack on ITS OWN reading, not "the SoC": 12's recalibration # drains and refills ONE pack and grades ONE coulomb counter; a minimum # would abort on a sibling's state and an average would declare a pack # full that is not. `arbitrate()` runs first, once, over all of them. _off = ({inv.name for inv in self.inverters if not self.pack_mode(inv).use} if self.modes_on else set()) for inv, machine in zip(self.inverters, self.maints): if inv.name in _off: self._maint_stop_off(machine, now) _on = [m for inv, m in zip(self.inverters, self.maints) if inv.name not in _off] _plan_on = iter(maint_arbitrate(_on, now) if _off else maint_arbitrate(self.maints, now)) maint_plan = [None if inv.name in _off else next(_plan_on) for inv, _m in zip(self.inverters, self.maints)] maint_results = [] for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan): if blocked is None: maint_results.append(MaintResult(IDLE, False, None, False, [])) continue # MAINT-13: the charge timeout counts only the minutes the pack was # allowed to charge (see `_charge_gate_open`). res = machine.tick(now, inv.soc, blocked_by=blocked, gate_open=self._charge_gate_open(inv)) for msg in res.events: self.log_event(msg, hint=messages.hint( "maint_failed" if res.failed else "maintenance", phase=machine.phase)) if res.failed: # Said once: only the tick that failed carries `res.failed`. multi = len(self.inverters) > 1 slug = (unit_slugs(self.units)[self.inverters.index(inv)] if multi else "battery") await self.hass.call( "persistent_notification", "create", messages.maint_failed( res.failed, FAIL_BACKOFF_DAYS, unit=inv.name if multi else "", slug=slug).data()) maint_results.append(res) # Carried to the status page, not left in the 40-line action log: the # deferral is edge-logged, and a pack that is due, idle and WAITING # must be able to say so when looked at (12's failure mode is a # schedule that silently stops firing). self._maint_deferred = [(m.name, r.deferred) for m, r in zip(self.maints, maint_results) if r.deferred] # SAFETY-02, and it runs BEFORE the early return below: evaluated every # cycle, else a run of held cycles leaves `disagree_since` untouched # and the timer resumes from a start time that predates the gap. One # watchdog per inverter on that inverter's own reading; what it is # compared against is decided by the roster (SAFETY-30). self._arm_sign_watchdogs() n_units = len(self.inverters) standing = {} for inv in self.inverters: was_tripped = inv.sign_wd.tripped # `Inverter.standing_w()`, not the expression it replaces: # SAFETY-37's freshness gate arms off the same method. stood = inv.standing_w() standing[inv.name] = stood against = self.grid if n_units <= 1 else inv.batt if inv.sign_wd.evaluate(stood, against, self.following, now) \ and not was_tripped: # REASON first, FLAG second (each `set` writes the file): a # crash between the two persists a reason nothing reads, not a # latch with no explanation. Both keys are namespaced to this # unit. inv.store_set("sign_reason", inv.sign_wd.reason) inv.store_set("sign_tripped", True) # The legacy flat keys are MIRRORED, the other half of store.py # rule 2: that rule only covered the latch existing at # migration time, so a trip after it wrote nothing flat and a # rollback would find no latch. self.store.set("sign_reason", inv.sign_wd.reason) self.store.set("sign_tripped", True) self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason, hint=messages.hint("direction", unit=inv.name)) _LOG.error("%s is set to 0 W until control is started, " "because its power went the wrong way: %s.", inv.name, messages.reason_code(inv.sign_wd.reason)) _LOG.debug("Evidence for %s: %s.", inv.name, inv.sign_wd.reason) # The site's own standing command is the SUM of the units': SAFETY-07 # and SAFETY-18 grade a SITE quantity (the quarter average at the grid # connection; a meter not moving while the site pushes near its rail). # Same number at a roster of one. site_standing = float(sum(standing.values())) # SAFETY-30: the site half of the sign check: the TOTAL standing on the # wire against the meter, catching a fault no per-unit check sees # (every unit faithfully executing the wrong total). Only at N>=2 (at # one inverter it would duplicate `inverters[0].sign_wd`; AC 8). Flat # keys, not namespaced. if n_units > 1: was_site = self.site_sign_wd.tripped if self.site_sign_wd.evaluate(site_standing, self.grid, self.following, now) and not was_site: # REASON first, FLAG second, as the per-unit trip and # SAFETY-31. The keys are flat, not namespaced, and not the # per-unit pair (store.py). self.store.set("site_sign_reason", self.site_sign_wd.reason) self.store.set("site_sign_tripped", True) self.log_event("FAILSAFE site: " + self.site_sign_wd.reason, hint=messages.hint("site_direction")) _LOG.error("Every inverter is set to 0 W until control is " "started, because the total power went the wrong " "way: %s.", messages.reason_code(self.site_sign_wd.reason)) _LOG.debug("Evidence for the site: %s.", self.site_sign_wd.reason) # SAFETY-31, beside SAFETY-02 for the same reason: evaluated EVERY # cycle, unconditionally, above the input hold (else `over_since` # resumes from a start that predates the gap). The input is MEASURED # power, never commanded: a unit ignoring or lagging its share, a stale # share still executing. circ_w = self.site_circulating_w was_circ = self.circ_wd.tripped if self.circ_wd.evaluate(circ_w, now) and not was_circ: # REASON first, FLAG second (each `set` writes the file): a crash # between must not persist a latch with no explanation. self.store.set("circ_reason", self.circ_wd.reason) self.store.set("circ_tripped", True) self._circ_announced = False self.log_event("CIRCULATING POWER - " + self.circ_wd.reason, hint=messages.hint("circulating")) _LOG.error( "Every inverter is set to 0 W until control is started, " "because the inverters worked against each other: %s.", messages.reason_code(self.circ_wd.reason)) _LOG.debug("Evidence for the site: %s.", self.circ_wd.reason) # SAFETY-42, for SAFETY-02's and SAFETY-31's reason: evaluated every # cycle above the input hold. The operands are `standing` and # `inv.batt`, the pair SAFETY-30 uses. `delinquent()` is called once # over the whole roster, and every number is the configured one, never # a literal that could drift (`self.delivery_cfg`, not # `DeliveryConfig()`: part 4b grades a hold that is not the shipped 185 # s). for inv in self.inverters: # `self.delivery_cfg`, not `DeliveryConfig()`: the window is # `delivery_hold_s`, and part 4b grades a hold that is not the # shipped 185 s. inv.delivery_wd.arm(self.delivery_cfg) _delinquent = delinquent( [UnitDelivery( inv.name, standing[inv.name], inv.batt, delivery_account( # ⚠️ `is_live()` AT ITS OWN DEFAULT WINDOW, which is # `LINK_STALE_S` - the SAME call `live_units()` makes for # the allocator. (DECISIONS/MAIN-orchestration-archive.md) live=inv.is_live(now)[0], # SAFETY-73: a refusing unit is asked 0 W on purpose, and # one asked under PROBE_W that gives under 100 W is a # refusal too small to call (its sibling covers the meter). parked=inv.sign_wd.tripped or bool(inv.refused) or ( n_units > 1 and inv.batt is not None and abs(float(standing[inv.name])) <= _ctl.PROBE_W and abs(float(inv.batt) - inv.idle_w) < _ctl.REFUSED_BELOW_W), # ⚠️ THIS UNIT'S OWN CUT-OUT (`bms_floor`): not the shipped # 10 % and not the site's `peak_soc_floor`, which bounds a # money-saving discharge only. # (DECISIONS/MAIN-orchestration-archive.md) soc=inv.soc, bms_floor=inv.bms_soc_floor)) for inv in self.inverters], self.tuning.saturation_w) for inv in self.inverters: was_short = inv.delivery_wd.tripped if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \ and not was_short: # REASON FIRST, FLAG SECOND, and each `set` writes the file (as # the three trips above): a crash between must not persist a # latch with no explanation. ⚠️ NO FLAT MIRROR OF THIS PAIR, # unlike SAFETY-02: store.py's rule 2 keeps flat keys so a # rollback finds its latch, but the version rolled back TO # drives one actuator, where this check is inert; a mirror # would hand it a latch about a roster it cannot have. # DECISIONS/MAIN-orchestration.md. inv.store_set("delivery_reason", inv.delivery_wd.reason) inv.store_set("delivery_tripped", True) self._delivery_announced.discard(inv.name) self.log_event(f"NOT DELIVERING {inv.name}: " + inv.delivery_wd.reason, hint=messages.hint("not_delivering", unit=inv.name)) _LOG.error( "%s is not delivering while control keeps running, and " "only stopping and starting control clears this: %s.", inv.name, messages.reason_code(inv.delivery_wd.reason)) _LOG.debug("Evidence for %s: %s.", inv.name, inv.delivery_wd.reason) _LOG.debug( "SAFETY-42 reports and does not act: commanding a unit " "that is not listening achieves nothing (HW-01), and the " "only re-allocation the add-on can express would throw " "away a partly delivering unit's output.") stale_after = float(self.o.get("stale_input_s", 15)) late_s = self._readings_late(site_standing) # SAFETY-128 self._late_zero = late_s is not None and late_s >= stale_after # ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed # poll does not become a fault; a LATCHED inverter must still reach the # claim list below, because its `failsafe` SET is what parks it. # SAFETY-30's site latch takes the roster past the hold too (its # `sign_site` SET parks every inverter): the trip would stop acting the # moment a poll was missed. (DECISIONS/MAIN-orchestration-archive.md) if not self.inputs_ok and not any(i.sign_wd.tripped for i in self.inverters) \ and not (n_units > 1 and self.site_sign_wd.tripped): bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0 if bad_for < stale_after: # A single missed poll is not a fault. Hold, keep the heartbeat # going, and give it a few seconds to come back. self.reason = f"inputs missing {bad_for:.0f}s" # UX-10: the age is rendered when the page asks, not frozen at # this cycle's. (DECISIONS/MAIN-orchestration-archive.md) self.why_words = messages.why_waiting(bad_for) self._why_waiting_since = now - timedelta(seconds=bad_for) return if late_s is not None and late_s < stale_after and self.inputs_ok \ and (self.auto or self.shadow) \ and not any(i.sign_wd.tripped for i in self.inverters) \ and not (n_units > 1 and self.site_sign_wd.tripped): self.reason = f"readings late {late_s:.0f}s" # SAFETY-128: hold self.why_words = messages.why_late() return claims = [] # safety limits: these bind EVERY strategy, always. Defence in depth: # write_setpoint() clamps to the device range too, and the duplication # is deliberate. ⚠️ PER-INVERTER, ONE PAIR PER UNIT from THAT unit's # own min/max and supervised cap: there is no site rating # (arbiter.CLAIM_SCOPE). (DECISIONS/MAIN-orchestration-archive.md) for inv in self.inverters: if inv.dev_min is not None and inv.dev_max is not None: claims.append(Claim.limit("device", P_SAFETY, inv.dev_min, inv.dev_max, "rating", actuator=self.actuator_of("device", inv))) # `max_w` is the supervised cap on what may be WRITTEN to one # setpoint entity (§11.3), not a bound on the grid connection. claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w", actuator=self.actuator_of("supervised", inv))) # OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS. ⚠️ AN ENVELOPE, # NOT A WEIGHT: a zero weight does not stop `allocator._fill()` # commanding a pack (at or under its BMS cut-out `hi = 0`, at 100 % # `lo = 0`). ⚠️ NEVER FILED AT N=1 (it would bound the site target: # SAFETY-25 Option A), so there nothing here stops the loop # discharging into the BMS cut-out; the BMS does. # (DECISIONS/MAIN-orchestration-archive.md) if n_units > 1: _pe = packshare.envelope(pack_energy_of(inv)) if _pe.binding: claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi, _pe.why, actuator=self.actuator_of("pack", inv))) # The SITE bound, for the site-level consumers below. It is the roster # sum: what this site can deliver, not what one of its inverters can. max_w = self.site_max_w # safety stops. ⚠️ SAFETY-02 is claimed FIRST and unconditionally while # latched, a SET at P_SAFETY, and re-filed EVERY cycle (claims are not # sticky). ⚠️ AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE: # at N>=2 the evidence cannot be pinned on a sibling this controller # never commanded (arbiter.CLAIM_SCOPE). # (DECISIONS/MAIN-orchestration-archive.md) for inv in self.inverters: if inv.sign_wd.tripped: claims.append(Claim.set("failsafe", P_SAFETY, 0.0, "sign disagreement", actuator=self.actuator_of("failsafe", inv))) # SAFETY-30: THE SITE SIGN TRIP, A SITE SET: the SUM of every standing # command against the meter, which no single inverter owns; parking one # unit on it would leave the rest pushing the same way. ⚠️ The `n_units # > 1` guard is not redundant: the latch is restored from the store, # and an install reconfigured back to one inverter must come up # driving. (DECISIONS/MAIN-orchestration-archive.md) if n_units > 1 and self.site_sign_wd.tripped: claims.append(Claim.set("sign_site", P_SAFETY, 0.0, "site sign disagreement", actuator=self.actuator_of("sign_site"))) # SAFETY-31: ⚠️ A SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. A # circulating loop needs two participants and one is this controller: # stopping it halves the loop and makes the residue visible at P1. It # does NOT stop a wedged inverter (HW-01); the remaining actuator is a # human at the isolator. A site claim: circulating power is zero for # any one unit (arbiter.CLAIM_SCOPE). # (DECISIONS/MAIN-orchestration-archive.md) if self.circ_wd.tripped: claims.append(Claim.set("circulation", P_SAFETY, 0.0, "circulating power", actuator=self.actuator_of("circulation"))) # SITE, both of them: "stopped" (the operator switching control off) # and "inputs missing" (the meter gone) are not about a pack. ⚠️ # SAFETY-10: `and not self.shadow` is the one failsafe this card # changes (a stop claim would record "stopped" on every shadow cycle); # the wire gate in `_write_unit()` is stronger. # (DECISIONS/MAIN-orchestration-archive.md) if not self.auto and not self.shadow: claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped", actuator=self.actuator_of("safety"))) elif not self.inputs_ok: if self.target != 0.0: self.log_event("inputs missing - commanding 0 W", hint=messages.hint("inputs_missing")) claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing", actuator=self.actuator_of("safety"))) elif late_s is not None: claims.append(Claim.set("safety", P_SAFETY, 0.0, "readings late", actuator=self.actuator_of("safety"))) # SAFETY-07: the quarter-peak projection guard: ONE `lo` limit at # P_PEAK; peakguard.py carries the argument. ⚠️ `frozen` is evaluated # on THIS cycle's standing command, not `self.freeze_wd.active` (a # cycle behind). ⚠️ `achievable_w` keeps the floor from filing `lo > # hi` (0 W). ⚠️ Filed EVERY cycle (claims are not sticky). ⚠️ NOT RUN # WITHOUT P1: it would be blind, and blind means "no charging"; the # report is NOT COVERED. (DECISIONS/MAIN-orchestration-archive.md) self._peak_proj = (None if not self.p1_enabled else self.peak.project(self.p1.averager, now, roll=self.modes_on)) # OPT-15 x SAFETY-81: see `check_peak_saturated()`. self._peak_rolled = (self._peak_proj is not None and self._peak_proj.block_start is not None and self._peak_proj.block_start != self.p1.averager.block_start) _reading = self.peak.peak(now) if self.p1_enabled else None # SAFETY-90: seconds a meter gap left uncounted are counted, so the # guard goes on after the readings return. self._peak_proj = self._fill_peak_gaps(self._peak_proj, now, _reading) # SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter, # so a HOLD's ceiling does not lapse for a cycle onto a held battery. if self._peak_proj is not None and len(self.inverters) > 1: self._peak_proj = next_quarter(self._peak_proj, self.p1.averager.max_hold_s) self.peak_guard = None if not self.p1_enabled else peakguard.decide( self._peak_proj, # ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is # ALREADY removing from the meter, and the meter sees every # inverter. (DECISIONS/MAIN-orchestration-archive.md) batt_w=self.site_batt_w, cap_w=float(self.o.get("peak_cap_w", 3500)), margin_w=self.peak_margin_w, reading=_reading, # ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each unit # contributes `min(its max_w, |its own device bounds|)`, so # inverter B's rating bounds inverter B. # (DECISIONS/MAIN-orchestration-archive.md) achievable_w=self.site_rating_w, # ⚠️ SAFETY-24. `pack_floor_soc` IS A MEASURED READING AND MAY BE # None, and it is handed over as None rather than defaulted (a # defaulted 100 % is a full battery, the value that disarms this # floor); `decide()` treats None as floor-limited. # (DECISIONS/MAIN-orchestration-archive.md) soc=self.pack_floor_soc, # ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN # CUT-OUTS: `binding_soc_floor()` takes the MAXIMUM with each # pack's `bms_soc_floor`, pairing with the MINIMUM in # `pack_floor_soc` (both move towards refusing a shave). # "Unreachable" is a claim about rule ordering, and sim/inject.py # does not run rule 8: asserted over the whole schema range in # test_peakguard.py part 12. # (DECISIONS/MAIN-orchestration-archive.md) soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get( "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))), max_hold_s=self.p1.averager.max_hold_s, # ⚠️ SAFETY-20. The device range goes in TOO: `standing` is # `last_write`, the value AFTER `clamp_to_rating()`, so a rail from # bare `max_w` is a ceiling the device cannot reach. # (DECISIONS/MAIN-orchestration-archive.md) frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None), site_standing, max_w, self.freeze_wd.cfg, dev_min=self.site_dev_bound("dev_min"), dev_max=self.site_dev_bound("dev_max")), ) # SAFETY-87: at N>=2 a battery at or under its own reserve gives # nothing for the peak, and the others carry the guard's need. if self.peak_guard is not None: self.peak_guard = self._peak_on_able(self.peak_guard, maint_results) self.peak_guard = self._peak_landing(self.peak_guard) self.peak_guard = peakguard.cap_floor( # SAFETY-128 self.peak_guard, self.answer.peak_cap(site_standing)) # SITE, and peakguard.claim() says so on the object it builds: the # quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION, # which the capacity tariff bills (one quarter-hour peak per site, not # per pack). ⚠️ It is also the `lo` half of SAFETY-25 §0c's table: a # site `lo` that the deferred loop reserve's per-inverter `hi` must # never cross. (DECISIONS/MAIN-orchestration-archive.md) peak_claim = (peakguard.claim(self.peak_guard) if self.peak_guard is not None else None) if peak_claim is not None: claims.append(peak_claim) # ⚠️ EDGE-LOGGED, not level-logged: the action log holds 40 lines, and # a line every 15 s for a quarter would push the cause off the end. # (DECISIONS/MAIN-orchestration-archive.md) engaged_now = self.peak_guard is not None and self.peak_guard.engaged if engaged_now != self._peak_engaged: self._peak_engaged = engaged_now if self.peak_guard is not None: _pg = self.peak_guard self.log_event(_pg.explain(), hint=messages.hint( "peak_off" if not _pg.engaged else "peak_blind" if _pg.blind else "peak_on" if _pg.within_aim else "peak_reserve" if _pg.floor_limited else "peak_saturated" if _pg.saturated else "peak_on")) # OPT-04: price arbitrage (§10.1 priority 3): ONE `set` claim at # P_ARBITRAGE; arbitrage.py carries the argument. ⚠️ AFTER THE PEAK # BLOCK BECAUSE IT READS ITS CONCLUSION (`engaged_now`). ⚠️ The # three-state collapse lives in arbitrage.guard_state(), NOT here (None # = the guard never ran, without P1). ⚠️ NO FETCH ON THIS PATH. ⚠️ The # uncertainty set is NOT empty (no weather forecast is built). # TARIFF-11: `arb_at` keeps the status row honest. # (DECISIONS/MAIN-orchestration-archive.md) self.arb, self.arb_at = None, now self.arb_headroom_w = None self.room_charge = False # OPT-11 P3: with battery modes on, each battery's mode decides who # trades (`_mode_roles()`, `_mode_trade()`, below, which read the # same headroom), and this site-wide trade is not run. site_trade = arbitrage.enabled(self.o) and not self.modes_on if site_trade or self.modes_on: # SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED: one evaluation # feeds both the sizing below and `arbitrage.sized_from_meter()`, # so the caveat on the recorded watts is never answered from a # different cycle's guard. # (DECISIONS/MAIN-orchestration-archive.md) self.arb_headroom_w = peakguard.charge_room_w(self.peak_guard, self._peak_proj) if site_trade: floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct) self.arb = arbitrage.decide( self.fresh_tariff_plan(now), now, peak_guard_active=arbitrage.guard_state(self.peak_guard), # SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS # ONE: the watts the billed quarter can still afford clamp the # buy leg (a switch alone gave 28 charge onsets an hour on # TEST-23). ⚠️ It reads the peak block's conclusion, so it must # stay after it. (DECISIONS/MAIN-orchestration-archive.md) charge_headroom_w=self.arb_headroom_w, # The emptiest pack again, and for the sell side that is the # right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there # is one `soc` argument - `pack_floor_soc` names that residual # rather than hiding it; a per-pack charge ceiling is CFG-04's, # and at the shipped roster of one the two reductions are the # same number. soc=self.pack_floor_soc, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), uncertainty=frozenset({Uncertainty.NO_WEATHER}), # OPT-09. PER-PACK SIZING, not `max_w`: the trade is sized to # the packs whose own state of charge permits that direction # (`trade_power_w()`). ⚠️ At N=1 both sides are `max_w` # exactly. ⚠️ On the sell side this is a no-op today (`soc=` # below is the EMPTIEST pack); test_packshare.py part 9 grades # it. (DECISIONS/MAIN-orchestration-archive.md) charge_w=charge_w, discharge_w=discharge_w, soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct) # SITE, and arbitrage.claim() says so on the object it builds. It # buys and sells at the grid against a day-ahead price: the price is # a property of the connection and the energy crosses the meter. arb_claim = arbitrage.claim(self.arb) if arb_claim is not None: claims.append(arb_claim) self.room_charge = self.arb.action == arbitrage.CHARGE # Edge-logged, for the peak guard's reason: the action log holds 40 # lines and a trade lasts a whole hour, so a line per cycle would # flush the context that explains it. # (DECISIONS/MAIN-orchestration-archive.md) if self.arb.action != self._arb_action: self._arb_action = self.arb.action self.log_event(self.arb.explain(), hint=messages.hint( "trade_charge" if self.arb.action == arbitrage.CHARGE else "trade_give" if self.arb.action == arbitrage.DISCHARGE else "trade_off")) # maintenance ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST # STAYS SHORT. (DECISIONS/MAIN-orchestration-archive.md) for inv, result in zip(self.inverters, maint_results): if result.owns_setpoint: # PER-INVERTER: §12's recalibration is a PACK procedure, filed # on the pack whose state machine produced it (`inv`, # MAINT-05). ⚠️ IT STAYS `SCOPE_UNIT`, never a site claim # (ARCH-01: per-inverter limits filed site-wide are the # cross-inverter `lo > hi`). # (DECISIONS/MAIN-orchestration-archive.md) claims.append(Claim.set("maintenance", P_MAINTENANCE, float(result.setpoint_w or 0.0), result.phase, actuator=self.actuator_of("maintenance", inv))) self.sat_count = 0 elif result.charge_only: # Money outranks the maintenance schedule: while the projection # is over the cap the charge-only shaping is not claimed, so # the loop can shave. ⚠️ Shaping is `hi <= 0` and the peak # claim `lo >= 0`; both filed give `lo > hi`, which resolves to # 0 W in the quarter that sets the bill, so the higher claimant # SUPPRESSES the lower. ⚠️ THE CONJUNCTION LIVES IN # peakguard.shaping_suspended(), NOT HERE. One suspension flag # per SITE (`arbitrate()` allows one pack in CHARGE). # (DECISIONS/MAIN-orchestration-archive.md) if peakguard.shaping_suspended( self.peak_guard, self.peak_fc, self.peak_aim_w()): # Edge-logged: level-logging here would write one line per # cycle for as long as the event lasts and flush the 40-line # action log of the very context that explains it. if not self._maint_shape_suspended: self._maint_shape_suspended = True self.log_event("peak at risk - maintenance charge shaping " "suspended so the peak claim is not " "contradicted", hint=messages.hint("maint_paused")) else: self._maint_shape_suspended = False hi = 0.0 reason = "charge-only" if self.cheap: # ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND # READ OF `self.o`: a literal fallback here let the # shaping and the state machine use different rates # from the same install. # (DECISIONS/MAIN-orchestration-archive.md) floor = maintenance_charge_floor( self.maint.cfg.charge_w, self.peak_fc, self.peak_aim_w()) floor = self._charge_headroom(floor, claims) if floor > 0: hi, reason = -floor, "cheap-window charge" # PER-INVERTER, like the SET above and for the same reason - # the shaping is about the pack being charged, which is `inv`. claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason, actuator=self.actuator_of("maintenance", inv))) # OPT-11: each battery's mode, a soft unit limit at P_MODE. Filed only # with battery modes on, and never on a battery in any maintenance # phase (maintenance outranks the mode). The range always holds 0 W # (packmode.mode_range()) and gives way to the peak floor only as far # as needed, so the two never meet in `lo > hi` # (arbiter._unit_modes()). (DECISIONS/MAIN-orchestration-archive.md) if self.modes_on: tick = self._mode_roles(now, maint_results) for inv, result in zip(self.inverters, maint_results): if result.phase != IDLE: continue pm = self.pack_mode(inv) lo, hi = packmode.mode_range(pm, inv.max_w) rlo, rhi = tick.ranges.get(inv.name, (lo, hi)) lo, hi = max(lo, rlo), min(hi, rhi) claims.append(Claim.limit("mode", P_MODE, lo, hi, pm.key, actuator=self.actuator_of("mode", inv))) # the grid-following loop. ⚠️ SAFETY-10: `or self.shadow`: the loop is # the leg a shadow run most needs; it is safe because the loop's # `prev_w` is `self.target`, re-seeded to what is on the wire. # SAFETY-52: a refusal learned while control ran does not survive a # stop or a start. (DECISIONS/MAIN-orchestration-archive.md) if bool(self.auto) != self._refusal_auto: self._refusal_auto = bool(self.auto) self._refusal_end("control started" if self.auto else "control stopped") for inv in (self.inverters if len(self.inverters) > 1 else ()): self._unit_refusal_end(inv, "control started" if self.auto else "control stopped") # SAFETY-73: each unit's own refusal, engaged and ended on its own # reading, before the loop reads `self.target` (an end reseeds it). _probing = {} if len(self.inverters) > 1 and self.inputs_ok: self._unit_refusals() _probing = {inv.name: self._unit_probe(inv) for inv in self.inverters if inv.refused} # SAFETY-73: each refusing unit's own P_SAFETY claim: a hard unit limit # that always intersects (OPT-11's P_MODE sits inside it), closed in # the refused direction, so the allocator hands the share to the others # within their own limits; a pin at PROBE_W during its probe. Filed # before the loop so OPT-11's loop bound sees it. # (DECISIONS/MAIN-orchestration-archive.md) for inv in self.inverters: if not inv.refused: continue if _probing.get(inv.name): claims.append(Claim.set("refused", P_SAFETY, inv.refused * _ctl.PROBE_W, _ctl.PROBING, actuator=self.actuator_of("refused", inv))) else: claims.append(Claim.limit("refused", P_SAFETY, *_refused_interval(inv.refused), _ctl.REFUSED, actuator=self.actuator_of("refused", inv))) # --- SAFETY-78: a pinned drain is a floor under the site target ----- # After every unit claim, so the floor is what the pin really places. drain = self._drain_floor(claims, maint_results) if drain is not None: claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain, reason="maintenance drain", actuator=self.actuator_of("drain"))) self._peak_over_drain(claims, maint_results) # --- SAFETY-86: while a HOLD gives, the site stays at the peak floor -- # So the loop never asks for watts the hold keeps back. hold_hi = self._peak_over_hold(claims, maint_results) if hold_hi is not None: claims.append(Claim.limit("hold", P_MAINTENANCE, hi=hold_hi, reason="maintenance hold", actuator=self.actuator_of("hold"))) # --- SAFETY-91: a drain rises before a held battery gives ------------ self._drain_rise(claims, maint_results) # --- SAFETY-84: a cheap-window charge is a ceiling over the site ---- charge = self._charge_ceiling(claims) if charge is not None: claims.append(Claim.limit("charge", P_MAINT_SHAPE, hi=charge, reason="maintenance charge", actuator=self.actuator_of("charge"))) # --- OPT-11 P3: the site trade, after every unit claim -------------- # Sized from what the batteries may place with their modes and hard # limits (the loop bound), so none is asked for watts it may not give. mode_trade, trade_was = False, self._mode_trade_was if self.modes_on: mode_trade = self._mode_trade(claims, now) self.room_charge = self.room_charge or ( mode_trade and self.arb.action == arbitrage.CHARGE) self._mode_trade_was = mode_trade self._charge_track(claims, maint_results) _live96 = set(self.live_units()) self._loop_start = {} if (self.auto or self.shadow) and self.inputs_ok: # ⚠️ prev is the ARBITER's last output, not the loop's own wish: if # something outranked the loop, that is what the hardware did. The # integrator re-seeds from the same (entering failsafe re-seeds it # to zero). (DECISIONS/MAIN-orchestration-archive.md) moved = self.loop_w is None or self.target != self.loop_w if moved: self.i_w = self.target # OPT-11 P3: a run edge (a mode trade ended) reseeds the loop, and # OPT-15 holds it there until the meter shows the step. if trade_was and not mode_trade: self._reseed_run_edge(claims, now) moved = False loop_grid = self._edge_grid(mode_trade or moved, now) # SAFETY-52: another writer overrode the loop, so a refusal learned # against the loop's own ask may no longer describe the wire. # (DECISIONS/MAIN-orchestration-archive.md) rejudged = 0 if self.loop_w is not None and self.target != self.loop_w: rejudged = self.batt_refused # SAFETY-96: a battery that dropped out of the split leaves the # loop's start point (SAFETY-93's own if its charge ended too). self._reseed_drop(_live96) # SAFETY-93: a charge that ended leaves the loop's start point. self._reseed_charge_end(maint_results) # SAFETY-95: then a drain that ended, last (it reads the start # the others left). self._reseed_drain_end(maint_results, _live96) # No probe while it is judged afresh (the loop does not hold the wire). probe = False if rejudged else self._refusal_probe() if len(self.inverters) > 1: probe = any(_probing.values()) # ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF. # The anti-windup test in compute() is `abs(prev_w - actual_w) > # saturation_w` and `prev_w` is the SITE target; one inverter's # power at N=2 would trip the saturation freeze permanently. # (DECISIONS/MAIN-orchestration-archive.md) site_batt = self.site_batt_w # SAFETY-103: what each battery can be doing (its reading may be # older than the meter). self._track_reads() # SAFETY-96: a battery the loop cannot move counts at its command, # so its own power does not freeze the loop (the meter has it). site_batt += self._held_out_w(_live96) # TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED # AGAINST. (DECISIONS/MAIN-orchestration-archive.md) loop_prev = self.target decision = compute( prev_w=loop_prev, # SAFETY-95: a drain's tail is not the loop's to chase. # OPT-15: `loop_grid` is the grid, or the target while a run # edge is held. grid_w=(self._drain_grid_w(_live96) if self._drain_tail else loop_grid), actual_w=site_batt, tuning=self.tuning, sat_count=self.sat_count, i_w=self.i_w, # SAFETY-52 at one inverter only: with several, OPT-09's pack # limit already closes an empty battery's discharge end. # SAFETY-73: at N>1 the site law holds 0 W only while every # unit refuses the same way. Otherwise None, the old law. refused=(0 if rejudged else self.batt_refused) if len(self.inverters) == 1 else self._site_refused(), probe=probe, # SAFETY-94: at N=1 the engage also needs the reading to have # given nothing for the whole span, as at N>1. gave_nothing=(bool(self._gave_nothing(self.inverters[0])) if len(self.inverters) == 1 else None), # SAFETY-97: "gives nothing" is centred on the idle reading. idle_w=self._site_idle_w(), hold=self.answer.hold, hold_w=self.answer.hold_w, # SAFETY-128 # OPT-11: None unless battery modes are on. SAFETY-85: or while # maintenance holds a battery at N>=2, so the loop does not # wind past what the others can give and jump when it ends. bound=(self.loop_bound(claims) if self.modes_on or ( len(self.inverters) > 1 and any(r.phase != IDLE for r in maint_results)) else None), ) if decision.frozen and self.sat_count < self.tuning.saturation_cycles: self.log_event(f"saturation freeze ({self.target:.0f} W vs " f"{site_batt:.0f} W)", hint=messages.hint("not_keeping_up")) self.sat_count = decision.sat_count self.i_w = decision.i_w self.loop_w = decision.target_w self._loop_decision = decision if len(self.inverters) == 1: self._refusal_update(decision, loop_prev, site_batt, probe, rejudged) # TEL-10. ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS: `loop_i` # is `Decision.i_w`, the value BEFORE the clamp, slew and # quantisation, so it is NOT `setpoint` and must not be published # from it; `loop_lead` is how far it stands ahead of the command; # `loop_reason` is `compute()`'s own word and NOT `self.reason`. # (DECISIONS/MAIN-orchestration-archive.md) self.loop_i_w = decision.i_w self.loop_i_lead = decision.i_w - loop_prev self.loop_reason = decision.reason # SAFETY-03. ⚠️ READ THE BOOLEANS, NOT `decision.reason`: `reason` # is overwritten by slew-limiting or the saturation freeze after # the clamp, so a string test is silent on the cases a winding loop # most often presents as. SAFETY-40: every number comes from THIS # decision, at clamp time. # (DECISIONS/MAIN-orchestration-archive.md) self.loop_clamped = ( decision.i_clamped or decision.clamped, loop_clamp_note(decision), decision.clamped, ) self.loop_clamp_facts = ( (messages.Clamp(None, decision.requested_w, decision.clamped_w, "rating"),) if decision.clamped else ()) # SITE. compute() regulates `grid_w` - the P1 measurement - to # zero. Its subject is the meter, and §6.2's architecture is ONE # integrator on P1 whose output is then SPLIT. # (DECISIONS/MAIN-orchestration-archive.md) claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason, actuator=self.actuator_of("loop"))) else: # Stopped or blind: no accumulation may survive the outage. self.i_w = 0.0 self.loop_w = None self._live_was = frozenset(_live96) # TEL-10, and it is the same zero the line above writes rather than # a second opinion about it. ⚠️ `not-running` rather than a stale # `tracking`: a reason row that keeps repeating the last word the # loop said while the loop is not running is a row that reads as # health, which is the failure `publish()`'s own None-dropping note # is about one field over. self.loop_i_w = 0.0 self.loop_i_lead = 0.0 self.loop_reason = "not-running" # A loop that is not running cannot be winding. Clearing this here # rather than letting the last value stand is what makes the alarm # de-assert when control is stopped. self.loop_clamped = (False, "", False) self.loop_clamp_facts = () self._loop_decision = None # SAFETY-28. A new resolution is a new tick, and the previous record is # stale from this line onwards. Incremented BEFORE resolve() so that the # record resolve() builds is stamped with the tick it is for. self.alloc_tick += 1 # ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. A METHOD, NOT # A DICT: the weighting is by ENERGY HEADROOM, so it depends on the # direction (the sign of the site target, chosen inside `resolve()`; # see `pack_weights()`). ⚠️ A PREFERENCE, NOT A PERMISSION: a weight # cannot stop a unit being commanded. # (DECISIONS/MAIN-orchestration-archive.md) _live = self.live_units() while True: resolution = resolve(claims, units=self.units, live=_live, weights=self.pack_weights, tick=self.alloc_tick, reserve=self._held_back()) # SAFETY-73: a probe pin stands only while the resolved site target # asks at least PROBE_W its way; below that a sibling would carry # the opposite direction (circulation), so the pin goes back to the # closed limit. (DECISIONS/MAIN-orchestration-archive.md) _void = [k for k, c in enumerate(claims) if c.source == "refused" and c.kind == "set" and c.value * resolution.target_w < _ctl.PROBE_W ** 2] if not _void: break for k in _void: claims[k] = Claim.limit( "refused", P_SAFETY, *_refused_interval(1 if claims[k].value > 0 else -1), _ctl.REFUSED, actuator=claims[k].actuator) self.allocation = resolution.allocation if self.modes_on: self._mode_fault_log(resolution) # THE TWO REFUSALS ARE NOT THE SAME REFUSAL: both REPLACE the # resolution with a 0 W failsafe, written like any other cycle; they # differ in WHO refused, hence two branches and two messages. ⚠️ # NEITHER MAY BE LEFT UNGRADED (test_peakguard.py part 10h). # (DECISIONS/MAIN-orchestration-archive.md) if not resolution.allocated: self.log_event("ARBITER: " + resolution.allocation.explain(), hint=messages.hint("split")) resolution = Resolution(0.0, "failsafe", None, False, resolution.considered, unit_bounds=resolution.unit_bounds, shares={}, allocated=False, scope_errors=resolution.scope_errors, allocation=resolution.allocation, winner_priority=resolution.winner_priority) # 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT. ⚠️ # RE-AIMED BY DEV-02, NOT DELETED: a roster entry with no writer would # have the site target written to the others (over-command by its # share); "nothing" here means 0 W. Not folded into `live_units()`: a # dead link is dropped and announced, an undeliverable roster stops the # site. (DECISIONS/MAIN-orchestration-archive.md) elif {u for u in resolution.shares if not self._can_write(u)}: undeliverable = ", ".join( sorted(u for u in resolution.shares if not self._can_write(u))) self.log_event( f"ARBITER: the site target was allocated across " f"{len(resolution.shares)} inverters and this controller has no " f"setpoint entity for {undeliverable} - refusing to command a " f"partial site", hint=messages.hint("no_setpoint")) # ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE # DEV-02 THAT IS LOAD-BEARING: the write loop commands # `shares.get(name, 0.0)`, so carrying the split through would # write the numbers this branch declined, or nothing at all when # they equal what stands (an unchanged value is not re-sent). # (DECISIONS/MAIN-orchestration-archive.md) resolution = Resolution(0.0, "failsafe", None, False, resolution.considered, unit_bounds=resolution.unit_bounds, shares={}, allocated=True, scope_errors=resolution.scope_errors, allocation=resolution.allocation, winner_priority=resolution.winner_priority) # 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its # share, a real change in what the site can deliver, and an operator # who is not told reads a working site. Edge-triggered on the SET of # dropped units (the action log is a log of events, §15). # (DECISIONS/MAIN-orchestration-archive.md) _dropped = tuple(u for u, _why in (self.allocation.dropped if self.allocation else ())) if _dropped != self._dropped_last: for _u, _why in (self.allocation.dropped if self.allocation else ()): self.log_event(f"ALLOCATOR: {_u} dropped from the split " f"({_why}) - the remaining inverter(s) now carry " f"its share of the site target", hint=messages.hint("unit_dropped", unit=_u)) if self._dropped_last and not _dropped: self.log_event("ALLOCATOR: every inverter is back in the split", hint=messages.hint("units_back")) self._dropped_last = _dropped # 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER. # `unmet_w` is the site target the envelopes could not absorb; not a # broken allocation (fusing it with `ok` would let one saturated # inverter park the site), but the site failing to deliver what the # loop asked. Edge-triggered on the VALUE. # (DECISIONS/MAIN-orchestration-archive.md) _unmet = int(self.allocation.unmet_w if self.allocation else 0) if _unmet != self._unmet_last: if _unmet: self.log_event( f"ALLOCATOR: {_unmet:+d} W of the site target could not be " f"placed on any inverter - {self.allocation.explain()}", hint=messages.hint("unmet", w=_unmet)) else: self.log_event("ALLOCATOR: the whole site target fits again", hint=messages.hint("unmet_gone")) self._unmet_last = _unmet if resolution.scope_errors: # A claim reached the arbiter on an actuator its source's scope # does not permit: a BUG in the filing site, not a state to survive # (a per-inverter constraint in the site list is the `lo > hi` this # card removes). Loud, every cycle; the resolution is unchanged. # (DECISIONS/MAIN-orchestration-archive.md) _LOG.error("A limit was filed on the wrong inverter, which is a " "software fault. %s.", " / ".join(resolution.scope_errors)) if resolution.contradiction: self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log", hint=messages.hint("clash")) # SAFETY-02's arming gate: was the number chosen by the grid-following # loop, or by a strategy that commands against the meter on purpose? # Only the former is expected to agree in sign with P1. ⚠️ THE # EXPRESSION LIVES IN watchdog.py, NOT HERE: read loop_is_following() # before touching the gate. (DECISIONS/MAIN-orchestration-archive.md) self.following = loop_is_following(resolution, self.loop_w) self.target = resolution.target_w self.reason = resolution.explain() # UX-08: the same answer in plain words, for the page's "Why" line; # `reason` stays the technical record the log and the checks read. # UX-10: every key `why_key()` can return has words in # messages.WHY_WINNER. (DECISIONS/MAIN-orchestration-archive.md) _why = why_key(resolution.winner, claims) if _why == "inputs missing" and not resolution.contradiction: # UX-11: past the hold, say which reading is missing. self.why_words = messages.why_missing(self.missing_reading(), stale_after) else: self.why_words = messages.why( _why, resolution.target_w, resolution.bound_by, contradiction=resolution.contradiction, raised=why_raised(resolution, claims)) # DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE, NEVER THE SITE TARGET # (handing each unit `self.target` is the ARCH-01 fault: each supplies # it in full and the gain at the meter becomes N times the per-unit # gain). At N=1 this is byte-identical. ⚠️ A unit not in `shares` is # commanded 0 W, not left standing: an inverter holds its last command # for ever. (DECISIONS/MAIN-orchestration-archive.md) self._loop_alarm_input(resolution) writes = [] for inv in self.inverters: inv.target = float(resolution.shares.get(inv.name, 0.0)) writes.append(self.write_setpoint(inv.target, unit=inv)) await asyncio.gather(*writes) # SAFETY-03, and its boundary is the WRITE. ⚠️ NOT THE SAME BOUNDARY AS # SAFETY-18's BELOW, THOUGH THE CALLS ABUT: this one reads # `self.clamp_last`, None until write_setpoint() has run, so it must # follow the write (test_clamp.py part 6). Two guards, two orderings: # do not "tidy" them into one rule. # (DECISIONS/MAIN-orchestration-archive.md) await self.check_output_clamp() # SAFETY-18, and its boundary is the ASSIGNMENT. ⚠️ THE BOUNDARY IS # `self.target = resolution.target_w` ABOVE, NOT write_setpoint(): both # terms of the conjunction must describe the SAME moment, so moving it # above the assignment grades the previous cycle's command against this # cycle's meter (test_freeze.py part 5). # (DECISIONS/MAIN-orchestration-archive.md) await self.check_meter_frozen() # --- PRICE-01: no price for now, said once and taken down once -------- # Not a safety check, so it is one of the cheap ones: an attribute test # and, on a change only, one Home Assistant call. await self.check_price_gap() await self.check_fixed_note() # SAFETY-31's alarm, and its boundary is the LATCH, not a number: it # grades `self.circ_wd.tripped`, advanced near the top of this method. # Called on EVERY cycle, not only the one that trips, because its # second job is re-raising the alarm after a restart. # (DECISIONS/MAIN-orchestration-archive.md) await self.check_circulating() # SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's: # it grades `inv.delivery_wd.tripped`, and is called on EVERY cycle # because its second job is re-raising the alarm after a restart. # (DECISIONS/MAIN-orchestration-archive.md) await self.check_not_delivering() # --- SAFETY-07's alarm, and it is NOT the engagement ------------------ # Its boundary is unconstrained: it grades `self.peak_guard`, which this # method assigned above and nothing since has touched. await self.check_peak_saturated() # SAFETY-24's alarm, and it is the OTHER way the money gets spent. Same # boundary argument as check_peak_saturated(); kept as its own method # because the two states are mutually exclusive and want OPPOSITE # operator actions (saturated: shed load; this: the pack is at its # floor and the quarter is not being shaved). # (DECISIONS/MAIN-orchestration-archive.md) await self.check_peak_floor_limited() # TARIFF-01, and it is LAST on purpose: banking a quarter is # bookkeeping about a moment that has passed, and a slow SD card must # never sit between the meter and the setpoint. It grades neither # `target` nor `standing`. (DECISIONS/MAIN-orchestration-archive.md) self.peak.sync(self.p1) # SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS: every # guard above has read `self.target`, the resolved intent (SAFETY-18 # grades exactly that); re-seeding before them would hand each a 0 W no # decision produced. See `_shadow_settle()`. # (DECISIONS/MAIN-orchestration-archive.md) self._shadow_settle(resolution) def _shadow_settle(self, resolution) -> None: """SAFETY-10. Record the decision, then re-seed the carried command (`self.target`, each `inv.target`) to `shadow.WIRE_W`, in this order because the second destroys the first's evidence. ⚠️ THE RE-SEED IS A SAFETY PROPERTY: compute's slew base, the sign watchdogs (`standing_w()`) and SAFETY-42 read the carried command as a statement about the HARDWARE; a shadow decision there would latch a trip that survives into live operation. One log line per CHANGE of decision. (DECISIONS/MAIN-orchestration-archive.md)""" engaged = self.shadow if engaged != self._shadow_was: # ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT (a # "shadow mode off" line at every start pushed a clamp episode out # of the 40-row log); coming up IN shadow mode always gets a line. # (DECISIONS/MAIN-orchestration-archive.md) first = self._shadow_was is None self._shadow_was = engaged if engaged: self.log_event( "SHADOW MODE ON - " + shadow.why( self.o, control_started=self.auto) + f"; inverter held at {shadow.WIRE_W:.0f} W", hint=messages.hint("shadow_on")) # UX-10: INFO. It follows a setting or a button press, and # no person has to act on it. _LOG.info( "Shadow mode is on, so every inverter stays at %.0f W and " "nothing the add-on decides is sent. %s.", shadow.WIRE_W, shadow.why(self.o, control_started=self.auto)) elif not first: self.log_event( "Shadow mode off: the add-on controls the battery again, " f"starting from {shadow.WIRE_W:.0f} W", hint=messages.hint("shadow_off")) _LOG.info( "Shadow mode is off, so the add-on controls the battery " "again, starting from %.0f W.", shadow.WIRE_W) if not engaged: self.shadow_would_w = None self.shadow_sized_from_meter = False self._shadow_winner = None return self.shadow_would_w = float(resolution.target_w) # SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY: all three rungs live in # `arbitrage.sized_from_meter()`; a rung written at a call site is one # no check can reach with the other two clear (TEST-16). # (DECISIONS/MAIN-orchestration-archive.md) self.shadow_sized_from_meter = arbitrage.sized_from_meter( self.arb, self.arb_headroom_w, winner=resolution.winner) # ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE (a loop # target that that wanders by a watt would be the whole log). ⚠️ # SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE: the same winner at the # same watts with and without the caveat are two different claims. # (DECISIONS/MAIN-orchestration-archive.md) edge = (resolution.winner, round(float(resolution.target_w)), self.shadow_sized_from_meter) if edge != self._shadow_winner: self._shadow_winner = edge self.log_event(shadow.log_line( would_w=resolution.target_w, reason=resolution.explain(), sized_from_meter=self.shadow_sized_from_meter), hint=messages.hint("shadow_decision", sw=resolution.target_w)) self.target = shadow.WIRE_W for inv in self.inverters: inv.target = shadow.WIRE_W def _loop_alarm_input(self, resolution) -> None: """SAFETY-85. The loop's clamp counts only on a cycle the loop decided (a rating may cut it). A battery another owner set to discharge counts when it gave less than it was told for `saturation_cycles` cycles in a row. DECISIONS/SAFETY-85-no-false-not-keeping-up.md""" d = self._loop_decision if d is None: self._short_run = 0 self._asked_hist = {} self._wire_lost, self._handback = False, None return unmet = self.allocation.unmet_w if self.allocation is not None else 0 cut = (resolution.bound_by or "").split("(")[0] owns = (resolution.winner == "loop" and not d.held and not unmet and (resolution.target_w == d.target_w or cut in ("device", "supervised"))) # Graded against the least that stood on the wire (0 W in shadow # mode) over SHORT_CYCLES cycles and SHORT_SPAN_S seconds. now, asked = self._now_mono, {} for inv in self.inverters: h = self._asked_hist.setdefault(inv.name, []) h.append((now, inv.standing_w())) while len(h) > SHORT_CYCLES and ( now is None or h[0][0] is None or now - h[0][0] > SHORT_SPAN_S): h.pop(0) asked[inv.name] = min(w for _t, w in h) others = [inv for inv in self.inverters if not owns or allocator.pin_w(resolution.unit_bounds.get(inv.name)) is not None] short = [(inv, asked[inv.name], inv.batt) for inv in others if inv.batt is not None and delivery_short(asked[inv.name], inv.batt, self.tuning.saturation_w)] self._short_run = self._short_run + 1 if short else 0 settling = self._settling(owns, now) if owns and self.loop_clamped[0] and not ( settling and not self.loop_clamped[2]): return engaged = self._short_run >= self.tuning.saturation_cycles named = len(self.inverters) > 1 self.loop_clamped = (engaged, shortfall_note( [(inv.name if named else None, a, g) for inv, a, g in short]) if engaged else "", False) self.loop_clamp_facts = () def _settling(self, owns, now) -> bool: """SAFETY-119: does the loop's bound on this cycle count? Not within a settle (HANDBACK_CYCLES cycles and SHORT_SPAN_S) after another owner handed the wire back; across all hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles in any ROLLING_S. DECISIONS/SAFETY-121-bound-every-quarter.md""" def aged(t0, span): return now is None or t0 is None or now - t0 >= span if not owns: self._wire_lost, self._handback = True, None return False if self._wire_lost: self._wire_lost, self._handback = False, (now, 0) if self._handback is None: return False tc, n = self._handback n += 1 self._handback = (tc, n) if aged(tc, SHORT_SPAN_S) and n >= HANDBACK_CYCLES: self._handback = None return False if self.loop_clamped[0] and not self.loop_clamped[2]: kept = tuple(t for t in self._hidden if now is None or ( t is not None and now - t < ROLLING_S)) if len(kept) >= ROLLING_BOUND_CYCLES: self._hidden = kept return False self._hidden = kept + (now,) return True async def check_output_clamp(self) -> None: """SAFETY-03. Alarm whenever a clamp actually engaged this cycle. Runs AFTER write_setpoint() (the clamp is arithmetic done inside the write). ⚠️ PUBLISHING IS NOT IN THE WRITE PATH: a stalled supervisor must never delay or drop a frame. ⚠️ THIS IS NOT A TRIP: no claim, no latch, 0 W is not commanded (§11.2: clamp and alarm); turning it into one is a deliberate change. (DECISIONS/MAIN-orchestration-archive.md)""" # ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT: `clamp_last` is on # the Inverter, `ClampWatchdog` is one per controller (an anti-flood # counter), so any unit that clamped is an engagement and the reason # names it. (DECISIONS/MAIN-orchestration-archive.md) clamped_units = [inv for inv in self.inverters if inv.clamp_last is not None and inv.clamp_last.engaged] engaged_write = bool(clamped_units) engaged_loop, loop_note, loop_bug = self.loop_clamped engaged = engaged_write or engaged_loop # SAFETY-40: only an over-rating is a bug; the integrator bound is not. bug = engaged_write or loop_bug if engaged_write: _named = len(self.inverters) > 1 reason = write_clamp_note([(inv.name if _named else None, inv.clamp_last) for inv in clamped_units]) facts = [messages.Clamp(inv.name if _named else None, inv.clamp_last.requested_w, inv.clamp_last.applied_w, inv.clamp_last.bound) for inv in clamped_units] else: reason = loop_note if engaged else "" facts = self.loop_clamp_facts if engaged else () fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc), bug=bug, facts=facts) # THE ACTION LOG, AND IT IS COALESCED PER EPISODE Every engagement is # recorded, but consecutive ones are recorded by UPDATING one row's # count rather than inserting a row each. # THAT IS NOT COSMETIC, AND IT WAS FOUND THE HARD WAY. # (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. (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, AND THAT IS THE WHOLE POINT. (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. # (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. (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, and this guard has nothing BUT the alarm. (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. (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. 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, for MqttPublisher's reason: a supplier's outage, a certificate expiry, a malformed feed - all of it is pricing, and the battery does not care. (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. # (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. # (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, and that was the bug CFG-01 exists # to remove: a max_w above the device's rating meant write_setpoint() # silently clamped and the configured ceiling was never the real one. # ⚠️ AND THE `if entity:` THAT USED TO WRAP THE WHOLE BLOCK WAS A # READER THAT FAILED PERMISSIVE - CFG-04's own hazard, in this file. # (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 running alongside the arrival trigger. # THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN. # (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. # THAT GUARANTEE IS TRANSPORT-CONDITIONAL, and saying so # matters more than the guarantee: with `meter_source: off`, # read_inputs() takes self.grid straight from hass.number() # with NO age gate at all, so a Home Assistant entity that has # gone quiet reads as a current value on that path with or # without this change. # (DECISIONS/MAIN-orchestration-archive.md) await self.cycle() # ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence # longer than ~30 s as "the controller is gone" and zeroes the # inverter. Refreshing the SAME value is what proves we are alive. # ONE DEADLINE PER INVERTER, AND THE REFRESHES GO OUT TOGETHER. # (DECISIONS/MAIN-orchestration-archive.md) await self.send_heartbeats(asyncio.get_running_loop().time(), heartbeat) self.publish() self.loop_pass_mono = time.monotonic() await asyncio.sleep(1) async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None: """Refresh every inverter whose own deadline has come. AC 6 lives here. IT IS A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` FOR ONE REASON: the acceptance criterion is "a hung write to A cannot consume B's layer-1 budget - verified by injecting the hang", and a check cannot inject a hang into the middle of a `while not self.stopping` loop that also sleeps, reads inputs and cycles. (DECISIONS/MAIN-orchestration-archive.md)""" due = [inv for inv in self.inverters if loop_now - inv.last_beat >= heartbeat_s] if not due: return # ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT. # (DECISIONS/MAIN-orchestration-archive.md) for inv in due: inv.last_beat = loop_now # ⚠️ CONCURRENTLY. (DECISIONS/MAIN-orchestration-archive.md) await asyncio.gather(*( self.write_setpoint(inv.target, force=True, unit=inv) for inv in due)) def publish(self) -> None: # ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE. # (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. (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". # (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`. (DECISIONS/MAIN-orchestration-archive.md) "circulation": (None if self.site_circulating_w is None else round(self.site_circulating_w)), # TEL-10. (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 THE VALUE `None` WHEN THERE IS NO # READING, AND ABSENT ENTIRELY WHEN THERE IS NO SOURCE. # AND 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 actually running. # (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. # THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY. # (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() async def command_site_release(self) -> None: """SMA-10. The add-on is going away (Stop, shutdown, restart, task death, config refusal): units that hand back are given back, the rest held at 0 W as `command_site_zero()` does. ⚠️ Safety stops and refusals do NOT come here: they hold 0 W for EVERY unit. A roster with no hand-back unit is `command_site_zero()` line for line. DECISIONS/SMA-10-two-idle-states.md.""" if not any(inv.driver.hands_back for inv in self.inverters): await self.command_site_zero(last=True) return self.target = 0.0 for inv in self.inverters: inv.target = 0.0 await asyncio.gather(*( self._hand_back(inv, force=True) if inv.driver.hands_back else self._write_unit(inv, 0.0, force=True, last=True) for inv in self.inverters)) async def command_site_zero(self, *, last: bool = False) -> None: """Every inverter to 0 W, and every inverter's TARGET to 0 W with it. THE SECOND HALF IS NOT BOOKKEEPING, AND IT IS A HAZARD DEV-02 CREATED. (DECISIONS/MAIN-orchestration-archive.md)""" self.target = 0.0 for inv in self.inverters: inv.target = 0.0 await self.write_setpoint(0.0, force=True, last=last) # -- UI ------------------------------------------------------------------ def checks(self, when: datetime | None = None) -> list: """The status rows. `when` is the reference time for anything billed by the calendar - today's month when it is None, which is what the live page means; `status_payload()` calls this with no argument. IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17). (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. (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, and THE SECOND REASON TO USE THIS ROW RATHER THAN # A NEW MECHANISM. (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 is what set that number, SAY SO - the # operator configured `max_w` and is owed the reason the guard # is armed somewhere else. ⚠️ Either bound may be None on its # own - `hass.limits()` reads `min` and `max` independently and # returns None for whichever attribute is missing or # unparseable - so this renders whatever is there rather than # formatting a None. (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. (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 way # would show a green line over a guard that is switched off. # (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: in both states the money # is going to be spent and a human is the only actuator left. # SAFETY-25. # (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. (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 ASSERTED A FALSE FACT ABOUT A NAMED # ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. # AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. # (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. (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. (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. (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. # (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 rather than a missing row. # (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. (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 TO AN # OPERATOR. (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, and the same honesty `circulating power: n/a` uses one # block up. (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, AND THAT IS A # DELIBERATE DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. # AND WHEN IT IS SET, A FAILED READ IS `ok=False, warn=True` - a # CAUTION, never `bad`. # (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" is the one sentence on this page that may never be # displaced by another condition. # AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE. # (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, so the site is at 0 W either way and the # banner can only carry one sentence. # (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, and the ordering is the same argument # both times: the banner carries one sentence, so it carries the # one that says the widest true thing. # (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. # (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. # (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. # BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT # IS NOT COSMETIC. # (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. # (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. One date on a # site with two schedules has to be the one that comes first, or the # row says "2026-10-01" while a pack recalibrates tomorrow. # (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: both of the numbers an operator # normally reads say the site is idle, and the third says 6 kW is # going round between the packs. # (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, which is the difference # between a 48-hour plan and a 12-hour one on a weekday morning. # (DECISIONS/MAIN-orchestration-archive.md) ("Price history", self.price_history.words), # OPT-04. The strategy's own sentence, not a re-derivation of it - # every refusal it can make already carries a reason a human can # act on, and "Arbitrage: no" would throw all of them away. # TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE UNDER A COMMENT # THAT WAS FALSE, AND TARIFF-11 IS THAT CORRECTION. # (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. (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. # (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 EVERY INVERTER'S LATCH, AND SAYS SO PER INVERTER. # (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. (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 and for the # same reason the per-unit ones are: the condition always goes # away on its own, because the trip commands 0 W everywhere and # a site commanding zero cannot oppose anything. # UNCONDITIONAL, LIKE THE FLAT PAIR ABOVE, AND NOT GUARDED ON # THE ROSTER. # (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: # the condition always goes away on its own, because the trip # commands 0 W everywhere and two inverters at zero cannot # oppose. # AND `_circ_announced` GOES WITH IT. # (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, and for a reason that # is NOT the one above. # (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. (DECISIONS/MAIN-orchestration-archive.md) _m = self._maint_button(now) for msg in _m.force_start(now): self.log_event(msg, hint=messages.hint("maintenance", phase=_m.phase)) return {"phase": self.maint.phase} if what == "maint_abort": # Aborts the pack that is actually cycling - `_maint_button()` again # - and then zeroes the SITE, which is what it always did. _m = self._maint_button(now) for msg in _m.abort(now, "operator"): self.log_event(msg, hint=messages.hint("maintenance", phase=_m.phase)) await self.command_site_zero() return {"phase": self.maint.phase} return None # MAINT-13: a battery power reading at or below minus this many W is a pack # that is taking charge. MAINT_CHARGING_W = 50.0 # SAFETY-61. Exit status when the control task dies; 70 is sysexits(3) # EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md. EXIT_CONTROL_DIED = 70 async def _bounded(awaitable, budget_s: float): """SMA-10. `await awaitable`, abandoning it after `budget_s` without waiting for it to wind down (`wait_for` would). Raises `asyncio.TimeoutError`.""" task = asyncio.ensure_future(awaitable) done, _ = await asyncio.wait({task}, timeout=budget_s) if not done: task.cancel() # a result or error that arrives later has nobody to read it task.add_done_callback(lambda t: t.cancelled() or t.exception()) raise asyncio.TimeoutError return task.result() class ControlTaskDied(RuntimeError): """The control task ended while nothing had asked it to stop.""" def mode_announcement(opts, store) -> tuple: """OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up. (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. (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. # (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()). # (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: a config the system # cannot honour is refused at startup, not survived at runtime. # (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. # (DECISIONS/MAIN-orchestration-archive.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. A # broken broker, a missing library, an API change in paho - all of it # is observability, and the battery does not care. # (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. (DECISIONS/MAIN-orchestration-archive.md) include=optional_keys(opts), # TEL-07. ⚠️ THE NAMES, NOT A ROSTER. # (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. # STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES: see # `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and TARIFF-24 # for why each term is there. # (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. # (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()