"""GoodWe RS485 Controller add-on - entry point and orchestration. WHAT THIS THING DOES, in one paragraph: 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 that figure to an ESP32 which puts it on the inverter's RS485 meter bus. Once a month it runs a full low->full battery cycle so the BMS can balance cells and recalibrate its coulomb counter. ⚠️ THE ONE THING TO UNDERSTAND BEFORE CHANGING ANY OF THIS: the inverter holds the last command it understood FOREVER. It has no meter-timeout of its own. Measured on real hardware: a controller died mid-command and the inverter held 5 kW of discharge for 113 s until a human noticed. Every failsafe in this system exists because of that one fact: layer 1 the ESP32's own watchdog - if we stop refreshing for ~30 s it commands 0 W and KEEPS WRITING it. Stopping is the failure, not the fix. This add-on's heartbeat is what feeds it. layer 2 wind-down before a firmware update, in the ESP32. layer 3 the optional RS485 e-stop, which writes 0 W after 30 s of total bus silence. It is the ONLY thing that covers this add-on's host dying. So: when in doubt, this process stops writing, and the hardware takes the battery to zero on its own. Never "hold the last value to be safe". """ import asyncio import contextlib import functools import json import logging import math import signal import time from datetime import datetime, timedelta, timezone import aiohttp from . import arbitrage from .arbiter import ( P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, Resolution, actuator_for, resolve, ) 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, is_commandable, loop_clamp_note, write_clamp_note, ) from . import freshness from . import homeview from . import messages from .freeze import ( FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w, ) from .hass import HomeAssistant # ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it # and two checks read `app.main.LINK_STALE_S` to build a stale link, so the # window an operator would look up here is the one the roster actually uses. from .inverter import (LINK_STALE_S, UNIT_A, build_roster, roster_entries, roster_names) from .maintenance import ( IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate, ) from .mqtt import (MqttPublisher, announced_units, optional_keys, unit_slugs, unit_state_key) from . import peakguard from . import packshare from . import pv as pv_read from . import shadow from .p1 import P1Ingest, build_source, is_enabled from .price import Uncertainty, brussels, price_option, provider_configured from .price_history import PriceHistory from .static_tariff import resolve_tariff, static_status from .tariff import PeakStore, PeakTracker 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` NOW LIVES IN app/inverter.py AND IS RE-EXPORTED HERE, because the # roster does. It stays importable from this module: four checks read # `app.main.UNIT_A` and it is still the name of the inverter this add-on drives. # DEV-02 is what turned "the one inverter" into a list; `build_roster()` is the # one function CFG-04 changes to put a second entry in it. __all__ = ["UNIT_A", "Controller", "amain", "main"] # --- DEV-02: THE PER-WRITE BUDGET, AND WHY IT IS SMALLER THAN A HEARTBEAT ---- # Every write to every inverter is dispatched CONCURRENTLY and each one is # bounded by this. Both halves are the acceptance criterion, not an # optimisation: one hung `hass.call()` used to sit on the single serialised # write path and eat a SIBLING'S layer-1 budget - the ESP32 commands 0 W after # ~30 s without a refresh, so a transport fault on inverter A could zero a # perfectly healthy inverter B. # # ⚠️ THE NUMBER IS DERIVED, NOT CHOSEN. `heartbeat_s`'s schema maximum is 25 s # (config.yaml) and layer 1's veto is `validate.FIRMWARE_INGEST_VETO_S` (30 s), # so the budget has to fit in what is left: 25 + 4 < 30. That is the whole # argument, and test_inverter.py grades the inequality rather than the literal, # so raising the heartbeat's schema ceiling takes this red instead of taking an # inverter to zero. It is also below `hass.py`'s own 10 s HTTP timeout on # purpose - we would rather abandon a write and re-issue it on the next tick # (one second later, and `last_write` is only updated on an ACCEPTED write, so # the re-issue happens by itself) than hold the control loop for ten seconds. WRITE_BUDGET_S = 4.0 # SAFETY-34: a Core-only restart wipes persistent_notification and does NOT # restart this add-on (measured on TEST-23), so a latched alarm is re-asserted # this often. Why: goodwe docs/DECISIONS.md, SAFETY-34. REANNOUNCE_S = 300.0 # UX-11 review: a quiet meter logs at most one WARNING this often, because on # `ha_signed` a healthy meter under a flat load 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, AND WHY IT IS NOT OPTIONAL # # `static_tariff.resolve_tariff()` may perform an HTTPS fetch. The control tick # is what feeds the ESP32 heartbeat, and layer 1 commands 0 W if that heartbeat # stops for ~30 s - so a supplier's TLS timeout on the tick is a SUPPLIER able # to push this add-on into the failsafe. Every module in the price stack says # so in its own words (static_tariff.static_plan, resolve_tariff, main's # read_inputs) and this is the task those notes were waiting for. # # The tick therefore never awaits a fetch. It reads `self.tariff_plan`, an # attribute this task assigns, and nothing else. # # ⚠️ THE CADENCE IS NOT A GUESS AT HOW OFTEN PRICES CHANGE. A day-ahead feed # publishes ONCE, at 14-16 h CET, and §8.3's carried hours become firm at that # moment; 30 minutes is short enough that the switch is picked up inside the # hour it happens and long enough to be a polite 48 fetches a day. Nothing in # the control law depends on it. TARIFF_REFRESH_S = 1800.0 # ⚠️ AND THE AGE BOUND IS NOT THE SAME QUESTION. A refresh that FAILS still # stores a plan - resolve_tariff() degrades to the §11.4 static fallback, which # is provisional and refuses arbitrage on its own. This bound covers the other # case: the refresh task itself dying, leaving a plan that was true hours ago # standing as if it were current. Three missed refreshes is the threshold, for # `p1.max_age_s`'s reason - one missed poll is not a fault, a run of them is. # A failed read is not an observation, and neither is a stale one. TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S def tariff_refresh_wanted(opts: dict) -> bool: """TARIFF-10. Does this install need the background tariff refresh at all? THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be `arbitrage.enabled(opts)` alone. That option defaults to `false`, and `run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the only caller of `price_history.carry()` - so on a shipped install with a provider configured NOTHING was ever fetched, no plan was ever resolved, `/data/price_history.json` was never created, the *Price history* row read `empty` forever, and TARIFF-09's short-horizon warning could not fire because there was no fetch to warn about. Measured over a 40 h soak of two instances side by side: zero feed requests and `present: false` across all 802 samples on the shipped default, against a resolved plan from the same feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE: OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on it, and the sentence saying a configured provider still fetches, all in one change. config.yaml has said it since TARIFF-02 (852fc7c). Two terms, and they answer two different questions: provider_configured(opts) -> "the operator asked for a price feed", so fetch it, validate it and record the day (§8.3). This is the term TARIFF-10 ADDS. arbitrage.enabled(opts) -> "the operator armed trading". This term is what the gate USED to be, kept verbatim. ⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration that started this task before still starts it; none that did not start it stops anything. That matters because the second term is not redundant: `price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a configuration validate.py explicitly ACCEPTS, and it trades - measured, at `cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile, `ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read `provider_configured(opts)` alone would leave `self.tariff_plan` at None on that site forever and `decide()` would answer "no tariff plan has been resolved yet" on every cycle - silently disarming a strategy the UI says is running, which is the exact failure validate.py's OPT-04 rules exist to refuse. ⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET. With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure arithmetic, no socket - so the second term starts a task that fetches nothing. The true shipped default (no provider, no arbitrage) starts no task at all. What is fetched is decided by the FIRST term only. ⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP. Whether they move energy is `arbitrage.enabled()` at the one call site in `cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still means no trade, ever, on any configuration. """ return provider_configured(opts) or arbitrage.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 {} def pack_energy_of(inv): """OPT-09. ONE roster entry as the view `packshare` consumes. ⚠️ ONE BUILDER, TWO CALLERS, AND THAT IS THE POINT. `Controller.pack_energy` needs the whole roster (for the weights and the trade sizing) and `Controller.cycle()` needs one entry at a time (for that unit's `pack` claim). Written out twice, the two would drift - and the direction they would drift in is a unit whose envelope is computed from a different set of numbers than its weight, which is precisely the class of defect DEV-02 spent a card removing. It is a module function rather than a method on `Inverter` so that `inverter.py` does not have to import `packshare`. """ return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor, inv.capacity_kwh, inv.max_w) # SAFETY-73 review: the evidence must also span this long, not only # SETTLE_CYCLES cycles. SETTLE_CYCLES was derived at the 2 s meter tick (22 s # from its first to its twelfth cycle). A 1 s meter makes 12 cycles only 11 s, # shorter than a healthy battery reading's lag (BATT_OBLIGED_S, 17 s, plus the # plant's dead time), and both inverters of a site were then refused after # ordinary load steps. At 2 s and slower this changes nothing. REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S 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" each stand for several causes, so for those the winning claim's own reason is the key ("stopped", "inputs missing", "sign disagreement"). A failsafe with no claim of its own is the arbiter's "no claim was made", or main.py's refusal to split the power ("unsplit"). """ 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. THE DEFECT THIS PREVENTS (TARIFF-11). `origin`, `product`, the horizon, the firm/carried split and the spacing appeared in NO status row. The row was `static_status()`'s sentence alone, computed on the 30 s I/O-free tick from its own static plan, and it NEVER SAW the provider plan - it read `'no static profile'` at all 802 samples of a 40-hour soak on a site whose feed was answering. The only external evidence a plan existed at all was one `_LOG.info` line, and that line carried the point<->hour mislabel `TariffPlan.words` is fixed for. ⚠️ TWO SOURCES, TWO CLOCKS, AND THE ROW SAYS SO IN WORDS. These sentences are NOT computed at the same moment and presenting them as though they were would be a new version of the defect being fixed: `tick_words` static_status() / the Home Assistant price entities, regenerated on THIS tick, every 30 s, I/O-free; `plan` the background refresh's resolution, up to TARIFF_REFRESH_S old before it is retried and up to TARIFF_MAX_AGE_S (5400 s since TARIFF-12) old before `fresh_tariff_plan()` retires it. So the plan half is stamped with its OWN age in seconds, measured from `tariff_at` - the moment of the fetch that produced it, which TARIFF-12 deliberately does not move on a kept plan. ⚠️ AND `age_s` IS THE CALLER'S MEASUREMENT, NOT THIS FUNCTION'S. A clock read in here would be a second clock disagreeing with the one that decided the plan was fresh enough to pass at all. ⚠️ "NO PLAN" IS SAID OUT LOUD ON A SITE THAT CONFIGURED A PROVIDER. A dynamic site whose refresh has never completed, or whose plan has aged past `max_age_s`, is the exact state the soak measured; rendering it as the bare static sentence is how it stayed invisible for 802 samples. """ # 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, which used to render as two. THE DEFECT THIS PREVENTS (TARIFF-11). The row was `"off (arbitrage_enabled)" if self.arb is None else self.arb.explain()`, under a comment asserting that `self.arb is None` if and only if `arbitrage.enabled(self.o)` was False. IT IS NOT: `cycle()` returns at the input hold - inputs missing, `bad_for < stale_input_s`, default 15 s - BEFORE it reaches `self.arb = None`. Driven: `arbitrage_enabled: true` plus a sensor answering `None` gives `arbitrage.enabled(opts) = True`, `self.arb = None` (its `__init__` value, untouched) and a row reading `off (arbitrage_enabled)` - NAMING AN OPTION THAT IS ON. Past the first completed cycle the same early return leaves the PREVIOUS decision standing instead, which the row then rendered as a current one. THE THREE FACTS, and they are three because they have three remedies: trading is off the operator has not armed it. Nothing is wrong and nothing will happen. no decision this cycle it is armed, and the cycle did not get as far as the arbitrage step. `why` is the control loop's own reason - usually "inputs missing". a decision the strategy's own sentence, with the age of the cycle that produced it. ⚠️ `on` IS READ FROM THE OPTIONS, NOT INFERRED FROM `decision is None`. Inferring it is the whole defect: the option is the only thing that answers "is trading armed", and it is available on every render. ⚠️ THE AGE IS ALWAYS PRINTED, INCLUDING WHEN IT IS ZERO. A row that reports staleness only once it is stale cannot be told, on any single render, from one that never reports it - and the bound TARIFF-10's comment claimed did not exist is ~15 s of it. """ # 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"): # UX-15: a read-only HomeAssistant belongs to setup-only mode, which # builds no controller. Refused here so no controller can hold one. 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, AFTER A REVIEW CATCH. This started life # as `stop=None`, which made a Controller that CANNOT STOP ITSELF the # default one. On the late-refusal path in run_control() that default # meant: log the banner, return, and leave the process alive forever # with no control loop, no exit and no further output - the exact # "inert but looks healthy" failure this whole module exists to refuse. # amain() always passed a real Event, so it was latent, but test_p1.py # was already constructing Controllers without one. No default now, so # the broken shape does not typecheck. 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 finds out that a latch was re-armed on # their behalf, and a guard that raises AttributeError on the way to # saying so is a guard that stops 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 THAT READS A RATING --- # ⚠️ EVERY PER-DEVICE VALUE IN THIS CONTROLLER LIVES ON ONE OF THESE # OBJECTS AND NOWHERE ELSE. app/inverter.py carries the argument for # each one, including the two it deliberately does NOT hold - a `Tuning` # and an integrator, because §6.2's stability property is ONE integrator # on P1 above the split and a second one regulating the same meter is # the two-writers failure the arbiter exists to prevent. # # ⚠️ AND THE STORE MIGRATION RUNS HERE, BEFORE THE FIRST `Inverter` READS # ITS LATCH. `Inverter.__init__` restores SAFETY-02 from # `unit//sign_tripped`; on an install written before DEV-02 that # key does not exist yet and the latch is under the flat one. Migrate # first or every existing tripped install comes up ARMED, into the # fault, with a green banner - which is the exact failure the latch was # persisted to prevent, rebuilt by a key rename. # SAFETY-02's pair, and since SAFETY-30 it is THE SITE COMPARISON's # pair - which at the shipped roster of one is the same thing under a # more careful name. It is what `Inverter.sign_wd` is armed with at N=1, # byte for byte, and what the site detector is armed with at N>=2. 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 AND NOT CARRIED ACROSS. `sign_disagree_w` / `sign_disagree_s` # were measured on two SINGLE-INVERTER bus captures against a command # opposed to THE METER; this comparison is a command against ONE UNIT'S # OWN POWER, where the magnitudes, the transient and the worst # legitimate episode are all different quantities. watchdog.py's # SIGN_OPTION_DEFAULTS carries the campaign that produced these, and # DECISIONS/SAFETY-30-per-unit-sign.md carries the tables. 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 # threshold is the shipped 300 W - carried, because the argument behind # it is about the meter's noise floor and the deadband, neither of which # the roster touches - and the WINDOW is not, because the transient it # rejects is the loop crossing zero at `slew_w` per cycle across a rail # that IS the roster sum. Measured at both rosters on the same stimuli: # 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. There is no second key: the WATTS half of that check is # `Tuning.saturation_w`, read at runtime rather than copied - see # `delivery.MIN_W_IS_SATURATION_W`, and CFG-03 on why a fourth copy of a # default is worth avoiding. self.delivery_cfg = delivery_cfg = DeliveryConfig( hold_s=float(opts.get("delivery_hold_s", DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])), ) # SAFETY-44. THE NAMES THE MACHINES READ, HANDED TO THE MIGRATION THAT # WRITES FOR THEM. This line used to read `migrate_unit_keys(store, # UNIT_A)`, and the constant was wrong in exactly the way the # `maint_migration` call below says the SAME constant would be wrong - # ⚠️ this comment used to cite "line 315" and "two hundred lines # below", and both were wrong. The correction I wrote for them was # wrong too: it said "~54 lines down", which was review's distance to # the WRONG line, carried to a new referent without re-measuring - # inside the sentence apologising for a wrong number. There is no # distance here now, because a distance is a coordinate and the # sentence above already names the thing. On a declared roster the flat # latch landed under # `unit/inverter-a/`, which is on no roster, was read back under the # name it was handed, and stamped the marker. `roster_names()` is the # naming rule itself and returns `[UNIT_A]` on the flat shape, so the # upgrade path every install runs today is byte-identical. # # ⚠️ AND `misdirected` IS UNREACHABLE FROM **THIS** CALL SITE, WHICH THE # COMMIT MESSAGE ORIGINALLY CLAIMED AS A BENEFIT. `unit` is # `roster_names(opts)[0]` and `build_roster()` names its entries from # the same `roster_names()` list, so `unit` is a member of `roster` by # construction and the refusal can never fire here. Review measured it: # dropping the `roster` argument from the `_verify_moves()` call inside # `migrate_unit_keys()` survives 28/28. # # THE ARGUMENT STAYS ANYWAY, and the reason is the one `_verify_moves()` # already gives for keeping its own `roster=None` branch: it is what a # NEW caller gets, and the grading it enables is measured at the # function's own level in test_maintenance.py rather than through here. # What is NOT true is that this call site buys the refusal, so that # claim is gone rather than restated. _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) # ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS # ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none # of `soc_entity`, `batt_entity`, `batt_invert` or `max_w`, and an # operator looking at the Configuration page sees all four with values # in them. validate.py rule 9(e) refuses the one that is genuinely # dangerous - a flat `setpoint_entity` naming an actuator nothing will # write - and the rest cannot be refused (the schema gives them no blank # value), so they are DISCLOSED instead: one line in §15's action log, # at every start, naming the roster it built and the keys it ignored. # Silence here is how "the site is running at half the rating I typed" # takes a week to notice. 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, SUMMED FROM THE ROSTER, NOT `opts["max_w"]`. # This is the integrator bound and the output rail of the SITE loop, # whose output is then SPLIT across the inverters - so the number it # may reach is what the site can deliver, which is the sum of what # each unit may be written. At the shipped roster of one that is # `max_w` exactly, byte for byte; at N>=2 a per-unit `max_w` still # binds each unit's own share through its own `supervised` claim, so # nothing here can over-command a device. max_w=self.site_max_w, # ⚠️ SAFETY-39: THE FALLBACK IS 0 AND 0 MEANS "FOLLOW THE RAIL", # which is what config.yaml ships and what `Tuning`'s own default # is. It used to be 1000 - a third copy of a constant that had # already gone stale against the rail in the other two - and rule # 6's comment in validate.py documents what that costs when the # copies disagree. `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", which is 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. EIGHT `opts.get(key, )` CALLS USED TO LIVE HERE, # and two of those literals disagreed with config.yaml - 2500 against a # shipped 2000, which is a config validate.py refuses. The read now # belongs to `MaintConfig.from_options()`, next to the one in-code # mirror of config.yaml it falls back to, so this file states no opinion # at all about what a missing maintenance key means. Read # maintenance.MAINT_OPTION_DEFAULTS before adding a ninth key: it goes # in config.yaml's `options:` and `schema:` blocks and in that dict, and # nowhere else. # --- MAINT-05: ONE `Maintenance` PER ROSTER ENTRY -------------------- # ⚠️ AND THE MIGRATION RUNS FIRST, FOR THE REASON THE SAFETY-02 ONE # DOES. These objects READ their history in their constructors' wake - # `phase`, `last_completed` - so a history still sitting under the flat # spelling when they are built is a pack that comes up believing it has # never been recalibrated, on a site whose state file says otherwise. # It targets `self.inverters[0].name` and NOT `UNIT_A`: the flat keys # belonged to whichever pack the one FSM was driving, which is the first # roster entry, and a declared `inverters:` roster names its own entries # (`inverter.build_roster()` says so, and its fallback for a nameless # entry is a POSITIONAL `inverter-1`, so `inverter-a` is not even the # default there). Writing `UNIT_A` here would deposit an install's whole # maintenance history under a name that is not on the roster at all. # # ⚠️ AND THAT SENTENCE USED TO BE A COMMENT NOTHING COULD GRADE, WHICH # IS WHY THE ROSTER IS PASSED RATHER THAN A COUNT. Every two-pack # fixture in the suite named its first entry `inverter-a`, so # `self.inverters[0].name` and `UNIT_A` were the same string in every # check that existed and swapping them left all 19 files green - while # on a roster of `["garage", "shed"]` it silently loses the history and # stamps the marker, so the next start skips the migration too. # `migrate_maint_keys()` now grades the namespace it wrote against the # names the machines read; test_maintenance.py sections 9 and 11 drive # a `["garage", "shed"]` roster, which is what makes the mutation red # instead of invisible. 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 - so it does not # re-arm anything. It is still the difference between a pack that is # recalibrated on schedule and one that silently is not, which is # §12's opening sentence, 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")) # ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the # PROCEDURE - how hard to drain, how full to charge, how long to hold - # and the procedure is the same on every pack in the roster; what is per # pack is the SCHEDULE and the HISTORY, which live in the store. A # per-entry config block would be a second shape for CFG-04's roster to # carry and a fifth place for a `maintenance_charge_w` to disagree with # config.yaml. If a site ever needs two different procedures, that is a # roster-schema card and it starts here. _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's identity: one pack, one history, and the keys it # has been written under since the add-on shipped. See # `maintenance.UnitHistory` and `store.migrate_maint_keys()` - # this is the line that keeps a one-inverter state file # byte-identical. 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)) for i, inv in enumerate(self.inverters)] # SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own # docstring has always said and what this Controller did not do. Each # `Inverter` builds its own, restored from ITS OWN namespaced store keys, # so a trip caused by B latches B and leaves A commanding. # # ⚠️ RESTORED FROM THE STORE, and that is the difference between a latch # and a pause. A trip held only in memory is cleared by the one action an # operator staring at a FAILSAFE banner is most likely to take - restart # the add-on - and the controller then comes back armed, into the fault, # with nobody involved. # # ⚠️ AND THE LATCH IS GRADED HERE RATHER THAN TRUSTED, ON EVERY START. A # latch that silently failed to move into its namespace - or that was # taken by an OLDER add-on while this install was rolled back, which is # the round trip store.py's rule 2 exists for - is a DISARMED SAFETY # GUARD on an install whose state file still says `sign_tripped: true`. # So the flat value is compared against what the roster actually came up # with, and a disagreement is not a log line: it re-arms the latch by # hand and shouts, because the fail-safe direction for a latch is ON. 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 its figures, is on the DEBUG line beside it. _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 review: `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, WHICH IS THE ONE ASYMMETRY WITH EVERY # OTHER RESTORED LATCH ON THIS OBJECT, AND IT IS DELIBERATE. SAFETY-02's # and SAFETY-31's latches survive a restart because they command 0 W and # an add-on that came back armed would drive straight into the fault # again. This one commands nothing at all: it is restored so that the # ALARM survives, which is the whole of SAFETY-34 and is exactly how the # 2026-09-02 outage went unseen for 6 h 25 m with the detector correct # the entire time. `delivery.py` carries the argument for reporting # rather than acting, with the rig measurements on both sides of it. # # `_delivery_announced` is the per-PROCESS half - see `_circ_announced`, # which is the same pattern for the same reason. The store says "this # unit is tripped"; the flag says "this process has put that in front of # a human". 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, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2. # The per-unit watchdogs above ask "is THIS actuator doing what I told # it", graded on that unit's own measured power. This one asks "is the # SITE driving the wrong way", graded on the SUM of the standing # commands against P1 - the comparison SAFETY-02 shipped, re-scoped to # the quantity it was always actually about. # # ⚠️ AT A ROSTER OF ONE IT IS CONSTRUCTED AND NEVER EVALUATED, and that # is the whole compatibility promise of this card rather than an # oversight. With one inverter the sum of the commands IS that # inverter's command, so arming this would be arming a duplicate of # `inverters[0].sign_wd`: two latches for one fault, two banners, and a # false-positive record measured on ONE comparison being spent twice. # `cycle()` is where that is enforced and says so at the line. # # Restored from the store like SAFETY-02's and SAFETY-31's latches, for # the reason store.py gives: "restart the add-on" is the first thing an # operator looking at a FAILSAFE banner tries, and a latch held only in # memory brings the controller back armed, into the fault, unattended. # FLAT keys, not namespaced - store.py carries that argument too. 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")) # And the per-unit detectors are told which question they are asking, # here as well as in `cycle()`. `checks()` and the status page read # `inv.sign_wd.cfg` and can be called before the first cycle has run, so # a roster of two would otherwise advertise the site thresholds on a # detector that is about to be graded against a unit's own power. self._arm_sign_watchdogs() # SAFETY-18. The frozen-but-fresh meter detector - see freeze.py for why # it is a conjunction and not an `unchanged_s` threshold, and for the # measurements behind the default. # # ⚠️ NOT restored from the store, unlike SAFETY-02's latch, and the # asymmetry is the point: this one does not latch because it does not # act. A restart re-derives its state from the meter within one # `meter_freeze_s` window, and until then the honest report is "not # alarming yet" rather than an alarm nobody can clear. self.freeze_wd = FreezeWatchdog( FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300)))) # SAFETY-31. ONE PER SITE, and restored from the store like SAFETY-02's # latch rather than defaulted like SAFETY-18's - the asymmetry is the # same one the freeze watchdog's own note draws, read the other way. # This one ACTS: a trip files a P_SAFETY SET that stops every inverter, # so a restart that cleared it would come back up straight into the loop # it had just stopped, which is SAFETY-02's measured three-minutes-at-a- # time failure with a different quantity in front of it. # # ⚠️ SITE-LEVEL KEYS, NOT NAMESPACED. store.py carries the argument: # circulating power is defined over the roster and is identically zero # for any one unit, so there is no inverter to hang the latch on. 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` is in # memory and does NOT survive the Home Assistant restart that the store # does, so the latch alone is not the alarm - re-raising it is. # `run_control()` does that, 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 - see 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 - see clamp.py for why the clamp # itself is the cheap half. # # ⚠️ NO CONFIG OF ITS OWN, on purpose. The bound it clamps to is `max_w`, # which is already an option, already validated against the device's # rating at startup, and already used by Tuning and the arbiter. Giving # this a second key would be a fourth place a limit has to be kept in # step (CFG-03 already tracks three) and, worse, would recreate exactly # the two-numbers-that-disagree failure the §14 warning box describes. # The anti-flood cadence is a module constant for the same reason # freeze.RAIL_FRAC is: it describes the event bus, not the plant. 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. It is an INSTRUMENT - it files no # claim and clamps nothing. SAFETY-07 is what will read it. # # ⚠️ NO NEW OPTION. The soft limit is `peak_cap_w`, already shipped, # already validated, already read by control.peak_at_risk(). CFG-03 # tracks three places option defaults live; a fourth, for a limit that # already exists, is the mistake the clamp watchdog declined to make ~30 # lines up. config.yaml and validate.py are untouched by this ticket. # # ⚠️ AND NO SECOND PEAK ENTITY. `peak_forecast_entity` is the existing # HA-side delegation and it stays the input to maintenance_charge_floor(): # repointing that consumer at this measurement CHANGES WHAT MOVES ENERGY # during a maintenance charge, which belongs to SAFETY-07 with its own # acceptance, not to a measurement ticket. What this adds is the MEASURED # MONTH PEAK - a different quantity from a forecast entity, published # nowhere as a duplicate of it. 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: the published prices of the # last few Brussels days, so the hours a day-ahead feed has not printed # yet can be filled from yesterday's profile and FLAGGED, instead of # being absent from the series entirely. # # ⚠️ IT IS AN INSTRUMENT'S BACKING FILE, NOT A STRATEGY. It commands # nothing and claims nothing; what it changes is how many hours the # plan covers. Before this, a dynamic site between midnight and the # 14-16 h CET publication had a series covering only the rest of today # - typically 10-24 of the 48 hours `price_horizon_h` asked for - and # `TariffPlan.arbitrage()` took its spread over that truncated window # without anything reporting that it had. Every carried hour is # provisional, so refusal 3 drops it from the trade set: CARRYING a # price widens what the optimiser can SEE, never what it may act on. # # ⚠️ AND THE CAVEAT THAT BELONGS WITH THAT SENTENCE, because this is # the one place a maintainer reads it on its own. THE OTHER HALF OF # THIS CHANGE DOES MOVE ENERGY, and it is the windowing rather than the # carrying: the filled series covers `[since, until)`, so hours that # have ALREADY ELAPSED are no longer in the spread. A cheap night that # has gone used to sit in the series as the day's cheapest hour, and no # cycle can buy in an hour that is past - so the trade was quietly # never taken. `resolve_tariff()`'s docstring, DOCS.md and the CHANGELOG # all carry this beside the guarantee; it may not appear here without it. # # ⚠️ A BROKEN OR ABSENT STORE IS NOT AN ERROR HERE. It answers `no_data`, # the fill carries nothing, and the plan is the pre-FCST-04 one minus # the elapsed hours - short, but honest and still refusing on the same # rules. It must not be an error in the other sense either: a day # record this build cannot read is DROPPED by `PriceHistory._load()` # and reported in `.words`, because both the constructor above and the # `.words` read below are unguarded, and a cache that raises here is an # add-on that does not start. self.price_history = PriceHistory(price_history_path) _LOG.info("Price history: %s", self.price_history.words) # SAFETY-07. The guard TARIFF-01 was built for. It is stateless by # design (peakguard.py holds no history between calls), so the only # state here is what an EDGE needs: what the last cycle concluded, so # the action log and the notification fire on transitions rather than # once per cycle for a quarter of an hour. # # ⚠️ THE CEILING HAS NO CONFIG OF ITS OWN, for the reason the clamp # watchdog gives ~90 lines up and TARIFF-01 repeats: it is `peak_cap_w`, # already shipped and already validated, and every other term of the # projection is read off something the arbiter or the averager already # owns. # # ⚠️ SAFETY-24'S FLOOR DOES HAVE ONE, AND IT COULD NOT NOT HAVE. How far # down the guard may spend the pack for money is a number only the site # knows, and refusing one set below the pack's own cut-out means knowing # that cut-out too. So this card adds exactly two keys and reads their # defaults from `peakguard.PEAK_OPTION_DEFAULTS` - TARIFF-02's mirror # pattern - rather than typing literals here. THAT is what kept this # card from widening: the divergence validate.py's rule 6 used to # document (`maintenance_charge_w` 2000 in config.yaml, 2500 in this # file) is exactly what a literal here would have repeated. CFG-03 has # since closed that one too - `maintenance.MAINT_OPTION_DEFAULTS`, # read through `MaintConfig.from_options()` - so the rule now holds for # every option family this file builds: no numeric fallback in main.py # that config.yaml has not shipped. self.peak_guard = None self._peak_engaged = False self._peak_saturated = False self._peak_floor_limited = False self._maint_shape_suspended = False # [(pack name, why)] for packs that are due and waiting their turn. # Empty at a roster of one, where nothing can defer anything. self._maint_deferred = [] # Set by amain() once the transport is built, so the status page can show # what only the transport knows (homewizard_local's unchanged_s). Stays # None when P1 is off, or under a transport that has no such counter. self.p1_source = None # --- ARB-03: which actuators this site has --------------------------- # ⚠️ THE ROSTER IS `self.inverters` AND `units` IS ITS NAMES, DERIVED. # Every claim below is filed on an actuator, and `arbiter.actuator_for()` # needs to know how many there are: at N=1 §6.2's identity holds - the # fabricated grid reading and the battery setpoint are the same number - # so the grid connection and the only inverter ARE one actuator and every # claim routes to the site. With a second entry the per-inverter claims # stop being site claims on their own, which is the seam ALLOC-01 # occupies and this card fills. # # ⚠️ IT IS A PROPERTY, NOT A FIELD, AND THAT IS THE POINT OF THE CARD. # `self.units = (...)` was a tuple somebody could set beside a roster of # one Inverter object, i.e. a site that claims two actuators and has one # writer. Deriving it means the roster and the actuator list cannot # disagree - to add an inverter you add an Inverter. # 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 before the first one. ⚠️ IT IS KEPT SEPARATELY FROM # `self.target` BECAUSE THEY ARE DIFFERENT FACTS: `self.target` is what # is STANDING ON THE WIRE - held at `shadow.WIRE_W` for the whole of a # shadow run, and re-seeded to it at the end of every shadow cycle so # the loop and the sign watchdogs track reality - and this is what a # live controller WOULD have put there. Folding them into one field is # how a shadow run starts telling 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? ⚠️ IT IS RECORDED WHERE THE DECISION # WAS TAKEN, NOT RE-DERIVED WHERE THE PAGE IS RENDERED. The status page # runs on its own task and `self.peak_guard` has moved on by then, so a # page that asked the question again would be answering it about a # different cycle from the one whose watts it is displaying - which is # the same two-facts-one-field defect the comment above this one is # about. ⚠️ False AND NOT None BEFORE THE FIRST SHADOW CYCLE, unlike # the field above: this one is a claim ABOUT a number, and there is no # number yet, so the honest value is "nothing to disclose". The row's # `would_w is None` branch renders no watts at all there. self.shadow_sized_from_meter = False # Edge state for the action log, both of them. `_shadow_was` is None # until the first cycle, so an install that comes up IN shadow mode # says so in the log it will be read from rather than leaving the # operator to infer it from a page nobody is looking at yet. Coming up # LIVE is silent - see `_shadow_settle()`, which says why. 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, kept for publication. Seeded # to the SAME values the "stopped or blind" branch of `cycle()` writes, # because before the first cycle the loop has not run - which is the # thing that branch is for. 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, and NOTHING READS IT ---------- # ⚠️ INSTRUMENTATION. It is published, it is shown on the status page, # and it reaches no decision: not `inputs_ok`, not the allocator, not # the arbiter, not a claim, not a watchdog. Wiring it into the control # law would be an unrequested behaviour change on a system driving a # real battery AND would destroy the measurement, because the loop would # then react to the quantity being measured. `tools/tel11_pv_trace.py` # is the instrument that says so, on both trees and both settings. # # ⚠️ `None` IS A READING THAT DOES NOT EXIST, AND IT IS SEEDED AS ONE. # Nothing here ever becomes 0.0 by default; see `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 - kept because `checks()` has to be able to name the # sensor that is missing, and `self.pv_w` alone cannot. Empty before the # first read, which is what "no entity has answered yet" is. self.pv_parts = [] # Did the grid-following loop actually win the LAST arbitration? That is # the question SAFETY-02 has to ask about the command standing on the # wire right now, so it is recorded when the arbitration happens rather # than re-derived a cycle later. Starts False: nothing is on the wire. 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 # before the first cycle. ⚠️ IT IS KEPT SO THAT SOMETHING CAN GATE ON # IT: SAFETY-28 puts the write path behind this record, and DEV-02 reads # `shares` off it to drive a second inverter. It is REPLACED every cycle # and never merged - a record from a tick whose envelopes have moved is # the cached envelope this card exists to forbid, wearing a different # hat. self.allocation = None # SAFETY-28. THE TICK THE RECORD ABOVE BELONGS TO, and the counter that # mints it. It is incremented ONCE PER RESOLUTION - not once per loop # iteration - and that distinction is load-bearing in both directions: # # * a NEW resolution makes the previous record stale IMMEDIATELY, so a # record cannot be replayed into a later tick after the envelopes it # describes have moved. That is the replay half of the gate. # * a cycle that produced NO resolution does not bump it. The "inputs # missing, not yet stale" hold above returns before the allocator # runs precisely so that a single missed poll does not zero the # inverter, and the heartbeat re-affirms `self.target` through the # write path while it does. Bumping here would make that heartbeat # unauthorised and drop the site to 0 W on the first dropped poll - # turning a hold into the outage it exists to prevent. # # So the record is SUPERSEDED, never expired: it stands until the # allocator produces its successor, and nothing else has moved in # between that it does not still describe. self.alloc_tick = 0 # What the last cycle's allocator dropped, so the log line above is # edge-triggered. Starts as the empty tuple - nothing has been dropped # yet and nothing has been said about it. self._dropped_last = () # OPT-09. The last cycle's `unmet_w`, so the line that reports it is # edge-triggered for `_dropped_last`'s reason: a site that cannot place # every watt stays that way for as long as the pack is empty, and a line # a second would flush the 40-row action log of the context explaining # it. 0 is "every watt was placed", which is the state at boot. self._unmet_last = 0 # ⚠️ THE SITE MEASUREMENT, AND THE ONLY ONE LEFT ON THIS OBJECT. `soc` # and `batt` were per-device scalars and now live on each `Inverter`; # `grid` is P1, one meter, one connection, and belongs here. self.grid = None self.peak_fc = None self.cheap = False # 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 - so "Cheap window: # no" is an evidenced answer rather than a bare no. self.tariff_words = "No fixed tariff set" # OPT-04. The tariff plan the BACKGROUND task last resolved, and when. # cycle() reads these two attributes and never awaits a fetch - see # TARIFF_REFRESH_S. `None` means no refresh has completed yet, which is # not "no prices" and certainly not "free": arbitrage.decide() reports # it as its own reason and does nothing. self.tariff_plan = None self.tariff_at = None self.arb = None # TARIFF-11. WHEN the standing arbitrage decision was taken, or None if # this controller has never reached the arbitrage step. ⚠️ `self.arb is # None` DOES NOT MEAN "TRADING IS OFF" AND NEVER DID - see # `arbitrage_row()`. It also means "cycle() returned before the step # ran", and at that point `self.arb` is either this attribute's initial # None (startup) or the PREVIOUS cycle's decision (an input hold), and # the status row rendered all three identically. self.arb_at = None # SAFETY-45/47. The billed-quarter charging headroom the arbitrage step # was handed on the cycle above, or None when nothing measured it. It # is CARRIED rather than recomputed for `arbitrage.sized_from_meter()`, # because `self.peak_guard` is a different cycle's decision by the time # the status page renders. self.arb_headroom_w = None # 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() # TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at` # is the exact, derived statement of "the latest cycle did not get as # far as the arbitrage step", which is the fact the *Arbitrage* row # used to render identically to "trading is switched off". self.cycle_at = None self._arb_action = None self.inputs_bad_since = None # SAFETY-03. What the loop's OWN bounds did last cycle - a # (engaged, note) pair so the alarm can say WHICH bound bit without # re-deriving it from a string. ⚠️ THIS ONE STAYS SITE-LEVEL, and it is # the counterpart to `clamp_last` moving onto the Inverter: the integrator # that wound is the SITE integrator, of which there is exactly one, while # the value a clamp bit is a value written to ONE device. # (engaged, note, over_rating) - SAFETY-40 splits the last one out. 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 = () 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) -> None: """Follow `compute()`'s carried refused direction, and say so.""" now = decision.refused or 0 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 >= _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's pack limit moves its share to the others). """ 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) < _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) < _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: the same refusal, per inverter, at N>1 ------------------- # SAFETY-52's decisions applied to one unit of several: a unit that gives # < 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 # to the others; it is 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 (a 400 W night load split two ways) and SAFETY-43 would otherwise # name the empty battery as not delivering. DECISIONS/SAFETY-73. 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 and says nothing about it. 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 is handed 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: if b is not None and inv.refused * b >= _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 (charge at the floor) for SETTLE_CYCLES. Counted # on its own reading, so one cycle of the loop overshooting # through zero does not hand it its share back. inv.ref_count = (inv.ref_count + 1 if b is not None and -inv.refused * b >= _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 b is None: continue # 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 # (a flapping load) reads ~0 through the plant's own lag, and that # is a battery following, not refusing (TEST-19 flapping family). if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W: if sign != inv.ref_sign or not inv.ref_count: inv.ref_count, inv.ref_since = 0, self._now_mono 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): self._unit_refusal_start( inv, sign, f"asked {standing:+.0f} W, the battery gave {b:+.0f} W " f"for {_ctl.SETTLE_CYCLES} cycles") 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? ⚠️ THIS IS THE HALF THE CARD CALLS RISKY, AND IT IS GRADED RATHER THAN PERFORMED. Namespacing `sign_tripped` is right - flat, a trip on B latches A - but a latch that silently fails to move is a DISARMED SAFETY GUARD ON AN INSTALL THAT BELIEVES IT IS PROTECTED: the state file still reads `sign_tripped: true`, the banner is green, and the controller resumes commanding into the fault that tripped it. That is strictly worse than never having persisted the latch, because now there is evidence that says it is fine. So the pre-DEV-02 flat value is compared against what the roster ACTUALLY came up with, and a disagreement is not reported and survived - the latch is re-armed by hand, from the flat value, and the store is written so the next start does not need this branch. The fail-safe direction for a latch is ON: a spurious trip costs a 0 W hold an operator clears with one button, and a missing one costs whatever SAFETY-02 was watching for. ⚠️ AND IT RUNS ON EVERY START, NOT ONLY ON THE ONE THAT MIGRATES. It used to return early on `not rep.ran`, for a real reason: while nothing cleared the flat key, an install that had ever tripped carried `sign_tripped: true` in its file forever, and a guard reading it would have re-armed the failsafe on every restart for the rest of the install's life. THAT GATE SWALLOWED THE EXACT ROUND TRIP store.py's RULE 2 EXISTS FOR, and it is not hypothetical - a firmware version change with a rollback path was performed on this site on 2026-09-02: 1. the install migrates -> `schema_version = 1`, `unit/inverter-a/sign_tripped = False` 2. the operator DOWNGRADES, and SAFETY-02 trips inside the downgrade window. The old code knows only the flat key, so the file gains `sign_tripped: true` and nothing namespaced 3. the operator upgrades again. `rep.ran` is False - the store is already at schema 1 - so BOTH the migration and this guard are skipped, `Inverter.__init__` reads the namespaced key, and the controller comes up with `sign_wd.tripped = False` while /data/state.json says `sign_tripped: true` Which is this module's own sentence, rebuilt by the version marker: the file still says tripped, the banner is green, and the controller resumes driving the wrong way. The gate is gone because the reason for it is gone. `handle_action()` now clears the flat keys alongside the namespaced ones when the operator clears the latch, and the trip mirrors them, so the two spellings can disagree in exactly one direction: FLAT SAYS TRIPPED AND NO UNIT CAME UP TRIPPED means the flat key was written by a version that did not know about namespaces - a downgrade-window trip - or a migration that lost the latch. Both want the same answer, which is the one below. """ 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**", AND THE LAST CLAUSE IS SAFETY-44's SECOND HALF. At a roster # of two a trip on B leaves A untripped, so re-arming A off a flat key # that B's own trip mirrored would latch a healthy inverter on every # restart - the flat-key defect DEV-02 removed, coming back through its # own guard. That much stands, and it is why the question is asked of # the STORE's keys rather than of the roster's objects. # # ⚠️ BUT IT USED TO ACCEPT **ANY** `unit/*/sign_tripped`, and the # paragraph here defended that: the store "holds every one this install # has ever written, including one belonging to a unit that is not in # TODAY's roster ... whereas the roster only knows what is configured # right now". THE PREMISE IS TRUE AND IT ARGUES THE WRONG WAY. A latch # under a name no unit answers to is not protection an operator has - it # is a key nothing reads, which is the definition of the state this # method exists to repair. Counting it as protection made the guard # return early on precisely the install that needed it, and that is not # hypothetical: it is where the migration above USED to put the latch # on every declared roster, so the guard was blinded by its own upgrade. # # THE MIGRATION FIX DOES NOT REACH THOSE INSTALLS. A store stamped by # the shipped version already has the marker, so `migrate_unit_keys()` # returns at the version check and never runs again. This guard is the # ONLY repair path for a site that upgraded before this card, and it # re-arms onto `inverters[0]` - which lands the latch back under a name # the roster reads. # ⚠️ AND "OFF THE ROSTER" IS NOT ENOUGH ON ITS OWN - IT HAS TO BE THE # MIGRATION'S OWN FINGERPRINT, WHICH IS `UNIT_A`. The first draft of # this treated EVERY off-roster latch as litter, and test_inverter.py's # DEV-02 restart case caught it: there the store carries # `unit/inverter-b/sign_tripped = True` from B's own trip while the # roster being graded holds only A, and the blunt rule re-armed a # HEALTHY A off the flat key that B's trip had mirrored - DEV-02's # defect rebuilt inside its own guard, which is exactly what the # paragraph above was right to be afraid of. # # `UNIT_A` is the discriminator because it is the ONLY name the broken # migration ever wrote. `roster_names()` falls back to a POSITIONAL # `inverter-N`, so `inverter-a` is not a name this code hands to anyone # else. A latch stranded under any OTHER name is a real unit that is # out of today's config - a roster is configuration and it changes - # and it still counts as protection. # # ⚠️ THE RESIDUAL, STATED RATHER THAN LEFT TO BE FOUND: a site whose # own first unit is NAMED `inverter-a`, latched, and then taken out of # the roster gets re-armed on `inverters[0]`. That is a healthy unit # held at 0 W until an operator clears it, which is the conservative # direction; SAFETY-44's is a site DRIVING on a state file that says it # is latched. # # ⚠️ AND THE SECOND HALF OF THAT RESIDUAL IS THE **SENTENCE**, NOT THE # BOOLEAN. The reason re-armed from is the FLAT mirror, which carries # whatever tripped LAST - so on a multi-unit site this path can print # one inverter's evidence under another inverter's name. The latch is # right and the explanation may not be. It is disclosed here because the # banner is what an operator acts on, and `test_maintenance.py`'s check # that the reason is not the removed unit's is guaranteed by that # fixture's choice of flat string, NOT by this code. # ⚠️ AND THE THREE CASES ARE THREE VARIABLES, NOT ONE SHARED FLAG. # THIS IS WHERE THE FIRST VERSION OF THIS FIX WAS DEFEATED, BY ONE KEY. # It wrote `namespaced_latched = True` for BOTH "on the roster" and "off # the roster but not UNIT_A", so a single latched key belonging to a # unit the operator had removed from `inverters:` vetoed the repair of # the misdirected `inverter-a` latch sitting beside it. Measured on the # shipped build, roster ['garage', 'shed'], store stamped so the # migration cannot reach it: # # sign_tripped + unit/inverter-a/sign_tripped # -> [('garage', True), ('shed', False)] repaired # ...AND unit/old-shed/sign_tripped # -> [('garage', False), ('shed', False)] ⚠️ DRIVING # # A site DRIVING on a state file that reads `sign_tripped: true`, which # is the sentence this whole card exists to make false - and # `tools/rehearse_roster.py` said so, on the unmodified tree, exit 1. # # ⚠️ IT IS ALSO INSTANCE #17 OF HABIT 4, IN THE EXTENSION MUTATION.md # ADDED TWO DAYS AGO. The two fixtures written for this card each hold # ONE arrangement - `_staged_store` has only `inverter-a`, `_other_store` # has only `inverter-b` - and the flag is written twice with opposite # intents only when BOTH are present. A per-key check whose keys have # never disagreed WITHIN ONE STORE is a check over whichever key came # last. The fixture below now holds both. # # THE THREE CASES, AND WHY THE ANSWER DIFFERS FOR EACH: # on the roster a machine reads it: real protection, stop. # off-roster, `UNIT_A` the broken migration's fingerprint - this # IS the flat latch, misdirected. Repair. # off-roster, other name a real unit out of today's config. Its # latch is unread too, but it is NOT the flat # key's misdirection, and re-arming a healthy # sibling off it is DEV-02's defect. Stop - # UNLESS a stranded `UNIT_A` latch is also # present, because then the flat key IS # accounted for and the repair is the point. 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 rather than re-deriving. 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 it carries no new information and # 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 the note in `__init__`: a settable `units` is a site that can claim two actuators while holding one writer, which is a half-driven site with nothing to say so. """ 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 right now. The pack that is mid-cycle if there is one, otherwise the first - which at a roster of one is always 0, always was, and is the whole of this property's N=1 behaviour. """ 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 TURNED THIS FROM AN ATTRIBUTE INTO A VIEW, AND THE THING IT IS A VIEW OF CHANGED WITH IT. There are now `self.maints` - one FSM per roster entry, each with its own schedule, phase and history - and every SITE-level surface that used to read `self.maint` (the `phase` MQTT value, the status banner, the operator's start/abort buttons) wants THE ONE THAT IS RECALIBRATING, not "the first one". `maintenance.arbitrate()` guarantees at most one pack is ever out of IDLE, so "the one that is recalibrating" is well defined; when none is, this is `maints[0]` and the surfaces read exactly what they read before. ⚠️ AT A ROSTER OF ONE IT IS `maints[0]` ON EVERY TICK, in both branches, which is the object that used to be assigned here. Nothing downstream can tell the difference - that is AC 6, and test_maintenance.py part 9 asserts it rather than trusting this paragraph. """ return self.maints[self.maint_index] @property def maint_unit(self): """The pack the site's one maintenance slot is currently on. ⚠️ THIS USED TO BE `self.inverters[0]`, FLATLY, AND THE COMMENT THAT STOOD HERE EXPLAINED WHY: there was exactly one `Maintenance`, its history lived under the store's un-namespaced `phase` / `last_completed` keys, so the pack it drove could only be the first in the roster. That sentence is what MAINT-05 removed. There is now one FSM and one namespaced history PER ROSTER ENTRY, so the pack being recalibrated is whichever one holds the site's maintenance slot, and `maint_index` - not the roster's order - is what answers it. ⚠️ AND `validate.py` RULE 6 MOVED WITH IT, exactly as its own comment said it must ("when `maint_unit` stops being `inverters[0]`, this line moves with it"). It graded `maintenance_charge_w` against the FIRST entry's cap on the argument that the cycle never touches the others; that argument is now false, so it grades against the weakest cap on the roster, like rule 5. See the ⚠️ block there. """ 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 site's maintenance slot when one holds it, so "abort" stops the cycle that is running and "start" restarts it - exactly what both buttons meant while there was one pack, and the reason this is not simply "the longest-waiting one". When every pack is idle it IS the longest-waiting one, which is `arbitrate()`'s own clause 2, so a forced start and a scheduled start choose the same pack rather than two different ones. ⚠️ AT A ROSTER OF ONE BOTH BRANCHES ARE `maints[0]`. There is no third case: `max()` over a one-element list is that element. """ 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. ⚠️ THIS IS WHAT MAKES THE N=1 VIEWS BELOW SAFE. §6.2's identity - one inverter means the site and the unit are the same thing - is true at N=1 and false at N>=2, so a view built on it must not quietly answer for "the first one" once the roster grows. Every one of them refuses instead, loudly, naming the per-instance attribute to use. """ 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] # ⚠️ PROPERTIES, NOT FIELDS, AND EVERY ONE OF THEM IS A COMPATIBILITY VIEW. # These are the scalars this card removed. They survive as read/write views # of the SOLE inverter so that an N=1 install, and every replay in this # repository that constructs one, is byte-identical - which is AC 8. They # are properties rather than attributes for two reasons that are the card: # # * an assignment cannot re-create the scalar. `self.soc = 4` now writes # inverter A's own SoC; there is no second copy to drift. # * they REFUSE at N>=2 rather than answering for the first unit, so a # consumer that has not been made per-instance fails on the line that # reads it instead of grading inverter B against inverter A's number. # That is the whole defect, and this is the tripwire on it. # # ⚠️ NOTHING INSIDE THIS FILE MAY READ THEM. main.py's own paths iterate the # roster; test_inverter.py's grader scans this module's AST and goes red on # a `self.` inside any method of `Controller`. A view that main.py used # would be the scalar back with a longer name. @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)`, AND THE ONLY PLACE THE ROSTER DECIDES ANYTHING ABOUT THIS CHECK. Written as one method rather than as a conditional at the two call sites for `loop_is_following()`'s reason: the arrangement that does not keep two things in step is the one where they are written down twice. AT ONE INVERTER - §6.2's identity, and the whole of AC 8. The single unit's detector is armed against THE METER with the shipped `sign_disagree_w` / `sign_disagree_s`, which is what it has always been armed against, and the site detector is not armed at all. A one-inverter install therefore makes the same comparison, at the same thresholds, writes the same reason string, latches under the same keys and files the same `failsafe` claim as it did before this card. AT TWO OR MORE - the identity is gone and the meter stops being a view of any one actuator. Each unit's detector is armed against ITS OWN MEASURED POWER (`Inverter.batt`, register 0x0518) under the re-derived per-unit pair, and the site detector is armed against the meter under the site pair. Two questions, two remedies, two latches. ⚠️ CALLED AGAIN AT THE TOP OF EVERY `cycle()`, not only from `__init__`. The roster is re-read every cycle for liveness and for the allocation, and this repository's two-inverter fixtures append an `Inverter` AFTER the Controller is constructed - a detector frozen at construction would then be graded against the meter at a roster of two, which is the defect this card exists to remove, wearing a fixture's name. `arm()` touches no timer and no latch, so calling it every cycle cannot disturb an episode in progress. """ 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 one named for the question it answers ------ # ⚠️ THESE ARE NOT "THE SCALARS WITH A SUM IN FRONT". Each one picks a # DIFFERENT reduction, because the questions are different, and getting the # reduction wrong is how a pack gets driven below its own floor. The # reduction is written at the definition, with its reason, rather than # inline at four call sites. @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 own share, so this cannot over-command a device; what it must not do is bound the site loop at ONE unit's rating, which would waste the second inverter entirely. At N=1 it is `max_w`, exactly. """ 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. The site rating is what the site can push; this is what the site can push AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` used to be handed `site_max_w` on both sides, so on a roster of two the trade was sized at the sum of the ratings whatever either pack's state of charge was - the site would buy at 9600 W with only one pack able to absorb it, and the watts that could not be placed would come back as `unmet_w` for the site integrator to chase for the rest of the hour. ⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` / `arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, because SIZING A TRADE IS THE TRADE'S BUSINESS. This method answers "how much may this strategy ask for"; the ENVELOPE - `packshare.envelope()`, filed as a `pack` claim - answers "what may this pack be commanded at all", and that one is the pack's own physical cut-out for the reason SAFETY-25 gives. Sizing with the physical floor would let the strategy ask for watts its own reserve forbids; bounding the envelope with the trade's reserve would let a trade preference stop the grid-following loop. THEY ARE DIFFERENT QUESTIONS AND THEY GET DIFFERENT NUMBERS. ⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack ends up carrying the trade is the allocator's answer, and the only per-pack instrument it has for a TRADE reserve is the weights - which are a preference, not a permission (`allocator._fill()` will hand watts to a zero-weighted unit rather than leave them unmet). So a pack between its arbitrage floor and its BMS cut-out can still take part of a sell. That is stated rather than fixed: fixing it needs a per-pack envelope that depends on WHICH claim won the tick, which the arbiter's partition does not express, and is a card of its own. A pack whose SoC did not read is counted on NEITHER side. It is not reachable with control running - `inputs_ok` stops the site when any pack's SoC is missing - and withholding is the direction that cannot over-size. ⚠️ NEITHER SIDE CAN BE ZERO ON A TICK THAT ACTUALLY TRADES, and it is not left to that coincidence: `arbitrage.decide()` refuses a 0 W sizing by name (`IDLE_NO_SIZE`), because a 0 W arbitrage claim is a park order that outranks the loop. """ # ⚠️ THE REDUCTION ITSELF LIVES IN `packshare`, NOT HERE, and this # method is the roster view it is applied to. A transcription of the # arithmetic into a check is one copy plus a future disagreement about # what it means, and the copy that drifts is always the grading one - # so `test_packshare.py` part 9 calls the SAME function this does. 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 actually be commanded, in W. SUM of each unit's own `min(max_w, |dev_min|, |dev_max|)`. This is the peak guard's `achievable_w` and the freeze detector's rail, and summing the PER-UNIT conjunction rather than conjoining the sums is what makes inverter B's rating bind inverter B (AC 4). A unit whose device range has not been read yet contributes its `max_w`, which is what the shipped single-inverter path already did with (None, None). """ return float(sum(inv.rating_w() for inv in self.inverters)) def site_dev_bound(self, which: str): """The roster's summed device floor / ceiling, or None if unknown. `freeze.looks_frozen()` and `rail_unreachable()` take a device range and SKIP a bound they were handed None for - "ESPHome is not up yet", not "the device can do nothing". So a bound is summed only when EVERY unit has answered; one silent unit makes the site bound unknown rather than making it smaller, which is the same direction the shipped code took. """ 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 is regulating against. ⚠️ THIS IS THE FINDING ALLOC-01'S REVIEW HANDED THIS CARD, AND IT GOES THE OPPOSITE WAY TO EVERYTHING ELSE HERE. `compute()`'s anti-windup test is `abs(prev_w - actual_w) > saturation_w`, where `prev_w` is the SITE target. Fed ONE inverter's power at a roster of two it compares a site number against a unit number, trips the saturation freeze permanently and leaves a standing grid error - measured on admin/goodwe's two-master rig as up to 510 W of split-dependence at gain 0.2 and 250 W at gain 0.4, with the allocator's shares summing to S in every one of those runs. The counterfactual is the proof: with this sum every split is bit-identical at every gain. See 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`. THIS IS THE WHOLE CARD, and the wrong version is the tempting one: the same formula over the allocation record would be simpler, would never be None, and would be worth nothing. ALLOC-01's sign-coherence gate (`allocator.REFUSE_SIGN`) already refuses an allocation whose shares carry two signs, so a record-derived sensor reads zero BY CONSTRUCTION, forever, and can only ever confirm that the allocator agrees with itself. Every fault this card is for is a divergence between what was commanded and what the hardware is doing - a unit ignoring or lagging its share, a stale share still being executed, or HW-01's power stage running its last instruction for 6 h 25 m with the comms dead and the allocation record impeccable throughout. Measured on the TEST-19 rig: with one unit wedged, the record stays `ok` with both shares at -1250 W - a record-derived sensor reads 0 W - while this one reads 2000 W. `batt` became per-instance in DEV-02; before that there was one scalar on the Controller and this property could not have been written. None when any unit has not answered, and NOT zero - circulation.py says why at length. The short version: zero would be this sensor reporting health from an absence, which is the 2026-09-02 failure itself. Identically 0.0 at the shipped roster of one (AC 5). """ return circulating_w([inv.batt for inv in self.inverters]) @property def pack_floor_soc(self): """MINIMUM. The SoC that a discharge PERMISSION must be graded against. SAFETY-24's floor asks "may this site spend the pack for money", and the honest answer at N>=2 is the EMPTIEST pack: an average lets it be driven below its own floor, which is the failure the option exists to prevent. None if any unit did not answer - `peakguard.decide()` treats None as floor-limited, which costs a shave rather than a pack. ⚠️ AND IT IS NOT THE RIGHT REDUCTION FOR A CHARGE CEILING, which is the FULLEST pack. Nothing in the add-on files a per-pack charge ceiling yet (arbitrage's `soc_ceiling` is the only consumer and it takes one number); that is CFG-04/SPIKE-04's per-pack bound, and it is named here so the next reader knows this reduction was chosen rather than assumed. """ 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. `pack_floor_soc` above is the site's ONE verdict and stays exactly what DEV-02 made it - the emptiest measured pack. This is the thing that reduction cannot produce: by the time `min()` has answered, WHICH pack the number came from and how far that pack was from ITS OWN cut-out have both been thrown away, and two packs at 15 % against cut-outs of 10 % and 20 % have opposite answers that are indistinguishable afterwards. ⚠️ EVERY unit, live or not. Same reason as `inputs_ok`: a pack whose SoC did not answer is a pack nothing can grade, and dropping it from this list would let the site spend the packs that DID answer while one sat unmeasured. `peakguard.discharge_permitted()` treats a None SoC as no permission, which is where that must be decided. """ 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, in roster order. The fourth is `capacity_kwh`, and it is why this is not `packs` above. That list answers the FLOOR question - may this pack be spent - and its consumers (`pack_permissions`, `binding_soc_floor`) have no use for a capacity and would have to default one. This list answers the SPLIT question - which pack should carry more of the site target - and cannot be asked without one. ⚠️ EVERY unit, live or not, for `packs`'s reason. The allocator drops what `live_units()` does not list and `plan()` ignores a weight for a unit it dropped, so filtering here would only move the same decision to a worse place - and `packshare.weights()` needs to see a pack that did not answer in order to withhold its opinion about the whole roster. """ 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`. ⚠️ IT IS A METHOD HANDED TO `resolve()`, NOT A DICT COMPUTED BEFORE IT, and that is not indirection for its own sake. An energy-headroom weighting is direction-dependent - room above the floor when the site is discharging, room below full when it is charging - and the two give opposite answers on the same roster. The direction is the SIGN OF THE SITE TARGET, which is stage 2's output, so nothing here can know it until `resolve()` has run stage 2. `arbiter.resolve()` calls this once, between stage 2 and stage 3, with the number it just chose. ⚠️ AND IT IS READ FRESH ON EVERY CALL, NEVER CACHED, for the reason the allocator refuses to cache an envelope: a pack's SoC moves for reasons that have nothing to do with the allocator, and a weighting held from construction prefers a pack that has since emptied. """ return packshare.weights(self.pack_energy, target_w) @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 is a unit nothing can grade the maintenance FSM or the pack floor against, and §11.1 rule 1 for a missing input is command zero and keep commanding. Dropping it from the SPLIT instead would hand its share to a sibling while the site went on regulating - see `Inverter.is_live()` on why liveness is about the WRITE path and this is not. """ 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, and it is `pv.entities()` - the SAME call `mqtt.optional_keys()` makes. The thing that decides whether the row is ANNOUNCED, the thing that decides whether it is READ and the thing that decides WHAT IS SUMMED have to be one predicate, or the add-on ends up with an entity nothing feeds, a topic nothing declared, or a total taken over a different list from the announced one. `announced_units()` in mqtt.py is the same argument for the inverter roster, in its own words. ⚠️ IT REPLACED A `pv_entity` PROPERTY RATHER THAN JOINING ONE. Two properties where one is a subset of the other is the second spelling arriving by the back door; every gate in this file reads this one. """ 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 for one entity, TEL-12 for a roster of them. `pv.total()` is where "one of three is absent" becomes an absence of the SITE figure rather than the sum of the two that answered, and its docstring is the argument. ⚠️ IT ASSIGNS ON EVERY CALL, INCLUDING THE FAILING ONES. Leaving the last good number standing through an outage is what `circulation` does on purpose - there, a stale figure beats a zero that reads as health - and it is exactly wrong here: this row is read back as HISTORY, and a value that persists through an outage is indistinguishable from a real one at the moment somebody is trying to work out whether the sun was shining. ⚠️ `hass.state()` RATHER THAN `hass.number()`, because the UNIT is half the reading. See `pv.watts()`: a PV integration may publish kW, and there is no magnitude heuristic that can separate 30 W of dusk from 30 kW of noon. """ # ⚠️ TEL-12: EVERY ENTITY IN ONE GATHER, for the reason the outer # gather exists (see `read_inputs()`). Read serially, a three-inverter # roster with one slow sensor would add up to three of `hass.state()`'s # 10 s timeouts to a cycle that feeds the ESP32 heartbeat - i.e. the # telemetry channel would be able to push a healthy battery towards # layer 1's veto, which is exactly the foot in the control path this # card is not allowed to have. Gathered, N entities cost what one does. 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)) async def read_inputs(self, now_mono: float | None = None) -> None: """Every input this cycle needs, and SAFETY-37's freshness verdict. `now_mono` the monotonic clock the per-unit freshness records are stamped on. Defaulted rather than read inside `_read_unit()` so that ONE instant covers every unit in a gather - two units stamped from two clock reads would carry a freshness skew nobody could reproduce - and so that a check can drive real seconds through the gate without sleeping through them. """ 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 it is stale this is None, which falls into the # existing "inputs missing -> command 0 W" path below. There is # deliberately NO fallback to an inverter-side figure: the # inverter's own AC power correlates 0.998 with battery power and # 0.09 with the real meter, so a controller that failed over to it # would be regulating 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, SEQUENTIALLY WITHIN ONE. Read serially, # a unit whose Home Assistant entity is slow delays every unit after it # on the path that feeds the ESP32 heartbeat - the same defect AC 6 # names for the WRITE path, one function up. Within a unit the order is # unchanged (SoC, then battery power), so at the shipped roster of one # this is the same two calls in the same order. # # --- TEL-11: AND THE PV READ RIDES IN THIS SAME GATHER --------------- # ⚠️ IN THE GATHER RATHER THAN ON A LINE OF ITS OWN, AND THAT IS THE # WHOLE "OBSERVABILITY MUST NOT BE ABLE TO DELAY CONTROL" DECISION. # This coroutine feeds the ESP32 heartbeat; a sequential read would add # its own worst case - `hass.state()`'s 10 s timeout - to every cycle, # so a hung PV sensor at a client site would push a healthy battery # towards layer 1's ~30 s veto for the sake of a telemetry row. # Gathered, its latency OVERLAPS reads that already happen and the # added worst case is zero. `peak_forecast_entity` below is read # serially and stays that way because it is a CONTROL input (the # capacity guard sizes off it), so the site is entitled to wait for it. # # ⚠️ AND AN EMPTY PV ROSTER APPENDS NOTHING. Not a coroutine, not an # HTTP call, not a log line: at the shipped default this gather is the # same list of the same coroutines it has always been. _reads = [self._read_unit(inv, now_mono) for inv in self.inverters] if self.pv_roster: _reads.append(self._read_pv()) await asyncio.gather(*_reads) if o.get("peak_forecast_entity"): raw = await self.hass.number(o["peak_forecast_entity"]) # Accept kW or W - nobody's quarter-hour forecast is 50 W. self.peak_fc = None if raw is None else (raw * 1000 if abs(raw) < 50 else raw) else: self.peak_fc = 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.tariff_words = "Prices from Home Assistant entities" else: # TARIFF-03. §9.2 makes a static day/night tariff a first-class # product, and its required EMS handling is TIME-WINDOW SHIFTING. # The add-on has exactly one shiftable load in 0.5.x - the # maintenance cycle's cheap-window grid top-up, ~15 lines below in # cycle() - so this is where the window reaches something that moves # energy. With `static_profile` empty this returns False and the # behaviour is bit-for-bit what it has always been: "fixed-tariff # site, never force a paid grid top-up, wait for sun". # # ⚠️ SYNCHRONOUS AND I/O-FREE, ON PURPOSE. The provider-backed # resolver (static_tariff.resolve_tariff) can perform an HTTP fetch, # and this coroutine is on the path that feeds the ESP32 heartbeat - # a supplier's TLS timeout here would be a supplier able to push the # add-on towards layer 1's 30 s veto. Generation from a static # profile is pure arithmetic, so §2.3's "regenerate on the tick" # applies and there is nothing to block on. Putting a fetched series # on this path needs a BACKGROUND refresh and belongs with OPT-04. # # ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile # alongside the two Home Assistant entities, because this `if` picks # one and the loser is silent. self.cheap, self.tariff_words = static_status( o, datetime.now(timezone.utc)) # ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND # THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the # 30 s I/O-free tick; `fresh_tariff_plan()` is an attribute read and an # age comparison against TARIFF_MAX_AGE_S - still no fetch on this path, # which is the rule the whole branch above exists to keep. What the row # gains is the plan's SHAPE (origin, product, horizon, the firm/carried # split, the spacing) and what it must never gain is the suggestion # that the two halves were computed at the same instant, so the plan # half carries its own age in seconds. See `tariff_plan_row()`. _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 meter # that goes quiet gets one WARNING per episode, not one per cycle. It # is a log line only: no notification, and nothing it decides. # A flapping meter logs at most one WARNING per METER_QUIET_REPEAT_S, # and the episodes in between go to DEBUG. 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.") 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) 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 READING DOES NOT, AND THE ASYMMETRY IS DELIBERATE. `batt` feeds the two consumers this card is about - SAFETY-30's per-unit sign check and SAFETY-31's circulating-power sensor - and its obligation term is command travel, which this method already has. SoC's obligation term is ENERGY through the pack and the constant it needs has never been measured on this site; wiring it here with a guessed constant would be a gate that fires on a rested pack, which is TEL-08's whole hazard. `freshness.BOUNDS["soc"]` states the bound and what TEL-08 owes. ⚠️ THE STANDING COMMAND IS ASKED OF THE UNIT, NOT RECOMPUTED. `cycle()` grades the sign watchdog against `inv.standing_w()` and this gate arms off the same method, because the gate exists to protect that check and a second spelling of "what is standing" would let the two disagree about the number they are both reasoning about. """ inv.soc = await self.hass.number(inv.soc_entity) raw = await self.hass.number(inv.batt_entity, inv.batt_invert) 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, # not a measurement - `inputs_ok` sees the None, the site holds for # `stale_input_s` and then commands 0 W and keeps commanding, which is # §11.1 rule 1 and is already what a missing reading does here. inv.batt = inv.batt_fresh.reading() 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-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING # ELSE IN IT. The escape clause used to read "It clears itself the # moment the register reports a different value", full stop - and # that was FALSE for the state this same line announces, because the # 0 W it announces in the sentence before is exactly what stops the # register ever being asked to report anything different. The live # site sat in it for 790 s on 2026-09-21 and was freed by a human. # ⚠️ SAFETY-56 REWROTE THE DIAGNOSIS HALF, WHICH SAFETY-57 LEFT # STANDING ON PURPOSE. It used to open "battery-power register is # not following its own command" - an accusation against a named # piece of hardware, and FALSE on the very episode above. The # pack read 11 % for the whole hold, every command in it was a # DISCHARGE, and the register answered the first CHARGE command # three seconds later - so the BMS was refusing, the register was # publishing that refusal correctly, and the operator was sent to # look at a working sensor. All three numbers are measured off the # committed capture by test_freshness.py part 11a. The gate's # conjunction is right and is NOT touched; only its conclusion was. # # ⚠️ AND THE TWO EXPLANATIONS ARE NAMED ONCE, IN `%s`, NOT TWICE. # This line's own words are the OBSERVATION plus the one thing a # log reader has to be told before acting - that the observation is # not yet a verdict - and `freshness._grade()`'s sentence, which # this line already interpolates, is where the pair is spelled out. # Saying it in both halves of one sentence is how an operator # learns to skim it. # UX-08 2d: plain, with SAFETY-57's escape clause kept as an # ordered pair (time, then power), and the reason still in `%s`. # UX-11: one sentence and the code. The reason, with SAFETY-56's # two explanations and its figures, and the limits are on the # DEBUG lines below and in the action log. _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 over %.0f s, a command " "over %.0f W and a move by 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 is a latch no operator can audit - and this # one is withdrawing a SITE STOP. `release_note()` is written to say # that nothing has been learned about the register, because nothing # has. 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 still 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, AND FOR THE REASON `self.units` IS ONE. Three surfaces ask this question at three different moments - the cycle, the write, and the status page rendered between two cycles - and a cached flag is a fourth answer that can disagree with the option and the button it is derived from. Derived, they cannot. ⚠️ AND THE WRITE GATE ASKS THIS, NOT A PARAMETER PASSED DOWN TO IT. A suppression that had to be threaded through `write_setpoint()` would be absent from every call site that forgot it - the heartbeat, the stop button, `shutdown()` - which is exactly the shape of hole this add-on's single choke point exists to close. """ 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 latch that is standing while the controller is inert is standing when it stops being inert. `shadow.banner()` carries them so an operator who turns shadow mode off is not handed a battery that will not move and a page that never mentioned why. """ 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: THE WRITE PATH IS GATED ON THE ALLOCATION RECORD -------- specs §1.5 makes "any configuration in which two writers can reach one actuator" a non-goal. Until this method that non-goal was enforced by `len(validate.ACTUATOR_WRITE_KEYS) == 1` - there is one option key, so a second writer is not expressible. True, and a statement about the SHAPE OF THE OPTIONS SCHEMA rather than about the writes. Add CFG-04's per-inverter config and there are two real writers with that length still 1: the check stays green while the property it protects is gone. That is this repository's named failure mode - a check that observes something other than what it claims to observe - so the enforcement moved here, onto the writes themselves. The rule is: A NUMBER REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR PLACED IT, ON THIS TICK, FOR THIS UNIT. A second writer with arithmetic of its own cannot satisfy that - its number is not in any record - so it has to file a Claim and go through `resolve()`, which is the one allocator. "Exactly one allocator per shared resource" stops being a convention somebody can forget and becomes a thing the write path checks. `Allocation.authorises()` holds the per-record half of the argument. ⚠️ 0 W IS ALWAYS AUTHORISED, AND THAT IS THE ANSWER TO "WHAT IF THE ALLOCATOR IS THE THING THAT FAILED". This is not a convenience exit; it is the one value whose correctness does not depend on a split existing: * The hazard the non-goal names is N-times loop gain from BROADCAST - handing every inverter the same target so each supplies it in full (see allocator.py's opening argument, and TEST-19's measured `N=2 gain 1.0` limit cycle). At S = 0 the broadcast and the correct allocation COINCIDE: a_i = 1 for every unit gives every unit 0 W, and 0 W is exactly its share. There is no over-command to make. * It is the fail-toward-inaction direction §11.1 rule 1 already names, and it is what layer 2 commands. `cycle()`'s two refusal branches replace the resolution with a 0 W failsafe and then write it through here like any other cycle; `shutdown()` and the operator's stop button do the same. Every one of those runs while the allocator has REFUSED - `Allocation.ok` is False and there are no shares - so a gate that asked the allocator's permission to command zero would deadlock the failsafe behind the very component whose failure the failsafe exists to answer. A gate that can block the safety write is worse than the hole it closes, so it cannot block it: the test for zero is first and reads nothing. ⚠️ AND IT IS `value == 0.0`, THE REQUESTED NUMBER - not a `failsafe=True` argument. A flag is an assertion by the caller, which is precisely what a second writer would pass; the value is a fact about what is going to be written. -0.0 == 0.0 in Python, and a nan never reaches here (the caller has already turned it into 0.0), so the predicate is total. ⚠️ DEV-02: THE UNIT IS AN ARGUMENT, NOT THE CONTROLLER'S N=1 VIEW OF IT. The record holds one share PER UNIT, so "did the allocator place this number" is a question about a NAMED actuator. Asking it with the first inverter's name at a roster of two would authorise A's share on B's wire and refuse B's own - the site/unit confusion this card removes, wearing the gate's name. `_write_unit()` always passes the unit it is about; the default is for the N=1 callers and REFUSES at N>=2 (see `_only()`) rather than answering for the first inverter. """ 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) -> None: """Put `value` on ONE inverter, or - with `unit=None` - on all of them. ⚠️ `unit=None` IS FOR A NUMBER THAT IS NOT A SHARE. The stop button, `shutdown()` and `maint_abort()` all command 0 W to the whole site, and 0 W is the one value that means the same thing to every inverter. A NON-ZERO value broadcast to a roster of two IS the ARCH-01 fault - each unit supplies it in full and the loop gain at the meter becomes N times the per-unit gain - so it is refused below rather than trusted to the caller. The cycle writes SHARES, one unit at a time, through the allocator. ⚠️ AND THE WRITES ARE CONCURRENT, EACH UNDER ITS OWN BUDGET. See WRITE_BUDGET_S: one hung `hass.call()` on a serialised path used to eat a sibling's layer-1 refresh window. """ 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, exactly 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) for inv in targets)) async def _write_unit(self, inv, value: float, *, force: bool = False) -> None: entity = inv.setpoint_entity if not entity: return # --- SAFETY-03: THE CLAMP, AND IT IS ON THE ONLY PATH TO THE WIRE ----- # Every write in this add-on comes through here - the cycle, the # heartbeat, the operator's stop button and shutdown() - so no optimizer, # maintenance or manual path can route around it. That placement is the # first acceptance criterion, and it is why the guard is not in # control.py: a guard the control law owns is a guard every other claimer # bypasses. # # ⚠️ A NON-FINITE VALUE IS LOSS OF INPUT, NOT AN OUT-OF-RANGE ONE. §11.1 # rule 1: command zero and keep commanding. It must not reach the clamp # arithmetic - see is_commandable() for what a nan does to it - and it # must not be counted as a clamp engagement, or a transport bug lands in # the winding-loop counter. 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: AND IT IS REFUSED HERE, NOT LOGGED SOMEWHERE ---------- # ⚠️ ABOVE THE CLAMP AND BELOW THE NON-FINITE COERCION, and both halves # of that placement are decisions. # # Below the coercion, because a nan is loss of input rather than an # unauthorised write: rule 1 has already turned it into 0 W, which this # gate then authorises unconditionally, so a transport bug does not get # a second, differently-worded refusal. # # Above the clamp, because the question is WHO CHOSE THIS NUMBER and the # answer is only meaningful about the number that was asked for. The # clamp then narrows what was authorised, always toward safety, and it # stays the last thing between a bug and the hardware. Gating the # CLAMPED value would be worse than useless: on every cycle the clamp # engages, the emitted watts differ from the allocator's share by # construction, so the gate would refuse exactly the cycles SAFETY-03 # exists for. # # The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in # place of the number nothing stands behind. Returning instead would # leave the inverter latched on its last command with the ESP32's # heartbeat still being fed - holding an unauthorised setpoint quietly, # which is the outcome this gate exists to prevent. # # ⚠️ AND IT IS ASKED ABOUT `inv`, NOT ABOUT THE SITE. The record holds # one share per unit, so the question "did the single allocator place # this number" is only answerable about a named actuator; DEV-02 made # this method per-unit, and passing the first inverter here would # authorise A's share on B's wire. 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` is the supervised cap on # what may be written to THIS setpoint entity and `inv.dev_min/dev_max` # are what THIS device reported about itself - which is the whole of "the # clamp for B uses B's numbers". Read off `self.o` these 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, never deduplicated: this is the action log # (§15), and it is a different surface from the notification, which # IS deduplicated. The count is what says how hard the loop pushed; # the notification only says that it did. self.log_event(f"{inv.name}: {result.describe()}", hint=messages.hint("write_cut", unit=inv.name)) value = result.applied_w # --- SAFETY-10: AND THIS IS THE LAST THING BEFORE THE WIRE ----------- # Shadow mode runs the whole decision path and commands nothing. The # suppression is HERE, in the one method every write in this add-on # reaches - the cycle, the heartbeat, the stop button, shutdown() and # maint_abort() alike - so there is no call site that can route round # it. app/shadow.py carries the whole argument; three things about the # placement belong here: # # ⚠️ IT IS A SUBSTITUTION, NOT A RETURN, exactly like the two refusals # above it. Returning would leave the inverter LATCHED ON ITS LAST # COMMAND with the ESP32's heartbeat still being fed - a battery held # at whatever it was doing when shadow mode was switched on, under a # status page saying nothing is being commanded. That is the failure # this add-on keeps finding, and shadow mode must not be a new way to # reach it. `shadow.WIRE_W` is 0.0 W and it is REFRESHED on the # ordinary heartbeat, which is also what firmware/goodwe-master.yaml's # 30 s stale-input watchdog requires of us. # # ⚠️ IT IS BELOW SAFETY-28's GATE AND BELOW SAFETY-03's CLAMP, so both # still grade the number the decision path actually produced. A shadow # run that could not say "it would have hit the rating here" would be # answering a different question from the one it is for. # # ⚠️ IT IS NOT A SECOND WRITER. No claim is filed, no transport is # opened and no arithmetic of its own reaches the wire: one actuator, # one writer, and this is that writer emitting the value it emits for # every other refusal. if self.shadow and value != shadow.WIRE_W: # UX-10: DEBUG, because it repeats every cycle in shadow mode. The # INFO line when shadow mode comes on says the same 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 changed = (inv.last_write is None or value != inv.last_write) if not (changed or force): return # ⚠️ BOUNDED, AND THE BUDGET IS AC 6. Without it one inverter's hung # transport holds this coroutine - and therefore the whole control loop, # and therefore every OTHER inverter's heartbeat - for as long as the # HTTP stack allows. A write abandoned here is re-issued on the next # tick by itself: `last_write` is updated only on an ACCEPTED write, so # `changed` is still true. try: ok = await asyncio.wait_for(self.hass.set_number(entity, value), 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, for a per-inverter source. Left out it is the first inverter, which is what every per-inverter claim meant while there was only one. ⚠️ A per-inverter claim built inside the per-unit loop MUST pass it, or inverter B's rating is filed on inverter A and the arbiter bounds the wrong share - the whole defect, wearing this method's name. ⚠️ THE CLASSIFICATION IS NOT HERE. `arbiter.CLAIM_SCOPE` decides whether a source is about the grid connection or about one pack, and it carries the reasoning for all eight; this method only supplies the roster, which is the one thing the arbiter cannot know. Routing a claim by hand - writing `actuator=` at a construction site - bypasses the KeyError that stops a ninth, unclassified source being filed, so do not. """ 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 then carry the whole site target. That is a real change in what the site can deliver, so it is reported rather than absorbed - `Allocation.dropped` carries it and the cycle logs the transition below. A wrong answer here is a site target divided among fewer inverters than exist, which is why it is a method with a reason and not a field somebody can set. ⚠️ AND IT IS ANSWERED PER TICK, NEVER CACHED. A master goes stale between one cycle and the next; an envelope read once at construction over-allocates into a unit that cannot take it. ⚠️ AND IT ANSWERS ONE QUESTION: CAN THIS CONTROLLER PUT A NUMBER ON THIS INVERTER RIGHT NOW. `Inverter.is_live()` carries the two ways it cannot - no `setpoint_entity`, so there is no writer at all; or every write REJECTED for a whole `LINK_STALE_S` window, which is layer 1's own veto period, i.e. the point at which the firmware is about to take the inverter away from this controller anyway. ⚠️ IT IS DELIBERATELY NOT "ITS SENSORS ARE MISSING". A pack whose SoC did not answer is a different fault with a different remedy - `inputs_ok` already stops the whole SITE at P_SAFETY for that - and dropping the unit from the SPLIT instead would hand its share to a sibling while the site went on regulating, which is the opposite of what a missing measurement should cause. ⚠️ AT A ROSTER OF ONE, ANSWERING FALSE IS A 0 W SITE. The allocator has nobody left in the envelope set, refuses (REFUSE_EMPTY), and main.py substitutes the failsafe. That is the honest answer and not a regression: the only way to reach it is a setpoint entity that has been REJECTING every write for 30 s, on which the previous code went on computing setpoints for an inverter it could not reach while layer 1 was already winding it down. It says so in the action log instead. """ 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 and cycle() edge-logs it. A line here # every second would flush the 40-row action log of the context # that explains it. _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, AND IT IS A DIFFERENT FAULT WITH A DIFFERENT REMEDY. A unit that is not commandable is dropped and its share is carried by the survivors; a unit whose power register has stopped following its command stops the SITE. They share this list because they share one place on the status page - the operator asking "why is nothing happening" needs both - and nothing downstream reads it. """ 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] 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's only job is to state honestly what each strategy wants. """ now = datetime.now(timezone.utc) # ⚠️ BEFORE THE FIRST `return` OF THIS METHOD, ON PURPOSE (TARIFF-11). # It stamps that a cycle RAN, which is a different fact from any of the # decisions the cycle may or may not reach, and the status row needs # the two separately. 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, so each # state machine is ticked with the SoC of the inverter it belongs to. A # minimum across packs would abort a cycle on a sibling's state, and an # average would declare a pack full that is not. The pairing is # positional and both lists are built from the roster in `__init__`, so # a pack cannot be graded on its neighbour's meter. # # ⚠️ AND `arbitrate()` RUNS FIRST, ONCE, OVER ALL OF THEM. It is the # site's one maintenance slot - see maintenance.py - and a pack that # does not hold it is told so by name rather than left to discover it. # Calling `tick()` without the answer this produced is the # two-packs-draining-at-once defect with the interlock present but not # wired, which is why the plan is built here and not inside the loop. maint_plan = maint_arbitrate(self.maints, now) maint_results = [] for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan): res = machine.tick(now, inv.soc, blocked_by=blocked) for msg in res.events: self.log_event(msg, hint=messages.hint("maintenance", phase=machine.phase)) maint_results.append(res) # ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The # deferral is edge-logged (it lasts as long as the other pack's cycle), # so by the time an operator asks "why has pack B not recalibrated" the # line that said so has scrolled off. §12's whole failure mode is a # schedule that silently stops firing; a pack that is due, idle and # WAITING has to be able to say so at the moment it is looked at. 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 ------------- # ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing # but not yet stale" hold, a run of held cycles would leave # `disagree_since` untouched across the gap and the timer would resume # from a start time that predates it. It is evaluated every cycle, # unconditionally, so a missing meter CLEARS the window - see watchdog.py. # # The value compared is the command STANDING ON THE WIRE, which is what # this cycle's fresh meter reading is a response to. `last_write` is that # value after the device clamp and confirmed accepted; `target` covers # the case where nothing has been written yet (and, deliberately, the # case where the last write was REJECTED - the inverter is then still # latched on `last_write`, which is exactly what `last_write` holds). # ⚠️ ONE WATCHDOG PER INVERTER, EVALUATED ON THAT INVERTER'S OWN # STANDING COMMAND. What the watchdog concludes is "THE UNIT I AM # DRIVING is pushing power the wrong way", and that evidence cannot be # pinned on a sibling this controller commanded differently. # `inv.target` is that unit's share from the last resolution, which at # N=1 is the site target itself. # # ⚠️ SAFETY-30: AND WHAT IT IS COMPARED AGAINST IS DECIDED BY THE ROSTER. # This block used to hand every unit's detector `self.grid`. There is # ONE P1, so at N>=2 that compares a number this controller put on ONE # actuator against a measurement that answers for ALL of them, and it is # wrong in both directions: # # FALSE TRIP inverter A commanded -1500 W while the meter reads # +1500 W is evidence of nothing when inverter B is # discharging 3000 W exactly as instructed. Nothing is # faulty and the highest-value check in the safety table # fires - on a record of zero false positives. # MISSED the converse, and it is worse. A unit commanded +1500 W # and MEASURED at -1500 W, beside a sibling at +1500 W, # leaves P1 exactly where a healthy site leaves it. The # sum destroys the evidence; no threshold recovers it. # # So at N>=2 each unit is graded against ITS OWN measured power - # `inv.batt`, register 0x0518, per-instance since DEV-02 - which is the # only instrument that can answer "is THIS actuator doing what I told # it". At N=1 §6.2's identity holds, the meter IS the view of that one # unit, and the comparison stays the meter, byte for byte. The site's # own question has not gone away; it is asked below, once, of the sum. # `_arm_sign_watchdogs()` states the choice and its docstring argues it. 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, and this used # to be the only spelling of it. See that method's docstring. 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, and each `set` writes the file. # The ordering is the whole atomicity story: a crash between the # two persists a reason nothing reads, which is harmless, where # the other order persists a latch with no explanation for the # operator who has to decide whether to clear it. # # ⚠️ AND BOTH KEYS ARE NAMESPACED TO THIS UNIT. Flat, a trip # caused by B latched A across every restart until somebody # pressed start, and one press cleared both. See store.py. inv.store_set("sign_reason", inv.sign_wd.reason) inv.store_set("sign_tripped", True) # ⚠️ AND THE LEGACY FLAT KEYS ARE MIRRORED, WHICH IS THE OTHER # HALF OF store.py's RULE 2. That rule keeps the flat keys "so a # rollback still finds its latch", and it only ever covered the # latch that existed AT MIGRATION TIME: a trip that happens # AFTER the migration wrote nothing flat, so an operator who # downgraded would find `sign_tripped: false` and come up # DRIVING, into the fault. store.py used to claim the staleness # could only fail toward a spurious latch; it could not, and # this is what makes that sentence true. The pair is # site-level on purpose - the version being rolled back TO # drives exactly one actuator - and it is cleared by the same # operator press that clears the namespaced ones. 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, WHICH IS THE SUM OF THE UNITS'. # SAFETY-07 and SAFETY-18 both grade a SITE quantity - the quarter-hour # average at the grid connection, and a meter that has stopped moving # while the site pushes near its rail - so what they need is the total # this controller currently has standing on the wire, not one unit's # share of it. At the shipped roster of one the two are the same number. site_standing = float(sum(standing.values())) # --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ---------------------- # The comparison SAFETY-02 shipped, asked of the quantity it was always # about: the TOTAL this controller has standing on the wire, against the # meter that total is answerable for. What it catches is a fault no # per-unit check can see, because every unit is faithfully executing its # share of a site target that is itself pointing the wrong way - an # inverted gain, an integrator wound past the rail, a strategy on the # wrong side of the meter. That is the 2026-08-20 fault day, and it is # the reason the site question survives rather than being replaced. # # ⚠️ ONLY AT N>=2, AND THIS BRANCH IS AC 8. At one inverter the sum of # the commands IS that inverter's command and the loop above has already # made exactly this comparison, at exactly these thresholds, on the # detector whose latch, store keys, claim and banner an install has had # since SAFETY-02 shipped. Arming this as well would be two latches for # one fault, two banners, and the same false-positive record spent # twice. A shipped install does not reach this block at all. 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 - the same atomicity ordering the # per-unit trip uses above and SAFETY-31 uses below: a crash # between the two must not persist a latch with no explanation # for the operator who has to decide about clearing it. # # ⚠️ AND THE KEYS ARE FLAT, NOT NAMESPACED, AND NOT THE # PRE-DEV-02 PAIR EITHER. `sign_tripped` / `sign_reason` are the # ROLLBACK VIEW of a UNIT latch (store.py rule 2) and mirroring # a site trip onto them would hand a downgraded add-on - which # drives exactly one actuator - a latch taken on evidence about # a roster it cannot have. `site_sign_*` is its own pair, for # the same reason `circ_*` is. 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, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON ----- # Evaluated EVERY cycle, unconditionally, above the "inputs missing but # not yet stale" hold below. A run of held cycles would otherwise leave # `over_since` untouched across the gap and the window would resume from # a start time that predates it, which is the ordering bug the sign # watchdog's own note describes. # # ⚠️ THE INPUT IS MEASURED POWER, WHICH IS THE ENTIRE POINT OF THE CARD. # `site_circulating_w` reads each `Inverter.batt` - that unit's own # meter, holding register 0x0518. It is NOT `self.allocation`, and the # property's docstring carries the argument: ALLOC-01's sign-coherence # gate already makes the allocation record incapable of expressing # circulating power, so a record-derived sensor is zero by construction # and detects nothing. What this catches is circulation the allocator # NEVER COMMANDED - a unit ignoring or lagging its share, a stale share # still being executed, or HW-01's wedged power stage. 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, and each `set` writes the file - the # same atomicity ordering SAFETY-02 uses two blocks up, and for the # same reason: a crash between the two must not persist a latch with # no explanation for the operator who has to decide about it. 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, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON # Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but # not yet stale" hold below. A run of held cycles would otherwise leave # each unit's `since` untouched across the gap and its window would # resume from a start time that predates it - the ordering bug the sign # watchdog's own note describes, in a third detector. # # ⚠️ THE OPERANDS ARE `standing` AND `inv.batt`, THE SAME PAIR SAFETY-30 # GRADES, AND THE CONCLUSION IS A DIFFERENT ONE. SAFETY-30 asks whether # they OPPOSE; this asks whether the second one ARRIVED. A unit sitting # at 0 W against a +2000 W share opposes nothing - it is not pushing the # wrong way, it is absent - so the sign gate never opens, the site sum # still agrees with the meter in direction, circulating power is # identically zero, and the only trace left is `reason == # "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an # operator learns to ignore. That is this card. # # ⚠️ AND `delinquent()` IS CALLED ONCE, OVER THE WHOLE ROSTER, BECAUSE # THE CHECK IS A REDUCTION ACROSS UNITS AND NOT A PER-UNIT PREDICATE. # What separates a wedged box from a legitimately saturated site is that # the site's shortfall is a PROPER SUBSET of the units asked to deliver. # Called per unit it would be a shortfall test, which is the saturation # signal again. delivery.py carries the measurement: a pack at its own # cut-out and a wedged unit produce IDENTICAL traces at the unit and at # the meter, so nothing computed from one unit alone can tell them # apart. At a roster of one the reduction is empty for every input. # # ⚠️ EVERY NUMBER BELOW IS THE CONFIGURED ONE, NEVER A LITERAL THAT # HAPPENS TO EQUAL IT TODAY, AND THAT IS A SHIPPED PROMISE RATHER THAN # A STYLE RULE. `delivery.MIN_W_IS_SATURATION_W` and config.yaml's # `delivery_hold_s` block both tell an operator, in prose, that there is # no fourth copy of a threshold to keep in step and no way for the two # to drift - an operator who raises `saturation_w` has told the loop a # bigger gap is ordinary tracking, and this check moves with them BY # CONSTRUCTION. A literal here breaks exactly that promise, silently. # # Both of these were once literals that no check in this repository # could see, because every fixture sat at the shipped value: # # self.tuning.saturation_w -> 250.0 (-19 B) - a survivor of all # 23 suites, because part 4's # gaps are far above BOTH # bms_floor=inv.bms_soc_floor -> 10.0 (-13 B) - a survivor of all # 23 suites, because every # fixture's cut-out WAS 10 % # # test_delivery.py part 4b is what stands between this call and both of # them: it grades this reduction at a `saturation_w` and at two # `bms_soc_floor`s that are NOT the shipped ones, and its arms are # answered opposite ways at two configured values, so no constant can # pass. Change an argument here to a number and read that part before # you decide the red is wrong. for inv in self.inverters: # ⚠️ `self.delivery_cfg`, NOT `DeliveryConfig()`. Same promise: the # window is `delivery_hold_s`, and part 4b's neighbours already # grade this one at 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. It has to be: what this branch means is # "the allocator has already dropped this unit and said so", # and a different window here would be this check answering # a question about a roster the allocator did not build. # It is the ONE number here with no option behind it, so # "graded at a changed config" is not available for it; # what part 6 grades instead is that WIDENING the window # changes the answer. 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)) < _ctl.REFUSED_BELOW_W), # ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not # the site's `peak_soc_floor` either - that one bounds a # money-saving discharge only, and grading a pack against it # explains a wedged unit away in a band an ordinary install # has ten points of. 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 - # the same atomicity ordering the three trips above use: a crash # between the two must not persist a latch with no explanation # for the operator who has to decide about clearing it. # # ⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02 # HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback # still finds its latch", and the version being rolled back TO # drives exactly ONE actuator - a roster at which this check is # inert by construction. A flat mirror would hand a downgraded # add-on a latch taken on evidence about a roster it cannot have. 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)) # ⚠️ `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. One tripped # unit therefore takes the whole roster past the hold, which is the # conservative direction: every other unit's claims are re-filed too. # ⚠️ AND SAFETY-30's SITE LATCH TAKES THE ROSTER PAST THE HOLD TOO, for # the same reason: its `sign_site` SET is what parks every inverter, and # a latched site that returned early here would leave each unit's last # share standing for the heartbeat to re-emit - i.e. the trip would stop # acting the moment a poll was missed. 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. With the meter reading gone the grid value # stops changing, so the next cycle is up to cycle_floor_s # away and the line used to read "0 seconds" all that time. self.why_words = messages.why_waiting(bad_for) self._why_waiting_since = now - timedelta(seconds=bad_for) return claims = [] # --- safety limits: these bind EVERY strategy, always ---------------- # Defence in depth: write_setpoint() clamps to the device range too. The # duplication is deliberate - one of them is the policy, the other is the # last thing between a bug and the hardware. # ⚠️ PER-INVERTER, ONE PAIR PER UNIT, FROM THAT UNIT'S OWN NUMBERS. # This loop is the difference DEV-02 makes to the claim list: the # numbers are ONE inverter's own reported min/max and ONE inverter's own # supervised cap, and there is no such thing as a site rating. Before # this card there was one pair, read from one setpoint entity, filed # once - so a second inverter's rating was never read and never bound. # See arbiter.CLAIM_SCOPE. 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 ------------- # ⚠️ THE POINT OF THE CARD, AND IT IS AN ENVELOPE RATHER THAN A # WEIGHT. Weighting an empty pack down does NOT stop it being # commanded: `allocator._fill()` runs a deliberate last-resort pass # over the zero-weight units when the preferred ones saturate, so # that a preference can never manufacture a standing error at the # meter. Only a closed envelope stops the ask, and `cap` is obeyed # absolutely. So a pack at or under its own BMS cut-out gets # `hi = 0` and a pack at 100 % gets `lo = 0`, and the site target # goes to the sibling instead of being asked of a battery that # cannot answer it. # # ⚠️ AT A ROSTER OF ONE THIS IS NEVER FILED, AND THAT IS THE SAME # IDENTITY `sign_site` IS GUARDED BY RATHER THAN A SPECIAL CASE. # `actuator_for()` routes every source to the SITE at N=1 - with one # inverter the fabricated grid reading and the battery setpoint are # the same number - so this interval would bound THE SITE TARGET # there, not a share. That is a behaviour change on the live # single-inverter install and it is the shape SAFETY-25 decided # against on 2026-09-02 (Option A): ordinary self-consumption is not # given an SoC floor by this card. There is also nothing for it to # do at N=1 - no sibling to move the share to - so the whole of AC 4 # ("at N=1 allocation is byte-identical to today") is this `if`. # # WHAT IT COSTS, SAID PLAINLY: at N=1 nothing here stops the loop # discharging into the BMS cut-out. That is unchanged, it is # SAFETY-25's deferred Option B, and the BMS itself is what stops it. 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. It is # a SET at P_SAFETY, so it outranks maintenance, peak shaving and the # loop alike - a controller pushing power the wrong way is wrong # whatever it thinks it is doing. Re-filed EVERY cycle: claims are not # sticky, and one filed once would lapse the next cycle and hand the # actuator silently back to the loop that was driving the wrong way. # # ⚠️ AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE. The # watchdog compares P1 - one meter, one site - against `standing`, THE # COMMAND THIS CONTROLLER PUT ON THIS ACTUATOR, and what it concludes is # "the unit I am driving is pushing power the wrong way". At N>=2 that # evidence cannot be pinned on a sibling this controller never commanded, # so a site-wide stop would park an inverter this watchdog never graded. # See arbiter.CLAIM_SCOPE. 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, AND IT IS A SITE SET ------------ # ⚠️ THE TWIN OF THE LOOP ABOVE, NOT A DUPLICATE OF IT. That loop files # one `failsafe` per unit on the evidence of THAT unit's own measured # power, and parks that unit. This files ONE claim on the SITE, because # what tripped is the SUM of every standing command against the meter - # a fact about the grid connection, whose offending number is the site # target and which no single inverter owns. Parking one unit on it would # leave the rest of the roster pushing the same way with the guard # satisfied. See arbiter.CLAIM_SCOPE, which classifies `sign_site` and # `failsafe` side by side and says why they differ. # # ⚠️ AND THE `n_units > 1` GUARD IS NOT REDUNDANT, EVEN THOUGH `cycle()` # never arms this detector at a roster of one. The latch is RESTORED # FROM THE STORE, so an install that ran with two inverters, tripped, # and was then reconfigured back to one would come up with # `site_sign_tripped: true` and would otherwise file a claim on evidence # about a roster it no longer has. Guarded, that install comes up # driving - which is right: the fault it latched on is not a statement # about the site it is now. 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: WHAT "ACTED ON" MEANS, AND WHAT IT HONESTLY CANNOT -- # ⚠️ A SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. Read the next # paragraph before changing it, because the obvious objection is right # and is answered rather than ignored. # # THE OBJECTION. HW-01 established that commanding a wedged inverter # achieves nothing - on 2026-09-02 the comms subsystem failed and the # power stage went on executing its last instruction for 6 h 25 m, # through every command this add-on sent it. If the cause of the # circulation is a unit that has stopped listening, then a command is # not a remedy and pretending otherwise is theatre. # # THE ANSWER, AND IT IS WHY THIS IS STILL AN ACTION RATHER THAN A # NOTIFICATION. A circulating loop needs TWO participants. In every # cause on this card's list, exactly one of them is a unit that has # stopped listening and THE OTHER ONE IS THIS CONTROLLER - faithfully # commanding the sibling to a share computed to sum with a number the # wedged unit is not actually producing. Stopping the commanding half # does two things a notification cannot: # # 1. IT HALVES THE LOOP. The unit that is still listening stops being # driven into the one that is not, so the round-tripping stops on # the side this controller owns. That is real energy and real # cycles off a real pack, and it needs no cooperation from the # wedged unit to take effect. # 2. IT MAKES THE RESIDUE VISIBLE. This is the more important half. # Circulating power hides because it NETS TO ZERO AT THE METER. # With this controller's contribution removed, whatever the wedged # unit is still doing stops being cancelled and appears at P1 as a # plain net flow - where SAFETY-02's sign watchdog (which gates on # `abs(grid_w) > 300` and could never open at a netted zero), the # peak guard and the capacity tariff can all see it. The fault # stops being invisible. Measured on the TEST-19 rig: the card's # own scenario reads +0 W at the meter with 6000 W going round, and # the shipped SignWatchdog's predicate is false on every one of # those cycles. # # WHAT IT DOES NOT DO, SAID PLAINLY: it does not stop the wedged # inverter. Nothing this add-on can send will. If the loop persists at # the meter after this trip, the remaining actuator is a human at the # isolator, which is what the notification says in those words. # # ⚠️ AND IT IS A SITE CLAIM. `arbiter.CLAIM_SCOPE` carries the argument: # circulating power is defined over the roster and is identically zero # for any one unit, so there is no attribution to file this per-unit on - # and parking the wrong one of the two would leave the loop running with # the guard satisfied. 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, and the two reasons are why. "stopped" is the # operator switching control off for the whole site; "inputs missing" is # the SITE's own telemetry gone - the meter this controller regulates # against. Neither is about a pack, and both must stop every inverter. # ⚠️ SAFETY-10: `and not self.shadow`, AND IT IS THE ONE FAILSAFE THIS # CARD CHANGES. `P_SAFETY` out-ranks every other claim, so leaving the # stop claim in while shadow mode is engaged would make the recorded # decision "stopped" on every cycle - a shadow run that answers the # wrong question, which is the same as no shadow run. The actuator ends # at the same number either way, and the substitution in `_write_unit()` # that replaces it is STRICTLY STRONGER than this claim: a claim can be # out-ranked by a higher priority, and the wire gate cannot be # out-ranked by anything. 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"))) # --- SAFETY-07: the quarter-peak projection guard -------------------- # ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument. # What is decided HERE and nowhere else is what the guard is allowed to # see and what it is allowed to ask for: # # ⚠️ `frozen` IS EVALUATED ON THIS CYCLE'S STANDING COMMAND, NOT ON # `self.freeze_wd.active`. The watchdog object is advanced at the END of # cycle() (check_meter_frozen), so reading its level here would grade # this quarter against the PREVIOUS cycle's conjunction - one cycle of # lag, in a guard whose entire job is to act inside a 900 s quarter. # `standing` is the value SAFETY-02 already computed above for exactly # this meaning: the command on the wire that this meter reading is a # response to. The predicate itself is freeze.py's shipped # `looks_frozen()`, not a copy of it - see loop_is_following() on what a # transcribed safety predicate costs. # # ⚠️ `achievable_w` BOUNDS THE FLOOR SO THIS CLAIM CANNOT CONTRADICT THE # SAFETY LIMITS ABOVE. `lo > hi` in the arbiter is a CONTRADICTION and # resolves to 0 W - the battery parked, in the one minute of the month it # should be discharging hardest. maintenance_charge_w had to learn the # same lesson (config.yaml, validate.py rule 6); this claim is built so # the config cannot express it. # # ⚠️ AND IT IS FILED EVERY CYCLE, unconditionally, like SAFETY-02's. A # claim is not sticky. Filed once and skipped next cycle, the floor # lapses and the actuator goes silently back to whatever was pushing the # peak. Which of the higher-priority claims outranks it is arbiter.py's # business, not this block's - and the answer is already right: a # maintenance DRAIN or HOLD owns the setpoint at P_MAINTENANCE and # commands discharge or zero, neither of which can create offtake, while # a maintenance CHARGE is only ever a P_MAINT_SHAPE limit (40) and is # therefore bound by this floor. That is §9.1's "money outranks the # maintenance schedule", now as a consequence of the priorities. # # ⚠️ AND IT IS NOT RUN AT ALL WITHOUT P1. `meter_source: off` is the # legacy single-entity path: there is no telegram stream, so # `p1.averager` has no open block, so the guard would be BLIND on every # cycle forever - and blind means "no charging". That is not a # conservative default, it is a controller that silently stops charging # on every install that has not opted in to P1 yet; test_clamp.py's # export case caught it doing exactly that. The billed quantity simply # is not measured on that path, and the honest report is NOT COVERED - # the same answer, for the same reason, that the status page already # gives for SAFETY-18 on a transport that cannot see `unchanged_s`. self.peak_guard = None if not self.p1_enabled else peakguard.decide( self.peak.project(self.p1.averager, now), # ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is # ALREADY removing from the meter, and the meter sees every # inverter. One unit's power here would size the floor from a # fraction of the discharge already happening. batt_w=self.site_batt_w, cap_w=float(self.o.get("peak_cap_w", 3500)), reading=self.peak.peak(now), # ⚠️ 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. Conjoining the SUMS instead # would let one unit's device range cap the whole site. 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 here. An early # draft of the stage-5 loop read SoC as `| float(100)` and thereby # invented a full battery (NOTES.md 4.4b); a full battery is # precisely the value that disarms this floor. decide() treats None # as floor-limited, which is the direction that costs a shave rather # than the pack. # ⚠️ THE EMPTIEST PACK, not an average - see `pack_floor_soc`. A # floor that grades a discharge permission against the mean lets the # emptier pack be driven below the level this option exists to # protect. None stays None: `decide()` treats it as floor-limited, # which costs a shave rather than a pack. soc=self.pack_floor_soc, # ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN # CUT-OUTS. `peak_soc_floor` is still one site-wide EMS floor and is # still read from that one key with CFG-03's one mirror behind it; # what `binding_soc_floor()` adds is the conjunction with each pack's # OWN `bms_soc_floor`, so the floor this site is graded against can # never be below the deepest cut-out on the roster. MAXIMUM here # pairs with the MINIMUM in `pack_floor_soc`: both reductions move # the verdict towards refusing a shave and away from spending a pack. # Under any config rule 8 accepts it IS the option, for every pack - # asserted over the whole schema range in test_peakguard.py part 12 # rather than assumed, because "another rule refuses that" is a claim # about rule ordering and sim/inject.py does not run rule 8 at all. 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, and this call site is # the one that needed it most: `standing` is `last_write`, i.e. the # value AFTER `clamp_to_rating()` applied `[dev_min, dev_max]`, so # grading it against a rail derived from bare `max_w` compares a # device-clipped number to a ceiling the device cannot reach. 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")), ) # SITE, and peakguard.claim() says so on the object it builds. The # quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION, # which is what the capacity tariff bills; there is one quarter-hour # peak per site, not one per pack. ⚠️ IT IS ALSO THE `lo` HALF OF # SAFETY-25 §0c's table - a site `lo` at this stage, which is what the # deferred loop reserve's per-inverter `hi` must never be able to cross. 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; a # guard that appended one every 15 s for a whole quarter would push the # cause of the event off the end of the log that is meant to explain it. # SAFETY-03 logs every engagement because a clamp engagement IS the # count that matters there; here the count is the seconds, and the # engagement's own reason line already carries them. 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_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, and app/arbitrage.py carries the whole # argument. What is decided HERE and nowhere else is what the strategy # is allowed to see: # # ⚠️ IT IS PLACED AFTER THE PEAK BLOCK BECAUSE IT READS ITS CONCLUSION. # `engaged_now` is this cycle's peak verdict, computed ~20 lines up. # Evaluated before it, this would hand the strategy the PREVIOUS cycle's # answer - one cycle of lag on the interlock that implements §9.1's # "peak shaving outranks arbitrage", inside a 900 s quarter. # # ⚠️ AND THE THIRD STATE IS NOT A TIDINESS. Without P1 the guard is not # CLEAR, it never RAN, and `None` says so. arbitrage.decide() refuses on # it, because charging at max_w creates the billed quantity while # nothing is measuring the quarter it lands in. `False` here would be # this line asserting a measurement that was never taken - the defect # `PeakReading` and `quarter_ceiling_w()` both exist to remove. # # ⚠️ THE THREE-STATE COLLAPSE LIVES IN arbitrage.guard_state(), NOT # HERE, and that is not tidiness. It is the same lesson # `shaping_suspended()` records: inline, this expression was reachable # by no check, and mutating it back to a bare `engaged_now` left every # suite in this repository green while a `meter_source: off` install # traded blind. Read guard_state() before touching this line. # # ⚠️ NO FETCH ON THIS PATH. `fresh_tariff_plan()` is an attribute read # and an age comparison; the HTTPS call lives in run_tariff_refresh(). # See TARIFF_REFRESH_S: a supplier's TLS timeout here would be a # supplier able to push this add-on towards layer 1's 30 s veto. # # ⚠️ AND THE UNCERTAINTY SET IS NOT EMPTY BY DEFAULT. §11.4's # no-weather row widens the threshold, and this add-on carries no # weather forecast at all - §8.1's PV forecast is not built. Passing # `frozenset()` would be main.py asserting a forecast is healthy when # there is not one, which is exactly what static_tariff's `uncertainty` # argument has no default in order to prevent. # ⚠️ TARIFF-11: BOTH ATTRIBUTES, AND `arb_at` IS WHAT MAKES THE ROW # HONEST. Reaching this line is the only proof that this cycle got as # far as the arbitrage step; the early return at the input hold above # leaves BOTH standing at whatever the last completed cycle left, and # `arb_at` is then the age the status row reports rather than a # decision presented as current. self.arb, self.arb_at = None, now self.arb_headroom_w = None if arbitrage.enabled(self.o): floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct) # SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED. One evaluation of # the shipped expression feeds both the sizing below and # `arbitrage.sized_from_meter()` in `_shadow_settle()`, so the # caveat on the recorded watts can never be answered from a # different cycle's guard than the watts were. self.arb_headroom_w = peakguard.charge_headroom_w(self.peak_guard) 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. `guard_state()` is a switch and it was the whole loop: # the guard engaged on a 5 W floor, the trade was refused # outright, the loop took the actuator, the meter fell, the # guard released and the trade came back AT max_w. Measured on # TEST-23 stage 3 at the shipped peak_cap_w/max_w: 28 charge # onsets an hour, grid -456 <-> +3200 W. This is the watts the # billed quarter can still afford, so the buy leg is CLAMPED # instead - the shape control.maintenance_charge_floor() has # always given a maintenance charge, off the MEASURED guard # rather than off the optional peak_forecast_entity. # ⚠️ SAME ORDERING ARGUMENT AS THE LINE ABOVE: this reads the # peak block's conclusion, ~50 lines up, so it must stay after # it. Evaluated earlier it would size this quarter's trade from # the previous cycle's quarter. 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 site rating - on # both sides any more: the trade is sized to the packs whose own # state of charge permits that direction, so it can never ask # for more than the roster can take. `trade_power_w()` carries # the argument, including which of the two SoC bounds belongs # here and which belongs on the envelope. ⚠️ AT N=1 BOTH SIDES # ARE `max_w` EXACTLY, whenever the trade is permitted at all: # the one pack that passed the gate is the whole roster. # ⚠️ AND ON THE SELL SIDE THIS IS A NO-OP TODAY on any roster, # because `soc=` below is the EMPTIEST pack, so a sell is # refused outright whenever any pack is at or under the floor - # i.e. whenever this sum could be smaller than the site rating. # It becomes reachable the day that gate becomes per-pack; # `test_packshare.py` part 9 grades it directly rather than # letting an unreachable branch go ungraded. 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) # 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. The transition is what an # operator needs - "it started buying at 03:00", not 240 copies. 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. # At most one machine is out of IDLE on any tick, so at most one of # these branches fires and the claim list is the shape it has always # been. The loop is not a widening of what maintenance may ask for; it # is what makes the ONE claim it does file land on the pack that is # actually being recalibrated, whichever roster slot that is. for inv, result in zip(self.inverters, maint_results): if result.owns_setpoint: # PER-INVERTER. §12's monthly recalibration is a PACK procedure - # drain THIS pack to its floor, charge THIS pack full, # recalibrate THIS pack's coulomb counter. Two packs recalibrate # on two schedules. See arbiter.CLAIM_SCOPE. # # ⚠️ FILED ON THE PACK WHOSE STATE MACHINE PRODUCED IT - `inv`, # the roster entry this `result` was ticked from - and that is # the whole of MAINT-05 at this line. It used to be # `self.maint_unit`, which was `inverters[0]` flatly, because # there was one FSM and its history had nowhere else to live. # Pack B's drain filed on pack A is one pack being commanded for # another's procedure while the pack that is actually draining # stays under the loop: the site would move the right total and # recalibrate nothing. # # ⚠️ AND IT STAYS `SCOPE_UNIT`. Two FSMs mean two claims on two # actuators, which the allocator consumes as per-unit intervals; # ARCH-01 is explicit that this must never become a site claim, # because per-inverter constraints filed site-wide are the # cross-inverter `lo > hi` contradiction at arbiter.py:114. 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 quarter-hour # projection is over the cap, the charge-only shaping is simply not # claimed, so the loop can discharge and shave the peak. When a # dedicated peak-shaving strategy arrives it will claim SET at # P_PEAK and outrank this limit without any code here changing. # ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A # SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's # claim is `lo >= 0`. Both are limits, arbiter.py applies EVERY # limit at or above the winner's priority, and most restrictive # wins - so a peak event during a maintenance charge window files # `lo > hi`, which is a CONTRADICTION and resolves to 0 W. The # battery would park in the exact quarter it should be shaving. # Priority does not save it: clause 2 has no notion of a # higher-priority limit overriding a lower one, and it should not - # "most restrictive" is what makes the safety bounds unconditional. # # So the higher-priority claimant SUPPRESSES the lower one rather # than out-voting it, which is what this branch already did for # `peak_forecast_entity`. That entity is optional and empty on a # default install, so until now the suspension could not fire on the # site that needed it. `self.peak_guard` is measured from the meter # and is always there. # # ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is # not tidiness. It was inline, and a reviewer mutated it back to its # historical broken form - re-gated on `peak_forecast_entity` alone # - and every suite in this repository stayed green while a default # install parked at 0 W in the quarter that sets the month's bill. # Read shaping_suspended() before touching this: both terms are # there, and why neither is sufficient alone. # # ⚠️ ONE SUSPENSION FLAG FOR THE SITE, NOT ONE PER PACK, AND THE # INTERLOCK IS WHAT MAKES THAT TRUE. The quantity it edge-logs is # a peak event at the grid connection, and `arbitrate()` allows # at most one pack to be in CHARGE at a time, so this branch can # run at most once per tick. Were two packs ever allowed to # charge together, this flag would have to become per-pack - and # so would the reasoning above it. if peakguard.shaping_suspended( self.peak_guard, self.peak_fc, float(self.o.get("peak_cap_w", 3500))): # 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`. This line used to re-open the options dict # with its own literal fallback, so the rate the shaping # asked for and the rate the state machine drained at # could be different numbers from the same install - and, # with the key absent, they were. The Controller already # parsed this once in __init__; asking the object that # owns it cannot drift from it. Every pack's machine # holds the same `MaintConfig` instance, so this is the # rate the pack in front of us is charging at. floor = maintenance_charge_floor( self.maint.cfg.charge_w, self.peak_fc, float(self.o.get("peak_cap_w", 3500))) 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))) # --- the grid-following loop ----------------------------------------- # ⚠️ SAFETY-10: `or self.shadow`. The loop is the leg a shadow run most # needs and the one that is switched off by `auto_start: false`, so # without this an install in `auto` shadow mode would record every # strategy's decision and a permanent silence where the grid-following # loop should be. It is safe because the loop's `prev_w` is # `self.target`, which the end of this method re-seeds to what is # actually standing on the wire. # SAFETY-52: a refusal learned while control ran does not survive a # stop or a start (shadow mode keeps the loop running when stopped). 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} if (self.auto or self.shadow) and self.inputs_ok: # ⚠️ prev is the ARBITER's last output, not the loop's own last # wish. If something outranked the loop, that is what the hardware # actually did, and the controller must track reality or it jumps # the moment it regains control. # # ⚠️ The integrator has to track the same reality, and it is no # longer prev_w, so it needs saying out loud: if the arbiter did not # give the loop what it asked for last cycle, the loop's # accumulated error belongs to a command that never happened. # Re-seed from what the hardware was actually told. This is the # failsafe case too - every layer-1 stop resolves to 0 W, so # entering failsafe re-seeds the integrator to zero and the first # cycle after release starts from zero instead of dumping the whole # stale period as power. if self.loop_w is None or self.target != self.loop_w: self.i_w = self.target # SAFETY-52: another writer overrode the loop, so a refusal learned # against the loop's own ask no longer describes the wire. A gap # (`loop_w` None: blind or stopped) keeps it: the stale-reading stop # is what seeds it, and dropping it there restarts the ~70 s cycle. if self.loop_w is not None and self.target != self.loop_w: self._refusal_end("another writer") probe = 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 inside compute() is # `abs(prev_w - actual_w) > saturation_w`, and `prev_w` is the SITE # target. Handed ONE inverter's power at a roster of two it compares # a site number against a unit number, permanently trips the # saturation freeze and leaves a standing grid error - measured on # admin/goodwe's two-master rig at up to 510 W of split-dependence # (gain 0.2; 250 W at 0.4, 50 W at 0.6) while the allocator's shares # summed to S in every run. With the sum, every split is # bit-identical at every gain the schema accepts. That is why the # per-unit readings became per-instance and THIS one became site # level: the two halves of §6.2's identity break in opposite # directions. See `site_batt_w` and test_alloc.py part 8. site_batt = self.site_batt_w # TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED # AGAINST. `self.target` is reassigned further down this method, so # a `decision.i_w - self.target` written at publish time would # subtract a DIFFERENT cycle's command and call the answer a lead. # The published lead is built from the value `compute()` was # actually handed, on the line that hands it over. loop_prev = self.target decision = compute( prev_w=loop_prev, grid_w=self.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=self.batt_refused if len(self.inverters) == 1 else self._site_refused(), probe=probe, ) 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 if len(self.inverters) == 1: self._refusal_update(decision, loop_prev, site_batt, probe) # --- TEL-10 ------------------------------------------------------ # ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT # THE CARD IS. `self.i_w` is carried between cycles and published # nowhere - no entity, no attribute, no log line - so the only way # anyone has ever asked what it was doing is to RECONSTRUCT it from # the grid trace and a replay. That reconstruction reproduced about # a third of the engagements it was checked against and was thrown # away; a card that has to be argued from a discarded reconstruction # is a card that cannot be argued. # # Three rows, and the third is not derivable from the first two by # anyone reading the dashboard afterwards: # # `loop_i` the integrator AFTER this cycle - `Decision.i_w`, # which its own docstring says is the value BEFORE # the output clamp, the slew limit and quantisation. # It is therefore NOT `setpoint` and must never be # published from it. # `loop_lead` how far the integrator stands AHEAD of the command # it was computed against. This is the quantity # SAFETY-48 bounds, and `integrator_max_w / slew_w` # is the number of cycles it takes to unwind. # `loop_reason` `compute()`'s OWN reason string - `deadband`, # `i-clamped`, `clamped`, `slew-limited`, # `saturated-freeze`, `freeze-released`, `tracking`. # # ⚠️ AND `loop_reason` IS NOT `self.reason`. `self.reason` is the # ARBITER's sentence (`resolution.explain()`, e.g. "loop -> 1200 W") # and it does not carry the loop's own word at all: the Claim filed # below takes `decision.reason` and `Resolution.explain()` prints the # winner's NAME, not the winner's reason. The status page's "Why" # row is the arbiter's sentence and stays that way. 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`. On a default install # the integrator bound is what bites and the output clamp is # unreachable (control.py says so), and `reason` is then overwritten # by whichever of slew-limiting or the saturation freeze ran after # it - so a string test is silent on the two cases a winding loop # most often presents as. These flags are set where the clamp # happens and nothing downstream touches them. # SAFETY-40: every number comes from THIS decision, at clamp time, # never from `self.target` after slew/freeze/arbiter rewrote it. 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. This is the claim the # allocator exists to divide. 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 # 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 = () # 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. ARCH-01 left the # allocator one free parameter and ALLOC-01 built the machinery that # consumes it (`allocator._fill`); this line is the first thing that has # ever passed one. Without it every unit weighed 1.0 and the split was # a fixed 50/50 regardless of what either pack could do. # # ⚠️ A METHOD, NOT A DICT. The weighting is by ENERGY HEADROOM and is # therefore direction-dependent, and the direction is the sign of the # site target - which stage 2 chooses inside `resolve()`. See # `pack_weights()` and `packshare.weights()`; the arbiter calls it once, # between stage 2 and stage 3. # # ⚠️ AND IT IS A PREFERENCE, NOT A PERMISSION. Nothing about the site's # ability to DELIVER rests on this argument - a weight cannot stop a # unit being commanded and cannot move a failing unit's load to its # sibling. The `pack` claim filed above is the half that constrains. # --- SAFETY-73: each refusing unit's own P_SAFETY claim --------------- # A hard unit limit that always intersects (OPT-11's P_MODE layer sits # inside it): closed in the refused direction, so the allocator hands # the share to the others within their own limits. During its probe it # is a pin at PROBE_W and the others carry the rest. 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))) _live = self.live_units() while True: resolution = resolve(claims, units=self.units, live=_live, weights=self.pack_weights, tick=self.alloc_tick) # 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 and the split is resolved once more. _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 # --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL -------------- # Both do the same thing - REPLACE the resolution with a 0 W failsafe # which is then written like any other cycle at # `write_setpoint(self.target)` below. Nothing is skipped. 0 W is the # fail-toward-inaction direction: the setpoint is commanded to zero # rather than left wherever the last cycle put it. They differ in WHO # refused and therefore in what an operator should do about it, so they # are two branches with two messages and not one with a vague one. # # ⚠️ AND NEITHER MAY BE LEFT UNGRADED. PR #40's reviewer replaced the # first of these with `if False:` and the whole 17-file suite stayed # green, because at the shipped roster of one it was unreachable. # test_peakguard.py part 10h grades both, on real cycles. # # 1. THE ALLOCATOR REFUSED. Before ALLOC-01 this fired on every N>=2 # cycle, because there was no allocator and the obvious placeholder - # hand the site target to each inverter - IS the ARCH-01 fault. There # is an allocator now and that trigger is gone; the branch is not, # because refusing is still right when no safe split exists at all: a # mis-routed claim (so the number to divide may itself be wrong), no # inverter left in the envelope set, or a split that could only be # made by commanding one pack to charge while another discharges. # `allocator.plan()` carries the argument for each. 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, AND THE DIFFERENCE MATTERS. # Under ALLOC-01 this fired whenever the split named any inverter but # the single one this controller could write, because there was one # `setpoint_entity`. There is a writer per inverter now, so that # trigger is gone - and the INSTINCT is not, because a roster entry # can still have no writer: an inverter configured with no # `setpoint_entity` of its own is an inverter this controller cannot # put a number on. Writing the site target to the ones it CAN reach # would over-command them by exactly the missing unit's share - # not the broadcast fault, but the same shape of over-command. # Commanding a partial site is a worse instruction than commanding # nothing, and "nothing" here means 0 W and says so. # # ⚠️ IT IS NOT THE SAME EVENT AS A DEAD LINK, WHICH IS WHY IT IS NOT # FOLDED INTO `live_units()`. A unit whose writes are being rejected # is DROPPED and its share is carried by the survivors - a real, and # announced, change in what the site can deliver. A unit with no # writer configured at all is a CONFIGURATION that cannot be # delivered, and the site stops rather than silently running at part # strength on a roster somebody wrote down wrong. 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 RATHER THAN TIDY. The write loop below # commands each inverter `shares.get(name, 0.0)`, so carrying the # split through a refusal would write the very numbers this branch # just declined - and, worse, would write NOTHING at all when they # equal what is already standing, because an unchanged value is not # re-sent. The record the operator needs survives on # `self.allocation`, which is assigned above and untouched here. 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, which is 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, not level-triggered, because the action # log is a log of events (§15) and a stale link would otherwise write # a line every second for as long as it lasts. _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. It is # NOT a broken allocation - `Allocation`'s own docstring says why # fusing it with `ok` would let one saturated inverter park the whole # site - but it IS the site failing to deliver what the loop asked # for, and before this card nothing anywhere reported it. It is the # state a closed envelope produces when BOTH packs are at their # limits: the ask has nowhere left to go, and an operator watching a # standing grid error needs the reason rather than the symptom. # Edge-triggered on the VALUE, for `_dropped` reason. _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. It is 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, and a site constraint on one unit is a bound the # other unit never gets. Loud, every cycle, and it does not change # the resolution - the arbiter already routed by `actuator`. _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 about to go on the wire 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 - without this, # every maintenance grid charge trips the failsafe within three minutes. # # ⚠️ THE EXPRESSION LIVES IN watchdog.py, NOT HERE, and that is not # tidiness. It was inline, test_watchdog.py held a transcription of it, # and the P_PEAK case meant to force whoever lands peak shaving to come # and change an expectation did not: editing this line alone to # `winner in ("loop", "peak")` left every suite green while the watchdog # went blind for every peak event. Read loop_is_following() before # touching the gate - the forward hazard and the reasoning are there. 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: `why_key()` maps a safety winner to its claim's reason, and # every key it can return has words in messages.WHY_WINNER. _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) # --- DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE --------------------- # ⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the # allocator's answer and `sum(shares) + unmet_w == round(target_w)` is # an integer identity, so handing each unit its entry is what makes the # gain at the meter a CONVEX COMBINATION of the per-unit gains. Handing # each unit `self.target` is the ARCH-01 fault: each supplies it in # full, the gain becomes N times the per-unit gain, and on the # two-master rig that is 4000 W peak-to-peak forever at gain 1.0 with # not one guard firing. # # ⚠️ AT N=1 THIS IS BYTE-IDENTICAL. The allocator's pass-through returns # `{inverter-a: target_w}` as an unrounded float, so the number written # is `self.target` exactly, as it always was. # # ⚠️ AND A UNIT NOT IN `shares` IS COMMANDED 0 W, not left standing. # That is the dropped-link case: the inverter holds its last command # FOREVER, so "not in the split" must mean zero on the wire, not # silence. The write is the same clamped, budgeted path as every other. 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`, which is None until write_setpoint() # has actually run, so it has to follow the write. The one below grades # `self.target`, which is assigned further up - see check_output_clamp() # and check_meter_frozen() for each. Two guards, two orderings, adjacent # lines: do not "tidy" them into one rule. # # Moving it BELOW the other guards is harmless - nothing between here and # the end of cycle() writes `self.clamp_last`. Moving it above the write # is not, and test_clamp.py part 6 fails on exactly that: it grades the # evidence, not the line number, so a rename or a move into a helper that # still runs after the write keeps it green. (TEST-11.) 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(). This comment used to say "the command that has just # gone on the wire", which is wrong twice over and was corrected by # TEST-06's reviewer: `check_meter_frozen()` grades `self.target` - the # arbiter's resolved intent - and `write_setpoint()` clamps only a local # `value` and can skip the write entirely, so that number may never have # gone anywhere. # # What both terms of the conjunction must describe is the SAME moment: # the command this cycle resolved, against how long the meter that chose # it has been standing still. Evaluated before # `self.target = resolution.target_w` it would grade the PREVIOUS # cycle's command against THIS cycle's meter - the one combination that # was true of neither moment. Moving it between the assignment and # write_setpoint() is therefore harmless; moving it above the assignment # is not, and test_freeze.py part 5 fails on exactly that. await self.check_meter_frozen() # --- SAFETY-31's alarm, and its boundary is the LATCH, not a number --- # Unconstrained where the two above are not: it grades # `self.circ_wd.tripped`, which was advanced near the top of this method # beside SAFETY-02 and which nothing since has touched. It is called on # EVERY cycle rather than only on the cycle that trips, because its # second job is re-raising the alarm after a restart into an existing # trip - see its docstring on why that shares one code path with the # fresh trip instead of getting its own. await self.check_circulating() # --- SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's # It grades `inv.delivery_wd.tripped`, which was advanced near the top # of this method beside SAFETY-02 and SAFETY-31 and which nothing since # has touched. Called on EVERY cycle rather than only on the cycle that # trips, because its second job is re-raising the alarm after a restart # into an existing trip - see its docstring on why that shares one code # path with the fresh trip instead of getting its own. 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(): it grades # `self.peak_guard`, which cycle() assigned above and nothing since has # touched. Kept as its own method rather than folded into that one, # because the two states are mutually exclusive and want OPPOSITE # operator actions - saturation says "shed load, the inverter is already # flat out"; this says "the pack is at its floor and this quarter is not # being shaved at all". await self.check_peak_floor_limited() # --- TARIFF-01, and it is LAST on purpose ----------------------------- # Banking a quarter is bookkeeping about a moment that has already # happened; a slow SD card must never sit between the meter and the # setpoint. Its position is unconstrained in the way SAFETY-03's and # SAFETY-18's are not - it grades neither `target` nor `standing` and # reads only `self.p1`, which no line in this method writes. self.peak.sync(self.p1) # --- SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS ----- # Every guard above has now read `self.target` - the resolved intent - # and SAFETY-18 in particular grades exactly that. Re-seeding before # them would hand each one a 0 W that no decision produced. Re-seeding # AFTER them hands the NEXT cycle, and the sign watchdogs, what is # actually standing on the wire. See `_shadow_settle()`. self._shadow_settle(resolution) def _shadow_settle(self, resolution) -> None: """SAFETY-10. Record the decision, then re-seed the carried command. TWO JOBS, AND THEY ARE IN THIS ORDER BECAUSE THE SECOND DESTROYS THE FIRST'S EVIDENCE. 1. THE RECORD. A shadow run is only worth running if it answers "what would it have done, and why". `self.shadow_would_w` and `Resolution.explain()` are that answer, and the action log gets one line per CHANGE OF DECISION rather than one per cycle - the log holds 40 rows, and a level-logged shadow run would flush the context of its own findings inside four minutes at a 15 s cycle floor. The same edge rule the peak guard and the arbitrage strategy already use, for the same reason. 2. THE RE-SEED. `self.target` and each `inv.target` are set back to `shadow.WIRE_W` - what the write gate actually put on the wire. ⚠️ WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING. Three machines read the carried command as a statement about the HARDWARE: * `compute(prev_w=self.target, ...)` - the loop's slew limiter and its integrator. Left holding a shadow decision, the first cycle after shadow mode is turned off would slew from a base that never existed: up to a full `slew_w` step onto a battery sitting at 0 W. Re-seeded, the exit is safe BY CONSTRUCTION and needs no separate transition code - the carried command already equals the standing one. * `Inverter.standing_w()`, which is what SAFETY-02/30's sign watchdogs grade. Fed a shadow decision they would compare a number against a meter that never saw it, disagree for as long as the decision asked for power, and LATCH A TRIP THAT SURVIVES INTO LIVE OPERATION AND A RESTART. Shadow mode would manufacture the failsafe it exists to help debug. ⚠️ THIS ONE IS ALREADY HELD BY THE WIRE GATE - `standing_w()` prefers `last_write`, and that is the 0.0 W the gate accepted - so the re-seed is the SECOND rung, covering the fallback before any write has been accepted. Two rungs answering one question is MUTATION.md's masking shape, and the mutation that reproduces the hazard has to defeat BOTH: driven singly, dropping this line leaves the detector inert and names nothing about the latch. Measured. * SAFETY-42's delivery watchdog compares `inv.target` against what the pack is producing. A share nothing was told to produce is a delinquency report on every cycle of every shadow run. ⚠️ AND EVERY CYCLE'S DECISION IS THEREFORE COMPUTED FROM WHERE THE PLANT REALLY IS, not from a counterfactual plant that drifts further from reality every tick. "What would this controller command NOW, given this meter and a battery at 0 W" is a true statement about the present; "what would the eleventh cycle of a run that never happened have commanded" is not a statement about anything. """ engaged = self.shadow if engaged != self._shadow_was: # ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. The # edge is against `None` on cycle one, and an install that comes up # CONTROLLING has nothing to report - a "shadow mode off" line at # every start is one more row of boilerplate in a 40-row log, and # it was measured pushing a real clamp episode's context out of # test_clamp.py part 2's ring. Coming up IN shadow mode is the # other direction and it is always worth a line: it is the log an # operator reads to find out why nothing is being commanded. 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 - the # arbiter's winner, the leg being a CHARGE, and the headroom having # been MEASURED - live inside `arbitrage.sized_from_meter()`, which # says why, including why it is NOT keyed on whether the trade was # capped. A rung written at a call site is a rung no check can reach # with the other two clear, which is TEST-16's rule. 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 decision # that moves from the loop to the peak guard at the same number is a # different decision and the operator needs the line; a loop target # that wanders by a watt is not, and 15 s of those 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 about # the same number, and an edge key blind to that would print the first # one and silently keep it standing. It cannot flood: the flag only # moves when arbitrage starts or stops owning the resolution, or when # the guard starts or stops measuring the quarter, and both of those # move the winner or the watts as well on every cycle measured. 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 async def check_output_clamp(self) -> None: """SAFETY-03. Alarm whenever a clamp actually engaged this cycle. Runs AFTER write_setpoint() for the same reason SAFETY-18 does: the clamp is arithmetic done inside the write, so before the write there is nothing yet to report. ⚠️ PUBLISHING IS NOT IN THE WRITE PATH. The clamp itself is synchronous inside write_setpoint(); this - the awaits, the notification, anything that can block on Home Assistant - is out here. A stalled supervisor must never be able to delay or drop a frame. ⚠️ THIS IS NOT A TRIP. No claim is filed, nothing is latched, 0 W is not commanded and `publish()` still reports "running". §11.2's action for this row is "clamp and alarm", and the clamped value is written and the loop keeps running. Turning this into a trip means coming here and changing that deliberately, not discovering it in the field. """ # ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT. `clamp_last` moved # onto the Inverter because a clamp on B says nothing about A; the # ClampWatchdog stayed one per controller because clamp.py's own # docstring says so - it is an anti-flood counter over an EVENT BUS, not # a property of a device, and one notification per inverter per cycle is # the flood it exists to remove. So: any unit that clamped is an # engagement, and the reason NAMES the unit. 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. `log_event()` # keeps 40 rows; a winding loop clamps on every cycle, so a row per # engagement flushes the whole ring within 40 cycles - and the first # thing it evicted, in test_freeze.py, was SAFETY-18's "METER FROZEN?" # row. A guard whose log drowns out the other guards' logs has moved the # flood rather than removed it. The count here is `clamp_wd.engagements`, # so nothing is lost: the row says exactly how many, and the number is # read off the counter rather than off the number of rows. # # This lives here rather than only in write_setpoint() because on a # default install the actuator-side clamp is unreachable - compute() has # already bounded the integrator - so the only engagements that exist are # loop-side ones. Measured: 60 cycles of the §10.3 runaway produce 0 # write-path engagements and 2 loop-side ones. 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. # UX-08: the words are messages.output_clamp()'s; the numbers are the # ones `reason` was written from (ClampWatchdog.facts), asked first. await self.hass.call("persistent_notification", "create", messages.output_clamp(self.clamp_wd.facts, shadow=self.shadow).data()) async def check_peak_saturated(self) -> None: """SAFETY-07. Alarm ONLY when the guard has run out of inverter. ⚠️ AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug signal - clamp it, then alarm" is SAFETY-03's rule and it is right there: the loop computing 14 768 W is a WINDING INTEGRATOR, a defect, and clipping it silently is what let it keep climbing behind a dashboard full of legal numbers. A peak floor above the inverter's rating is not a defect. It is a house load bigger than the inverter - 2026-08-20 15:45 ran ~5900 W against a 5000 W unit - and alarming on the ordinary case is how an operator learns to dismiss the notification that matters. So an engagement is an ordinary control action: it goes to the §15 action log and the status page, and no further. What DOES earn the operator's attention is saturation: the guard asked for more discharge than the inverter can give, so the quarter is going over the ceiling anyway and the controller has already done everything available to it. That is the one state where the money is going to be spent and a human is the only remaining actuator. Edge-triggered, and de-asserting arms it again - the same shape as FreezeWatchdog's `evaluate()`, and for the same reason: one notification per episode, not one per cycle. """ g = self.peak_guard saturated = bool(g is not None and g.saturated) was, self._peak_saturated = self._peak_saturated, saturated if not saturated or was: return self.log_event("PEAK_GUARD_SATURATED - " + g.reason, hint=messages.hint("peak_saturated")) _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) # 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. The guard exists for one euro figure - the capaciteitstarief bills the month's single worst quarter - so a cycle in which it CHOSE not to shave is a cycle in which money is being spent on purpose. Under-shaving quietly would leave the operator with a bill and a status page full of green. The distinction from check_peak_saturated() is the operator's action, which is why these are two notifications and not one: saturated the guard asked for everything the inverter has and it was not enough. SHED LOAD - the controller has already done all it can, and it is still discharging. floor-limited the guard asked for NOTHING, because the pack is at or under the EMS floor, or its SoC could not be read. Nothing is being discharged for this quarter at all. They cannot both be true: peakguard.decide()'s floor-limited branch returns before the `achievable_w` clamp that sets `saturated`. ⚠️ SAFETY-25 - AND THE MESSAGE SAYS WHAT THE FLOOR IS NOT. Read on its own, "the capacity-peak guard has run out of battery" invites exactly one wrong inference: that the pack is now being held at `peak_soc_floor`. It is not. `peak_soc_floor` bounds THIS GUARD only; the grid-following loop has no SoC floor at all and, on the committed 2026-08-20 replay, is being granted discharge in EVERY cycle that raises this notification. An operator who reads the alarm and relaxes because "the EMS has a 20 % reserve" has been misled by us, so the alarm carries the correction itself rather than leaving it to DOCS.md. Edge-triggered, and de-asserting arms it again - FreezeWatchdog's shape, for FreezeWatchdog's reason: one notification per episode, not one per cycle for a quarter of an hour. """ g = self.peak_guard limited = bool(g is not None and g.floor_limited) 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. The saturated path needs its prefix because its # reason carries the word in the middle of a sentence, not at the front. 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. A battery at its # energy floor is not a saturated inverter and is not a frozen meter, # and an operator who has to dismiss three identical-looking cards # learns to dismiss the one that mattered. # ⚠️ SAFETY-25's sentence must render on the unreadable-SoC branch too, # where `soc_floor` is a number but naming it would invite the reader to # think the pack is AT it. `decide()` guards its own reason string the # same way; a f-string that formats a None here would turn the one # notification that matters into a traceback. # ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor # the decision was actually taken with, so this re-grades nothing and # cannot disagree with the alarm it is printed inside. At a roster of one # no pack is named - there is exactly one. _blocked = [name for name, why in peakguard.pack_permissions(self.packs, g.soc_floor) if why is not None] # ⚠️ SAFETY-25's sentence (the reserve binds the peak guard only) is # part of messages.peak_reserve() on every branch, the unreadable-SoC # one included. 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=(_blocked if len(self.inverters) > 1 else ())).data()) async def check_circulating(self) -> None: """SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. ⚠️ THE LATCH IS NOT THE ALARM. `circ_tripped` in `/data/state.json` survives everything, but nothing looks at a JSON file at 3 a.m. What the operator sees is a `persistent_notification`, and THAT IS IN MEMORY: a Home Assistant restart deletes it while the fault runs on. This is not a hypothetical - it is precisely how the 2026-09-02 outage went unseen for 6 h 25 m with the detector correct the whole time (SAFETY-34). So the durable half and the visible half are joined HERE, and the join is `_circ_announced`: a per-PROCESS flag, deliberately not persisted. The latch says "this site is tripped"; the flag says "this process has put that in front of a human". A restart clears the flag and keeps the latch, so the notification is RE-RAISED on the first cycle after every add-on start. A Core-only restart does NOT restart the add-on, so it is also re-asserted every REANNOUNCE_S (SAFETY-34). ⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE. A fresh trip and a restart into an existing trip raise the alarm through these same lines. Two paths would be two chances for the restart one to rot unnoticed, and the restart one is the case nobody exercises by hand. """ 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. The latch is # already durable and the log line above already fired; this surface is # best-effort by design. 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. `unit//delivery_tripped` in `/data/state.json` survives everything, but nothing looks at a JSON file at 3 a.m. What the operator sees is a `persistent_notification`, and THAT IS IN MEMORY: a Home Assistant restart deletes it while the fault runs on. That is precisely how the 2026-09-02 outage went unseen for 6 h 25 m with the detector correct the whole time (SAFETY-34), and it matters more here than anywhere else in this file, because SAFETY-02 and SAFETY-31 at least leave a stopped site for somebody to notice. This one leaves a site that looks like it is working and is quietly delivering one unit less than it has. So the durable half and the visible half are joined HERE, and the join is `_delivery_announced`: a per-PROCESS set of unit names, deliberately not persisted. The latch says "this unit is tripped"; membership says "this process has put that in front of a human". A restart empties the set and keeps the latch, so the notification is RE-RAISED on the first cycle after every add-on start, and re-asserted every REANNOUNCE_S because a Core-only restart does not restart the add-on (SAFETY-34). ⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE - `check_circulating()`'s argument. A fresh trip and a restart into an existing trip raise the alarm through these same lines; two paths would be two chances for the restart one to rot unnoticed, and the restart one is the case nobody exercises by hand. ⚠️ AND ONE NOTIFICATION ID PER UNIT. Every other alarm in this file has a single fixed id because its subject is the site; this one's subject is a named box, and two wedged inverters must produce two cards rather than one replacing the other. The id is built from the same `unit_slugs()` TEL-07 announces the MQTT entities under, so the card and the entity an operator correlates it with carry the same token. """ 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. The # latch is already durable and the log line already fired; this # surface is best-effort by design. 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_meter_frozen(self) -> None: """SAFETY-18. Alarm on a meter that answers forever without changing. Notification only - no claim, no latch, no 0 W. freeze.py carries the reasoning and the euro figure; DOCS.md carries the exposure this does NOT close. ⚠️ getattr, not an attribute access: `unchanged_s` exists only on `homewizard_local`, and `p1_source` is None until amain() builds the transport. Every other meter_source hands the predicate None and gets False, which is the honest answer - see the transport note in freeze.py. """ 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. Both terms are required - a plan with no timestamp is one nothing stored, and a timestamped plan older than TARIFF_MAX_AGE_S is a reading from a task that has stopped answering. """ 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. What it must never do is raise out of here and take the task down, because then the plan freezes at its last value and looks current forever. That is what `fresh_tariff_plan()`'s age bound is the backstop for, and this is the half that keeps the task alive. ⚠️ THE BOUND IS NO LONGER A BACKSTOP ONLY: since TARIFF-12 it is also the serve-stale window an outage runs on, and it is consulted below on every refresh. ⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN - WHICH IS A CLAIM ABOUT WHAT `resolve_tariff()` RETURNS, NOT ABOUT WHAT IT RAISES (TARIFF-12). This paragraph was enforced by the `except` below alone, and the `except` below is the one path a supplier outage never takes. `load_prices()` says so in its own words - it "RETURNS, NEVER RAISES, for every failure a site can actually have" - because a degradation arriving as a traceback into the planning tick would stop the controller regulating over an expired certificate. So a dead feed is a RETURN: the `try` succeeds, `_degrade()` hands back a §11.4 plan, and the assignment used to overwrite the good one unconditionally. Measured in a 40 h soak with `static_profile` unset (the shipped default, and the normal state of a dynamic-tariff site): the FIRST failed fetch replaced a complete 48 h plan with `TariffPlan(no_data, None, "none")` - 0 points - 27 minutes after the last good one, and `TARIFF_MAX_AGE_S` governed nothing. A transient blip cost the same as a 90-minute outage. ⚠️ SO THE QUESTION IS ASKED ONCE, AFTER THE ATTEMPT, FROM THE EVIDENCE THAT EXISTS - FCST-06's answer, in this file. The evidence is `plan.from_feed` (did this attempt observe the feed) and `fresh_tariff_plan(now)` (is an observation still current). A degradation replaces a standing observation only once that observation has aged out, so `TARIFF_MAX_AGE_S` is now the serve-stale window it reads like rather than a bound nothing could reach. ⚠️ AND A §11.4 STATIC FALLBACK DOES NOT REPLACE A FRESH PROVIDER PLAN EITHER, although it carries real prices. Every point `_degrade()` builds is `provisional=True`, so `firm_points()` is empty, `arbitrage()` refuses and `cheap_now()` is False - everything a consumer may act on is already off. A day-ahead price for a given hour does not change once published, so a plan fetched half an hour ago is exactly as true about that hour as one fetched now. The fallback is less actionable and no more current; it takes over when the observation retires, which is what §11.4 is for. The reverse is not symmetrical: a standing plan that is ITSELF a degradation is replaced by a fresh one, or an outage would freeze the operator's static profile at the window it was generated for. ⚠️ AND NOTHING HERE READS `arbitrage_enabled`, WHICH TARIFF-10 MAKES WORTH SAYING. Since that card this task also runs on a site with a provider configured and trading DISARMED, so "keep the plan" is a decision about what the add-on has LOOKED UP, not about what it may trade on. It is also strictly downstream of `resolve_tariff()`: the §8.3 carry into `price_history` happens on the `series` branch inside that call, before this decision exists, so which of two plans is kept cannot add or lose a recorded day. ⚠️ AND `tariff_at` DOES NOT MOVE ON A KEPT PLAN. It is the moment the fetch that produced the standing plan happened, and `fresh_tariff_plan()` judges the age from it - re-stamping it here would be this task asserting the feed answered when it did not, and the plan would "freeze at its last value and look current forever", which is the hazard the paragraph above this one names. An unmoved `tariff_at` is also the only thing that tells a kept plan from a freshly fetched one, so it is what the check asserts. """ 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) standing = self.fresh_tariff_plan(now) # Three terms, and each of them is the only false one in some # check: the feed answered (replace), nothing current stands # (take the degradation - this is what makes the age bound # govern), or what stands is itself a degradation (replace). keep = (not plan.from_feed and standing is not None and standing.from_feed) if keep: _LOG.warning( "The price update was incomplete, so the plan from %s " "is kept until it is %s old. The update held %s.", messages.when(brussels(self.tariff_at)), messages.duration(TARIFF_MAX_AGE_S), plan.words) else: self.tariff_plan, self.tariff_at = plan, now _LOG.info("tariff refreshed: %s", plan.words) except Exception as err: # noqa: BLE001 # ⚠️ NOT THE OUTAGE PATH - see the docstring. `load_prices()` # returns on every failure a site can have, so what reaches # here is something nobody expected, and the plan is kept for # the same reason and on the same terms. _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. There # was one read of one `setpoint_entity` here, so a second inverter's # rating was never read at all and its clamp, its freeze rail and the # peak guard's `achievable_w` were all the first one's numbers. # Concurrently, for `read_inputs()`'s reason: a slow ESPHome on one unit # must not delay the controller's start on every other. async def _limits(inv): if inv.setpoint_entity: inv.dev_min, inv.dev_max, _ = await self.hass.limits( inv.setpoint_entity) 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. # # amain() already ran this gate before anything started. It runs # AGAIN here for one case the gate cannot cover: ESPHome is often # not up yet when the add-on boots, so hass.limits() returned # (None, None) and the range checks were skipped rather than # guessed at. This is the first moment the device's real rating is # known. If it conflicts, refuse now - stopping deterministically # through the normal shutdown, which commands 0 W on the way out. # # ⚠️⚠️ 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. # `entity` is the FLAT `setpoint_entity`, which a per-inverter config # leaves blank (validate rule 9(e) requires it), so on the very config # this add-on now supports the entire re-check was SKIPPED: no # inverter's `max_w` was ever graded against the device it is written # to, and the skip was silent. The flat gate still guards the flat # rules, because they read flat options and one device's range; the # per-unit rule runs unconditionally, off the roster, and grades each # unit against ITS OWN ESPHome. At one inverter `validate_unit_ranges()` # skips the unit the flat call already graded, so nothing is reported # twice and the N=1 path is unchanged. 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, then return WITHOUT entering the loop. amain() wakes # on this, runs the normal wind-down (which commands 0 W) and # re-raises ConfigRefused afterwards, so the exit code is 78 and # the inverter is left at zero rather than latched. 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. Every cycle # resets it, whichever trigger fired, so on a healthy changing meter it # never elapses and not one existing cycle moves - and therefore no # change to the per-cycle gain the tuning was measured against. # # That is true twice over, and the second reason is the sturdier one: # the combined cycle rate is max(arrival rate, 1/floor), never the sum. # At the defaults 1/15 s is strictly slower than the ~1/5 s arrival # rate, so even a floor that raced the arrivals could not push the loop # past its nominal healthy rate. The re-tune is unreachable by # construction, not merely by the reset. ⚠️ That argument holds only # while floor > the meter's update interval - which is why the schema # minimum of 5 s deserves the second look validate.py has not given it # yet: 5 s IS the reference meter's interval, i.e. the one configurable # value that would double the cycle rate. # # Why it has to exist, and it is not only about latency: `self.grid != # last_grid` alone means a meter that repeats a value runs NO control # cycles at all. The heartbeat below keeps re-emitting the last command # and the firmware keeps putting it on the wire, so every indicator # reads healthy - and, worse, the `inputs missing -> 0 W` claim lives # INSIDE cycle(), so on a repeating meter THAT SAFETY CLAIM CANNOT FIRE. # Demonstrated in test_loop.py: constant meter, SoC entity dies at t=4 s, # and the pre-fix controller holds 220 W through the whole outage with # the status line still reading "loop -> 220 W". Layer 1 does not cover # it on a `meter_source: off` install either - no P1 age sensor is # published, so the firmware has nothing to veto on. Measured on the rig # during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals, zero # cycles. # # This is NOT a freeze detector, and deliberately so - see # HomeWizardLocalSource.unchanged_s for why one cannot be built here (at # the converged -10 W target a 1 Wh register needs ~6 minutes to move, # so any freeze threshold false-trips at the exact operating point we # run at). Nothing here decides whether a repeated reading is true. It # only stops "the number is the same" from meaning "do not think". # # ⚠️ THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN. It is under # validate.FIRMWARE_INGEST_VETO_S (30 s) and under meter_max_age_s on # purpose, so the loop re-decides at least twice inside any window in # which layer 1 might otherwise be the only thing deciding. If the # firmware's veto ever moves, this default moves with it - it is not # independently justified at 15. 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. A floor cycle runs the # same law on the same inputs as an arrival cycle, because in P1 # mode the freshness question is already answered upstream and # answered harder: past `meter_max_age_s` (30 s) p1.net_w is # None, which is "inputs missing", which becomes a P_SAFETY 0 W # claim. So a floor cycle cannot run the PI on a 60 s old # figure, because at 60 s there is no figure. # # ⚠️ 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. Running the full law is still the right choice # there - the alternative is a degraded second law reasoning # about an age nobody measured - but the floor does not make # that path fresh, and nothing here should be read as claiming # it does. And the integrator # advances per CYCLE, not per second (control.py), so bounding # the gap bounds the accumulation: at most one extra # gain x error per floor interval, under the same # integrator_max_w as every other cycle. A second, gentler # control law for floor cycles would be a second set of # dynamics nobody can test on this hardware. 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. # This was ONE `last_beat` and ONE serialised write, so a hung # `hass.call()` to inverter A consumed inverter B's ~30 s layer-1 # budget and the firmware zeroed a healthy sibling. Now each unit # owns its own deadline, each write is dispatched CONCURRENTLY and # each is bounded by WRITE_BUDGET_S - so the worst a hung A can do # to B is delay B's NEXT beat by the budget, which is why the budget # plus the schema's largest `heartbeat_s` has to stay under layer # 1's veto. That inequality is graded, not asserted. # # ⚠️ AND EACH UNIT REFRESHES ITS OWN SHARE, `inv.target`, never the # site target. A heartbeat that re-emitted the site number would be # the broadcast fault arriving through the back door, once every # `heartbeat_s`, on a path with no allocator in it. 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. Inline, the only thing a test could grade was its OWN gather of `write_setpoint()` calls - which is a check on the fixture, and a serialised shipped loop survived it. Measured: the mutation that replaced the concurrent dispatch with a `for` loop passed the whole suite while this block was inline. `loop_now` is the caller's monotonic clock, the same discipline every watchdog in this add-on takes its clock by. """ 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. Moved after, a # unit whose write hangs would have its deadline advanced late every # time and would drift into being re-sent on every tick; and a unit # whose write RAISED would never have it advanced at all. for inv in due: inv.last_beat = loop_now # ⚠️ CONCURRENTLY. In series, every inverter behind a hung one waits out # that one's whole budget before its refresh is even attempted - and # before this card, with no budget at all, it waited out the HTTP # stack's timeout. That is one inverter's transport fault pushing a # healthy sibling towards layer 1's ~30 s veto. 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. These entities are already # discovered and already on every dashboard (TEL-07 keeps their # `unique_id`s), so `battery` stays the number the meter sees the # battery doing - the SUM - and `soc` stays the one a floor is graded # against - the EMPTIEST pack. At the shipped roster of one both are # inverter A's own readings, unchanged. Per-inverter entities are # TEL-07's, not a key rename here. 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. IF-01/IF-02 own `ems/event/failsafe` and the `failsafe active` # binary sensor and neither is built yet; inventing a bespoke topic # here would be a surface to migrate later. This sensor is already # discovered, already on every dashboard, and "failsafe" is # additive to its vocabulary rather than a change to it. # SAFETY-30 adds the site sign latch to the same vocabulary, for # the reason the note above gives: "failsafe" is additive to a # sensor that already exists and is already on every dashboard, and # a P_SAFETY stop that did not reach it would be a site at 0 W # reporting "running". "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". A fleet dashboard reporting a # shadow install as `running` would be calling a # battery nobody is controlling healthy; one reporting # it as `stopped` would lose the fact that the decision # path is live and being recorded. ⚠️ BELOW `failsafe`: # a latch that is up is up, and shadow does not clear it. else "shadow" if self.shadow else "running" if self.auto else "stopped"), # SAFETY-31. ⚠️ MEASURED, not the allocation record - see # `site_circulating_w`. `publish()` drops None values, so a cycle # with a missing per-unit reading leaves the last figure standing # rather than publishing a zero that would read as health. "circulation": (None if self.site_circulating_w is None else round(self.site_circulating_w)), # TEL-10. The loop's own three, written from the fields `cycle()` # recorded and not re-derived here - the page and the publisher run # on their own tasks and `self.target` has moved on by the time # either of them looks (SAFETY-47's note on `sized_from_meter`, one # field up, is the same hazard). "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. Those are two different # states and the publisher treats them as two: `mqtt.ABSENT_AS_UNKNOWN` # turns the first into HA's own `None` payload, so the entity's state # goes to `unknown` and the recorder records a GAP - which is how a # reader of a week of history tells "no PV reported" from "PV reported # 0 W". The second never reaches the broker at all, because an install # that never set `pv_entity` or `pv_entities` has no such entity # announced. ⚠️ TEL-12: AND THE ROW IS STILL ONE NUMBER. At a roster of # three it is the SITE total, `None` unless EVERY entity answered - see # `pv.total()`, whose note is where the decision not to publish a # partial sum is argued. This line did not have to change for that, and # that is the point: TEL-11's `None`-is-an-absence rule already covered # it, one layer up. # # ⚠️ AND THE GATE IS `optional_keys()`, THE SAME EXPRESSION THE # ANNOUNCEMENT USES. Written as a second predicate here the two could # disagree, and both directions are bad - an announced entity nothing # feeds, or a topic nothing declared. `announced_units()` two blocks # down is the same discipline for the roster. 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. The ESP32's stale-input # watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id # note in mqtt.py; the composed name is not the object_id - and forces layer-1 # failsafe once it reaches max_age_s. With meter_source off there is no # ingester feeding it, so published_age_s would be time-since-startup # climbing without bound - i.e. every existing install would cross the # threshold within 30 s and pin its inverter at 0 W forever. Publishing # nothing leaves the entity non-existent, which is the status quo and # what has_state() in the firmware is checking for. 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. # `announced_units()` answers `[]` at a roster of one, `zip` then yields # nothing, and the values dict comes out byte-identical to what shipped - # so the condition that decides what is ANNOUNCED is the same expression # that decides what is PUBLISHED. Written as a second predicate, the two # could disagree, and both directions are bad: an announced entity # nothing feeds (the hazard `omit` exists for) or, at N=1, a published # topic nothing announced, which AC 4 forbids outright. # # ⚠️ AND THE SITE ROWS ABOVE DO NOT MOVE. `battery` stays the SUM and # `soc` stays the EMPTIEST pack; these are additions beside them, not a # re-pointing of them, because those two ids are on the live install's # dashboards and in its automations. 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") self.mqtt.publish(values) async def shutdown(self) -> None: """Deterministic wind-down. Do not skip this.""" self.stopping = True _LOG.info("shutting down - commanding 0 W on every inverter") await self.command_site_zero() self.mqtt.close() async def command_site_zero(self) -> 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. Before this card the heartbeat re-emitted `self.target`, so zeroing that one scalar was enough: the stop button set it and the next refresh carried the zero. The heartbeat now re-emits `inv.target` - it has to, or it would broadcast the site number - so a stop that zeroed only `self.target` would leave each unit's LAST SHARE standing, and the very next heartbeat (up to `heartbeat_s` later, and before any cycle has run) would put it back on the wire. The operator would have pressed stop, watched the inverter go to zero, and seen it resume seconds later. Both must move together, which is why this is one method with a reason and not two lines repeated at three call sites. ⚠️ AND 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE. It means the same thing to every inverter; a non-zero site-wide write is the ARCH-01 broadcast fault, and `write_setpoint()` refuses it. """ self.target = 0.0 for inv in self.inverters: inv.target = 0.0 await self.write_setpoint(0.0, force=True) # -- 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). Every other peak query already takes it - `PeakStore.peak()`, `PeakTracker.peak()`, `headroom_w()`, `warning_frac()` - and the row built from them was the one thing that could only be read against `datetime.now()`. A suite that banks an August quarter then asks this page what the peak is goes green in August and red in September, which is what happened at 00:00 on 2026-09-01: TARIFF-01's own section 10, red on a calendar boundary, on a commit nobody had touched for a day. """ 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)}) 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. Past the limit it is the red row below, as before. 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). The whole argument for # leaving it unthresholded is that a human interprets it, which # requires a human being able to see it - so here it is. getattr: # only homewizard_local has one, and p1_source is None until # amain() builds the transport. unchanged = getattr(self.p1_source, "unchanged_s", None) out.append({"ok": True, "warn": False, "id": "meter", "text": messages.row_meter( self.p1.net_w, age, self.p1.parse_errors, unchanged)}) # SAFETY-18. Reported in both states, for the reason SAFETY-02 is: # a detector nobody can see is armed is a detector nobody trusts # when it fires. ok=False/warn=False so the banner goes red - this # is an alarm, and the banner is the loudest surface we own. It # changes NOTHING about control; see check_meter_frozen(). 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. The transport case above and this one are the # same statement - "the conjunction cannot be evaluated here" - # and an operator does not need to learn two vocabularies for # it. What differs is only which term is missing: there, # `unchanged_s`; here, a rail the command could ever stand at. _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. This row used to interpolate # `rail_w(max_w)` while `looks_frozen()` graded a command the # device had already clipped below it, so on any install whose # inverter is smaller than its configured ceiling the page named # a number the command could never reach and called it `armed`. # Both the row and the predicate now read the same three inputs. _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. # UX-08: "the inverter's own limit" says the device set it; which # inverter and its range are DOCS and the settings, not entity ids. out.append({"ok": True, "warn": False, "id": "freeze_check", "text": messages.row_freeze_on( self.freeze_wd.cfg.hold_s, _rail, own_limit=_rail < rail_w(self.site_max_w))}) # TARIFF-01. The month's billed peak, and - the load-bearing half - # a DIFFERENT row when the store could not answer. "no peak yet" is # a normal state on the 1st; "unreadable" is a broken instrument, and # a status page that spelled them the same way would let a 0 W read # look like a quiet month right up until SAFETY-07 shaved nothing. # UX-08: in plain words. The store's state at start is on the INFO # line `capacity-tariff peak store: ...` and `_pk.explain()` on the # log; the coverage figure (TARIFF-13) is a row of its own. _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. `None` # is a fourth state on purpose: before the first cycle the guard has # concluded nothing, and saying "idle" then would be a claim about a # decision that has not been taken. _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: out.append({"ok": False, "warn": False, "id": "peak_guard", "text": messages.row_peak_saturated(_pg.ceiling_w)}) elif _pg.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. A # separate row from saturation because the two ask for opposite # things - see check_peak_floor_limited(). # # ⚠️ SAFETY-25. THE SCOPE SENTENCE IS PART OF THE ROW, NOT A # DOCS FOOTNOTE. This is the one line an operator reads at the # exact moment the guard stops discharging, and read alone it # says "the battery is being held at peak_soc_floor" - which is # false, and measurably so: on the committed evening replay the # loop goes on discharging in EVERY cycle this row is showing. # A floor that binds one consumer and not the other is worse # than no floor if the page does not say which. # # ⚠️ AND IT CARRIES ITS OWN `banner`. status_payload() renders # the FIRST not-ok/not-warn row as the page's headline, so # without this the loudest element on the page would be a # 550-character paragraph that the row three lines below # repeats verbatim. The banner is the short form; the row keeps # the whole sentence, because the row is where an operator # reads what the floor does and does not bound. Shortening the # ROW instead would be weakening AC 3. # UX-08: the same three branches, and the same pack naming, as # check_peak_floor_limited()'s notification. The figures stay in # `_pg.reason`, on the log and the event list. _blocked = [name for name, why in peakguard.pack_permissions(self.packs, _pg.soc_floor) if why is not None] out.append({"ok": False, "warn": False, "id": "peak_guard", "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=(_blocked if len(self.inverters) > 1 else ()))}) elif _pg.blind: out.append({"ok": False, "warn": True, "id": "peak_guard", "text": messages.row_peak_guard_blind( peakguard.blind_kind(_pg.blind))}) 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)}) else: out.append({"ok": True, "warn": False, "id": "peak_guard", "text": _pg.explain()}) else: # ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point. # SAFETY-07 is NOT COVERED on the legacy single-entity path - the # quarter-hour average is measured from P1 telegrams and this path # has none - and a row that only rendered where the guard runs would # leave the one install that is unprotected showing nothing at all. # Same answer, same reason, as the freeze detector's NOT COVERED row # a few lines up. 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. The label keeps its exact wording at a roster of one, because # that is what an existing install reads. 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.soc_entity)) rows.append((messages.LABEL_BATTERY, unit, inv.batt, inv.batt_entity)) 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. It read # " is missing or not numeric" for ANY None, and one of # the ways this value becomes None is SAFETY-37's freshness gate # withholding a reading that is present, numeric and updating - # `Freshness.reading()` returns None BY DESIGN there. On the # live site on 2026-09-21 the operator opened Developer Tools on # the entity this row named, found it working, and had every # reason to conclude the diagnostics were broken. # # ⚠️ AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. The # row could ask `inv.batt_fresh.stale` - it is on the same # object at the same moment - but this loop also renders the # grid and SoC rows, which have no gate, and a branch per row # would be a second place where "why is this None" is decided. # The gate already has a surface of its own: `stale_note()` # rides `liveness_notes()` a few rows below with the whole # STALE_READING sentence in it, so the operator's discriminator # is "is that row there?" and this row's job is only to stop # pre-empting the answer. Same reason the wording is not shared # with `freshness._grade()`: that one is a log line about a # BEHAVIOUR, this one is a banner about an ENTITY. 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: sp = inv.setpoint_entity unit = None if len(self.inverters) == 1 else inv.name if not sp: out.append({"ok": False, "warn": False, "id": "control", "text": messages.row_control_unset(unit)}) elif inv.dev_max is None: out.append({"ok": False, "warn": False, "id": "control", "text": messages.row_control_missing(unit)}) else: out.append({"ok": True, "warn": False, "id": "control", "text": messages.row_control(inv.dev_min, inv.dev_max, unit)}) # DEV-02. Whether this controller can still put a number on each # inverter, and it is reported in the ONE state that matters: a unit # `live_units()` has stopped listing has had its share handed to its # siblings, or - at a roster of one - has taken the site to 0 W. A # dropped link that shows nothing on the page reads as a healthy site. # Red only for a unit that is no longer live; the rest are amber. # The same notes, in the same order, as `liveness_notes()`. _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}) # SAFETY-03. Red while an over-rating clamp is engaged (amber for the # integrator bound, SAFETY-40), and the engagement COUNT is shown even # after it clears - a row that vanishes with the condition takes the # evidence with it. Reported only when there is something to report: # unlike the freeze detector this guard has no "not covered" state, it # runs on every write on every transport. # UX-07 item 1 / UX-08: each count is shown under ITS alarm's name - # over-rating cuts as "Power request too high", integrator-bound cycles # as "Battery is not keeping up" - never the two summed under one. _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)}) # 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: out.append({"ok": False, "warn": True, "id": "not_keeping_up", "text": messages.row_before( messages.T_LOOP_BOUND, _cw.bound)}) for inv in self.inverters: if inv.last_write_ok is False: out.append({"ok": False, "warn": False, "id": "write", "text": messages.row_write_refused( None if len(self.inverters) == 1 else inv.name)}) # SAFETY-02. Reported in both states: a watchdog nobody can see is armed # is a watchdog nobody trusts when it fires. # ⚠️ ONE ROW PER INVERTER, because there is one watchdog per inverter # and a trip on B leaves A commanding. A single row would have to pick # one of them to describe, and the honest answer at a roster of two is # both. At a roster of one the wording is unchanged. # ⚠️ AND SINCE SAFETY-30 THE ROW SAYS WHAT THE UNIT WAS COMPARED # AGAINST, because at a roster of two that is no longer the meter and a # row that still said "the command opposed the meter" would send an # operator to look at P1 for a fault that is visible only on one # inverter's own power sensor. `sign_wd.against` is the same string the # trip sentence uses, so the two cannot drift apart. for inv in self.inverters: unit = None if len(self.inverters) == 1 else inv.name # ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the # shipped roster of one the two sentences below are the ones that # have always been on the page, word for word - AC 8 is about what # an operator sees as much as about what the detector computes, and # "opposed a meter reading" would be a gratuitous change to a line # somebody has learned to recognise. At N>=2 the noun is load- # bearing: the row would otherwise send them to look at P1 for a # fault that is visible only on one inverter's own power sensor. # UX-08: at N>=2 `unit` is set, and the row says "its own power". # The figures stay in `sign_wd.reason`, on the log and event list. 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. With one inverter the site comparison and the row # above are the same sentence about the same actuator; printing it twice # would advertise two guards where there is one, which is how an # operator learns to skim the list. if len(self.inverters) > 1: if self.site_sign_wd.tripped: out.append({"ok": False, "warn": False, "id": "site_direction", "text": messages.row_site_direction_trip()}) else: _sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc)) out.append({"ok": True, "warn": False, "id": "site_direction", "text": messages.row_site_direction_check( _sheld, self.site_sign_wd.cfg.hold_s)}) # --- SAFETY-31 ----------------------------------------------------- # ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and the row says # so. There is one circulating figure for the site; naming an inverter # here would invite the operator to clear "the other one". _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. Every # other latched row in this list means the site is at 0 W and a red # cross is the truth. This one means the site is STILL RUNNING and is # quietly short by one unit's share; rendering it as a hard failure # would say the controller had stopped, which it has not. It is not "ok" # either - `ok: False` with `warn: True` is this list's caution, and a # caution is what a fault nobody is acting on actually is. 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. At one inverter the reduction this check is has no proper # subset to find, for EVERY input, so "armed" would be a claim to be # watching something that cannot happen. 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()}) # --- TEL-11 ---------------------------------------------------------- # ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A DELIBERATE # DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. That one # nags because an unset forecast entity leaves a MONEY GUARD without an # input; this one is pure instrumentation, and a permanent caution on # every install that never asked for a PV channel is exactly the # "no warning spam, no degraded status row" the card forbids. # # ⚠️ AND WHEN IT IS SET, A FAILED READ IS `ok=False, warn=True` - a # CAUTION, never `bad`. `status_payload()` builds "NOT READY" from rows # that are neither ok nor warn, and a telemetry channel that could put a # battery site into NOT READY would be observability with a foot in the # control path. The reason comes from `pv.watts()` rather than being # re-derived here, so the page cannot disagree with the reading. # # TEL-12 kept 0.11.0's single-entity sentences word for word. UX-08 # replaced them on purpose (DECISIONS/UX-08-short-notifications.md), so # a `tools/tel11_pv_trace.py` compare against a pre-UX-08 tree shows # these rows as changed telemetry; the control documents stay equal. 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. A CAUTION and never a `bad` row: telemetry must not stop # control. 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-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. A banner that # can be out-ranked does not STATE a thing plainly; it states it # whenever nothing else is happening, which is the half of the time # nobody needs telling. # # ⚠️ AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE. The three # `bad` branches below all say some version of "held at 0 W", which is # still true in shadow mode - but an operator who turns shadow mode off # with a latch up gets a battery that will not move and a page that # never mentioned it. `shadow.banner()` names the latch and goes red # for it, so nothing this cascade would have said is lost. _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. This one goes first because it # is the fault the operator has NO OTHER WAY of learning about: a # sign disagreement is visible at the meter and on every dashboard # in the house, and circulating power is visible nowhere at all. 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. A site sign trip means EVERY # inverter is parked and the fault is in what the site was asking # for, not in one device - naming a unit here would send an operator # to swap the wrong box. level, banner = "bad", messages.banner_site_direction() elif any(i.sign_wd.tripped for i in self.inverters): # Outranks "Not ready": nothing is missing, something went wrong. # At a roster of one the unit was graded against the meter; at N>=2 # against its own power, so the banner says so - see checks() on # why the noun is load-bearing rather than cosmetic. level, banner = "bad", messages.banner_direction( len(self.inverters) > 1) elif bad: # ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may # need to say more to an operator reading the checks list than fits # in the one line at the top of the page - SAFETY-25's floor-limited # row is the case that forced this - and a headline that is a full # paragraph, repeated verbatim by the row underneath it, is a # headline nobody reads. UX-08: without the key, the banner takes # the row's first sentence, so it stays a headline. level, banner = "bad", messages.banner_not_ready(bad[0]) elif not self.auto: level, banner = "warn", messages.banner_stopped() 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. # Every `bad` banner above means the site is at 0 W; this one means # the site is STILL RUNNING and quietly short by one unit's share, # and a red headline would say the controller had stopped. # # ⚠️ BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT # IS NOT COSMETIC. That sentence is a claim the site cannot make # while a unit is absent - the loop is saturated and the meter is # short - and "the page reads healthy while the fault runs" is # precisely the shape this whole card exists to remove. It sits # BELOW "Stopped" because a stopped site is the more urgent fact and # the banner carries one sentence. level, banner = "warn", messages.banner_not_delivering( [i.name for i in self.inverters if i.delivery_wd.tripped]) elif self.maint.phase != IDLE: # ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster # of one this is the string it has always been, character for # character; at two, "Maintenance: drain" without a name is a # banner an operator cannot act on, because the remedy is at one # device and the site has two. level, banner = "warn", f"Maintenance: {self.maint.phase}" if len(self.inverters) > 1: banner += f" ({self.maint_unit.name})" else: level, banner = "ok", "Running — holding grid at zero" 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. `None` means # "never run, due now" and has to win over any date, so it is tested for # rather than sorted with. At a roster of one this is that pack's own # date, unchanged. _now = datetime.now(timezone.utc) _dues = [m.next_due(_now) for m in self.maints] 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. Only shown at N>=2 - at one inverter it is # identically zero and a permanent "0 W" row would be noise. *(() 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". It sits beside Grid and Battery because the # question it answers is the one those two cannot: at "Grid 0 W, # Battery 0 W" the site is either becalmed or throwing away every # watt it makes, and only this row separates them. *(() 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 ()), ("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), # 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. # It is its own row rather than a clause of "Tariff" because it # answers a different question: "Tariff" says what the plan IS, and # this says what the plan could still be filled from if the feed is # late again tomorrow. ("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. The # claim was that `self.arb is None` **if and only if** # `arbitrage.enabled(self.o)` was False. Only the forward direction # holds: `cycle()` returns at the input hold (`bad_for < # stale_input_s`, default 15 s) BEFORE it reaches `self.arb = None` # at all, so on an armed site `self.arb` is `__init__`'s None at # startup and the PREVIOUS cycle's decision during a later hold - # and the row rendered `off (arbitrage_enabled)` on a site whose # `arbitrage_enabled` is `true`. `arbitrage_row()` reads the option # instead of inferring it, and separates "trading is off" from "no # decision was reached this cycle"; `cycle_at` and `arb_at` are what # make the second one an observation rather than a guess. ("Arbitrage", arbitrage_row( on=arbitrage.enabled(self.o), decision=self.arb, decided_at=self.arb_at, cycle_at=self.cycle_at, now=datetime.now(timezone.utc), why=self.why_words)), ] out = { "banner": banner, "level": level, "rows": rows, "checks": checks, "hint": self.hint, # The action log itself, newest first. The page does not show it # (UX-08 2d), but an installer can still read it in the `status` # JSON, because several events (a dropped inverter's reason, the # peak guard's projection) are written nowhere else. "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)) 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 async def handle_action(self, what: str): now = datetime.now(timezone.utc) if what == "auto_toggle": self.auto = not self.auto self.store.set("auto", self.auto) self.log_event("control started" if self.auto else "control stopped", hint=messages.hint("control_started" if self.auto else "control_stopped")) if self.auto: # ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose. # It clears on a deliberate human start, not on the condition # going away - the condition ALWAYS goes away, because the trip # commands 0 W and a 0 W command cannot disagree with anything. # An operator pressing start has, at minimum, looked at the # banner that says why. # # ⚠️ IT CLEARS EVERY INVERTER'S LATCH, AND SAYS SO PER INVERTER. # One button is what the UI has and one button is what §15's # banner tells the operator to press; what must not happen is a # press that clears A while B stays latched with nothing on the # page changing. Each latch is cleared under its OWN key, so an # install that had only B tripped comes back with only B's # history cleared. for inv in self.inverters: if not inv.sign_wd.tripped: continue inv.sign_wd.reset() inv.store_set("sign_tripped", False) inv.store_set("sign_reason", "") self.log_event(f"sign-disagreement failsafe cleared by " f"operator on {inv.name}", hint=messages.hint("stop_cleared", unit=inv.name)) # ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, UNCONDITIONALLY. # This is what makes `flat True + namespaced False` MEAN # something, and it is the reason `_grade_store_migration()` can # run on every start instead of only on the start that migrates. # # Without it the flat key is a fossil that says `true` forever # on any install that ever tripped, so a guard comparing against # it would re-arm the failsafe on every restart for the rest of # the install's life - which is why that guard used to be gated # on `rep.ran`, and why the gate then swallowed the rollback # ROUND TRIP: migrate, downgrade, trip during the downgrade # window (the old code writes only the flat key), upgrade again # -> `rep.ran` is False, the guard never runs, and the # controller comes up UNLATCHED while /data/state.json says # `sign_tripped: true`. That is store.py's own header sentence, # rebuilt by the version marker. # # Cleared here, the two spellings can only disagree one way: the # flat key is newer than the namespaced one, which means it was # written by a version that did not know about namespaces - i.e. # a trip inside a downgrade window. Unconditional rather than # inside the loop because an operator pressing START is # declaring the whole site clear, and the fossil belongs to no # unit in particular. 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. Only a human # who has read the banner can say the site is clear. # # ⚠️ UNCONDITIONAL, LIKE THE FLAT PAIR ABOVE, AND NOT GUARDED ON # THE ROSTER. An install that ran with two inverters, tripped, # and was then reconfigured back to one would otherwise carry # `site_sign_tripped: true` in its file forever, with no roster # that can reach the code which clears it - and would re-arm the # moment a second inverter was configured again. 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. Only a human who has read the banner can say the site # is clear. # # ⚠️ AND `_circ_announced` GOES WITH IT. It is the "has a human # been shown this" flag; leaving it True through a clear would # mean the NEXT trip in this process latched, stopped the site, # and raised no notification at all. 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. The other three latches are cleared here # because their trip removed its own evidence - a site commanding # 0 W cannot oppose anything - so only a human can say the fault # is gone. This one commands nothing, so its evidence is still # standing and it could in principle clear itself. It does not: # a unit that stopped delivering for minutes and then resumed is # an INTERMITTENT fault, and a detector that erased its own # record of that would be the least useful thing in this file. # ⚠️ AND `_delivery_announced` GOES WITH IT, like # `_circ_announced`: leaving a name in it through a clear would # mean the NEXT trip on that unit latched and raised no # notification at all. 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(). # Zeroing `self.target` alone would leave each unit's last share # standing for its next heartbeat to re-emit, which an operator # would read as the stop button not working. await self.command_site_zero() return {"auto": self.auto} if what == "maint_start": # ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS # ONE LABEL. `_maint_button()` answers it: the pack that holds the # site's maintenance slot if one does - which is what "start" # already meant at a roster of one, where pressing it mid-drain # re-entered DRAIN - and otherwise the pack that has gone longest # without a cycle, which is the same pack `arbitrate()` would have # picked on its own. At N=1 both branches are `maints[0]` and this # is the shipped call, byte for byte. # # A PER-PACK BUTTON IS A UI CARD, NOT THIS ONE. `web.py` posts a # bare `maint_start` with no unit, and inventing a unit parameter # here would ship half a surface: the page would still have one # button and the API would have two meanings. _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 # SAFETY-61. Exit status when the control task dies; 70 is sysexits(3) # EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md. EXIT_CONTROL_DIED = 70 class ControlTaskDied(RuntimeError): """The control task ended while nothing had asked it to stop.""" def 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 every inverter is " "set to 0 W and the add-on exits with code %d.", "cancelled" if task.cancelled() else (repr(exc) if exc else "returned"), 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. `GET /addons/self/info` and `GET /info` are on the Supervisor's bypass list for an add-on's own token, so they need no role (see selfconfig.py). Asked together with a 3 s budget: a link may never hold up control start. """ 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 amain() -> None: opts = load_options() logging.basicConfig( level=getattr(logging, str(opts.get("log_level", "info")).upper(), logging.INFO), format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", ) _LOG.info("GoodWe RS485 Controller starting") from .store import Store store = Store() async with aiohttp.ClientSession() as session: hass = HomeAssistant(session) # Startup self-check: prove we can actually reach the Core API before # anything tries to control an inverter with it. A 401 here is a # permissions/token problem, not a configuration mistake, and saying so # explicitly saves an installer from re-checking entity ids for an hour. import os as _os _tok = _os.environ.get("SUPERVISOR_TOKEN", "") _LOG.info("supervisor token: %s (%d chars); env has: %s", "present" if _tok else "MISSING", len(_tok), ",".join(sorted(k for k in _os.environ if "TOKEN" in k.upper())) or "none") try: async with session.get("http://supervisor/core/api/", headers={"Authorization": f"Bearer {_tok}"}, timeout=10) as _r: _LOG.info("core api probe: HTTP %s %s", _r.status, (await _r.text())[:80]) except Exception as _e: # noqa: BLE001 _LOG.error("core api probe failed: %s", _e) # UX-09: the Details link in every notification needs this add-on's # slug and Home Assistant's version (see messages.docs_url()). Both # are best effort: without them the link falls back to the hostname, # or to naming the section only. 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. # # It sits HERE, before the broker, before the web server and before any # task exists, so a refusal cannot leave half a controller running with # a UI in front of it. Reading the device range is the one thing the # validator cannot do for itself; (None, None) when ESPHome has not come # up yet is not a refusal - run_control() re-checks once the real rating # is known. See validate.py for where the line is drawn and why. _sp = str(opts.get("setpoint_entity", "") or "").strip() _dev_min = _dev_max = None if _sp: _dev_min, _dev_max, _ = await hass.limits(_sp) _LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max) _problems = validate_startup_config(opts, _dev_min, _dev_max) # UX-15: a new install names no inverter. When that is the ONLY # refusal it serves the setup wizard in setup-only mode, which builds # nothing that commands and returns when stopped. Any other refusal # still exits 78 (DECISIONS/UX-15-setup-only-mode.md). if _problems and setuponly.applies(opts, _problems): await setuponly.serve( opts, HomeAssistant(session, read_only=True), store, port=INGRESS_PORT) return if _problems: refuse(_problems) raise ConfigRefused(_problems) # ⚠️ Status publishing must NEVER be able to stop the controller. A # broken broker, a missing library, an API change in paho - all of it is # observability, and the battery does not care. Caught broadly and on # purpose: this crashed the add-on once already (paho 1.x vs 2.x) and # took the control loop down with it. broker = None try: broker = await hass.mqtt_service() pub = MqttPublisher( broker.get("host") if broker else None, broker.get("port", 1883) if broker else 1883, broker.get("username") if broker else None, broker.get("password") if broker else None, omit=() if is_enabled(opts) else ("p1_age",), # TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE # OPTION - `Controller.publish()` gates the row on this same # call. An install with no PV roster announces exactly # what it announced before: no topic, no entity, no row. include=optional_keys(opts), # TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here # would construct a SECOND set of `Inverter`s off the same # Store, minutes before `Controller.__init__` constructs the # real ones; `roster_names()` is the naming rule on its own and # is what `build_roster()` itself reads. At a roster of one this # announces nothing new - see `announced_units()`. units=roster_names(opts), ) 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) runner = await web.start(controller, port=INGRESS_PORT) loop = asyncio.get_running_loop() for sig in (signal.SIGTERM, signal.SIGINT): with contextlib.suppress(NotImplementedError): loop.add_signal_handler(sig, stop.set) # SAFETY-61: the control task's death ends the process, after the # wind-down below. See DECISIONS/SAFETY-61-control-task-death.md. died: list = [] control = asyncio.create_task(controller.run_control()) control.add_done_callback(functools.partial( control_task_done, controller=controller, stop=stop, died=died)) tasks = [control] # OPT-04. The tariff refresh runs as its OWN long-lived task, for the # reason P1 ingestion does and then some: `resolve_tariff()` may open an # HTTPS connection to a supplier, and the control tick is what feeds the # ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able # to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S. # # ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED # - TARIFF-10, and it used to be the second term alone. `arbitrage_ # enabled` ships `false`, so a site that had selected a price feed # fetched nothing at all; the whole argument, the measurement and what # the `or` is protecting are in `tariff_refresh_wanted()` above. # # ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are # two decisions; this is the first, and `cycle()`'s # `if arbitrage.enabled(self.o):` is still the only gate on the second. if tariff_refresh_wanted(opts): tasks.append(asyncio.create_task(controller.run_tariff_refresh())) # P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven # off the control loop: telegrams arrive every ~5 s and the loop would # decimate them, so the 15-minute average - the capacity-tariff billing # unit - would be computed from a fraction of the data. p1_source = build_source(opts, controller.p1, session, broker) if p1_source is not None: controller.p1_source = p1_source # so checks() can report on it tasks.append(asyncio.create_task(p1_source.run())) await stop.wait() await controller.shutdown() for task in tasks: task.cancel() for task in tasks: if task is control and died: continue # already logged by control_task_done() with contextlib.suppress(asyncio.CancelledError): await task await runner.cleanup() _LOG.info("stopped") if died: raise ControlTaskDied(died[0]) # The late-arriving-device refusal from run_control(). Re-raised only # now, so the wind-down above still ran and the inverter is at 0 W. if controller.refused: raise ConfigRefused(controller.refused) def main() -> None: try: asyncio.run(amain()) except KeyboardInterrupt: pass except ConfigRefused: # ⚠️ No traceback, on purpose. The banner refuse() already printed says # everything an operator needs, and a stack trace on top of it would say # "this crashed" - the exact confusion AC3 is about. 78 is sysexits(3) # EX_CONFIG, so even a script reading the exit status can tell a bad # config from a bug. 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()